Files
suaveplan/openspec/archive/2026-04-19-enterprise-testing/design.md
2026-05-27 23:29:53 +01:00

252 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
`@suaveplan/testing` sits at the bottom of the genesis dependency graph. Every other `@suaveplan/*` package uses it to write unit/integration tests. The archived `2026-04-08-testing-libraries` change on main established the current shape — dual-runner conditional exports (vitest default / bun condition), sub-path exports for `./config` / `./utils` / `./polyfills`, full mock API, `createTestConfig` factory absorbing WSL2 pool + polyfill settings. That shape is load-bearing and must survive.
Constraints specific to genesis:
- **Rule #9** — no `@suaveplan/*` workspace deps. External catalog deps only.
- **Layer 0** — nothing else on genesis has been built yet. No consumers to migrate. The audit phase is surface-preservation against main's source, not consumer churn.
- **Version** — starts at `0.1.0`; stays there until graduation cut.
- **Enterprise bar** — `enterprise/testing.md` §6 acceptance criteria is the gate for marking the package ✅ Enterprise.
## Goals / Non-Goals
**Goals:**
- Deliver every capability from `enterprise/testing.md` §3 and §4 within the genesis-rule constraints.
- Preserve the full public surface inventoried below; no signature shrinkage.
- Make the package a reference implementation for the enterprise checklist in `openspec/project.md` — so subsequent genesis packages can copy its patterns (error shape, Zod schema layout, OTEL wrapping, Clock/Rng/Signal DI, property tests, benchmarks).
- Dual-runner condition (vitest default, bun-test as a peer) remains intact.
**Non-Goals:**
- Introducing new user-facing APIs. The §4 "feature-rich" items (plugin interface on `FixtureManager`, `traits` on `Factory`) are additive to the existing shape, not replacements.
- Supporting any runner beyond Vitest and bun-test.
- Shipping a `@suaveplan/*` adapter for schemas / errors / OTEL — those integrations land when each matching package reaches enterprise on a later layer.
- Re-routing public messages through i18n — English-only by design (rule parallel to utils D10).
## Surface Inventory (public exports that MUST survive)
From `/mnt/s/web/suaveplan/packages/universal/testing/src/` — every name below is preserved unchanged in type shape; internals are rewritten.
### Runner (`src/runner/`)
Conditional export routing via `package.json`:
- `vitest` condition → `src/runner/vitest.ts` (re-exports vitest primitives)
- `bun` condition → `src/runner/bun-test.ts` (bun-test shim)
- default → `src/runner/index.ts` (barrel for the vitest path)
Exports (from the default condition): `afterAll`, `afterEach`, `beforeAll`, `beforeEach`, `describe`, `expect`, `fakeClock`, `fn`, `it`, `mock`, `restoreMocks`, `restoreTimers`, `spy`, `stub`, `test`, `vi`.
### Builders (`src/builders/`)
- `RequestBuilder` class; quick helpers `get`, `post`, `put`, `patch`, `del`.
- `ResponseBuilder` class; quick helpers `ok`, `created`, `noContent`, `badRequest`, `unauthorized`, `notFound`, `serverError`.
- Types: `RequestBuilderPreset`, `RequestBuilderState`, `ResponseBuilderPreset`, `ResponseBuilderState`, `TestRequest`, `TestResponse`, `HttpMethod`, `HttpHeaders`, `QueryParams`.
### Clock (`src/clock/`)
- `FakeClock` class (implements the local `Clock` interface).
- Types: `ClockSnapshot`, `FakeClockOptions`, `ScheduledTimer`.
### Factories (`src/factories/`)
- `defineFactory<T>(definition)`, `Factory<T>` class.
- Types: `FactoryBuildOptions`, `FactoryDefaults`, `FactoryDefinition`, `BatchBuildResult`.
### Fixtures (`src/fixtures/`)
- `FixtureManager` class.
- Types: `FixtureDefinition`, `FixtureState`, `SetupAllResult`, `TeardownAllResult`.
### Storage (`src/storage/`)
- `MemoryStore` class.
- Types: `MemoryStoreEvent`, `MemoryStoreStats`, `MemoryStoreOptions`, `StoreEntry`.
### Helpers (`src/helpers/`)
- `assertThrows`, `assertThrowsAsync`, `createCollector`, `deferred`, `measureTime`, `resetTestIdCounter`, `sleep`, `uniqueTestId`, `waitFor`.
- Types: `WaitForOptions`.
### Constants (`src/constants/`)
- `DEFAULT_CLOCK_OPTIONS`, `DEFAULT_HTTP_STATUS`, `DEFAULT_RESPONSE_LATENCY_MS`, `DEFAULT_STORE_OPTIONS`, `DEFAULT_WAIT_OPTIONS`, `MAX_FIXTURES`, `MAX_STORE_ENTRIES`, `MAX_TIMERS`.
### Config subpath (`@suaveplan/testing/config`)
- `createTestConfig(options?)` async factory.
- Types: `TestConfigOptions`, `TestRunner`, `TestEnvironment`, `VitestConfigOverrides`, `BunTestConfig`, `BunTestConfigOverrides`.
### Utils subpath (`@suaveplan/testing/utils`)
Re-exports every non-runner symbol for callers that want a single import without pulling the runner surface.
### Polyfills subpath (`@suaveplan/testing/polyfills`)
Side-effecting module loaded by `setupFiles` in consumers' vitest configs. Installs whatever global shims (e.g. `crypto`) the happy-dom environment needs on WSL2.
## Decisions
### D1: Local `Clock` and `Rng` interfaces in `src/context/`
```ts
export interface Clock {
now(): number; // ms since epoch
hrtime(): bigint; // nanoseconds, monotonic
}
export interface Rng {
nextUint32(): number; // [0, 2^32)
nextFloat(): number; // [0, 1)
}
export const defaultClock: Clock;
export const cryptoRng: Rng;
export function seededRng(seed: number): Rng;
```
Rationale: matches the shape `@suaveplan/datetime` will eventually expose, so a later genesis layer can ship an adapter without changing call sites. Keeps genesis rule #9 clean. `FakeClock` implements `Clock` so callers can swap seamlessly.
### D2: Local `TestingError` hierarchy
```ts
export type TestingErrorCode =
| "TESTING.CLOCK.MAX_TIMERS_EXCEEDED"
| "TESTING.CLOCK.NEGATIVE_ADVANCE"
| "TESTING.FIXTURES.SETUP_FAILED"
| "TESTING.FIXTURES.TEARDOWN_FAILED"
| "TESTING.FIXTURES.MAX_FIXTURES_EXCEEDED"
| "TESTING.FACTORY.BUILD_FAILED"
| "TESTING.FACTORY.TRAIT_NOT_FOUND"
| "TESTING.STORE.CAPACITY_EXCEEDED"
| "TESTING.STORE.KEY_NOT_FOUND"
| "TESTING.HELPERS.WAIT_TIMEOUT"
| "TESTING.HELPERS.ASSERTION_FAILED"
| "TESTING.BUILDER.INVALID_STATE"
| "TESTING.CONFIG.UNKNOWN_RUNNER"
| "TESTING.ABORTED";
export class TestingError extends Error {
readonly code: TestingErrorCode;
readonly context?: Record<string, unknown>;
constructor(code: TestingErrorCode, message: string, options?: {
cause?: unknown;
context?: Record<string, unknown>;
});
}
```
Subclasses per domain (`ClockError`, `FixturesError`, `FactoryError`, `StoreError`, `HelpersError`, `BuilderError`, `ConfigError`) are thin wrappers narrowing the code union — callers can branch on `err instanceof ClockError`. All still satisfy `err instanceof TestingError`.
Rationale: shape-compatible with eventual `@suaveplan/error`; per-domain subclasses let TypeScript narrow the code; stable strings as machine contract.
### D3: Zod schemas in sibling `*.schemas.ts`
Every implementation file `foo/foo.ts` has a peer `foo/foo.schemas.ts` exporting input/output schemas under a stable namespace. Example:
```ts
// src/builders/request-builder/request-builder.schemas.ts
import z from "zod";
export const RequestBuilderSchemas = {
preset: z.enum(["get", "post", "put", "patch", "delete"]),
input: z.object({
method: z.string(),
url: z.string(),
headers: z.record(z.string()).optional(),
query: z.record(z.string()).optional(),
body: z.unknown().optional(),
}),
output: z.object({ /* … */ }),
} as const;
```
Schemas are NOT pulled in by the functional entry points — they are consumed only when a caller opts in (`RequestBuilderSchemas.input.parse(…)`). Verified by the tree-shake test.
Rationale: respects the enterprise requirement (Zod on public types) without forcing runtime validation on every call. Stable names mean a future `@suaveplan/schema-registry` adapter can enumerate them without modifying this package.
### D4: OTEL via `@opentelemetry/api`, no-op by default
```ts
// src/observability/tracer.ts
import { trace, metrics, type Span, type Counter } from "@opentelemetry/api";
const tracer = trace.getTracer("@suaveplan/testing");
const meter = metrics.getMeter("@suaveplan/testing");
```
Every public entrypoint is wrapped via a local `withObservability(name, fn)` helper. When no SDK is registered (default for Vitest/Bun-test runs), `trace.getTracer()` returns a NoopTracer; overhead is a single function call and object allocation per invocation — immeasurable in practice.
Counters: `testing.<domain>.<op>.count`, `testing.<domain>.<op>.errors{code}`. Histograms: `testing.<domain>.<op>.duration_ms`. Spans: only for iterative operations (`waitFor`, `FixtureManager.setupAll`, `Factory.buildBatch`).
Rationale: `@opentelemetry/api` is the stable public spec — 2 KB, zero transitive Node deps, safe in browsers. Writing against it means a future `@suaveplan/opentelemetry` package that merely bundles an exporter + resource config will just work.
### D5: Dual-runner conditional exports preserved exactly
`package.json` retains:
```json
"exports": {
".": {
"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": { … },
"./utils": { … },
"./polyfills": "./dist/setup/polyfills.js",
"./package.json": "./package.json"
}
```
Rationale: established shape from the archived change. Swapping a package from vitest to bun-test is a single-condition change with no test-source rewrites.
### D6: AbortSignal at bounded checkpoints
`waitFor` checks `signal?.aborted` each poll cycle. `FixtureManager.setupAll` / `teardownAll` check between fixtures. `Factory.buildBatch(n)` checks every 256 items. `MemoryStore` eviction sweeps check every 1024 entries. On abort, throws `TestingError("TESTING.ABORTED", …, { cause: signal.reason })`.
Rationale: deterministic cancellation with predictable cost. Enterprise doc §3 "Backpressure & cancellation".
### D7: Plugin interface on `FixtureManager` (feature-rich)
```ts
export interface FixtureManagerPlugin {
name: string;
beforeEach?(ctx: { fixtureName: string; state: FixtureState }): void | Promise<void>;
afterEach?(ctx: { fixtureName: string; state: FixtureState }): void | Promise<void>;
onError?(ctx: { fixtureName: string; error: TestingError }): void | Promise<void>;
}
```
Rationale: enterprise doc §4 "plugin / extension interface". Downstream genesis packages can contribute fixture-level concerns (e.g. database seeding for a `surrealdb` package) without forking `FixtureManager`.
### D8: Benchmarks in `src/__benchmarks__/<domain>.bench.ts`
Every domain has a bench file. `benchmarks/baselines.json` stores the baseline. CI runs benchmarks 3×, takes the fastest, compares to baseline, fails if any op regresses >10%. Baseline updates land in their own commit with reviewer approval.
Rationale: enterprise doc §4 "Performance". Baselines taken against the genesis implementation, not retrofit.
### D9: `createTestConfig()` absorbs WSL2 realities
The factory handles:
- `pool: "forks"` (vmThreads/threads fail on WSL2)
- `setupFiles: ["@suaveplan/testing/polyfills"]`
- `fileParallelism: false` for browser envs
- React plugin loaded only when `environment === "happy-dom"` and `@vitejs/plugin-react` is installed (optional peer)
- bun-test path when caller's `package.json` / env says so
Preserved exactly from the archived 2026-04-08 change.
## Risks / Trade-offs
- **OTEL overhead on hot paths.** `trace.getTracer().startSpan()` in a noop tracer is ~5 ns — measurable in tight benchmarks. Mitigation: benchmark suite asserts overhead stays below 5% vs a no-OTEL baseline.
- **Zod bundle cost on `@suaveplan/testing` root bundle.** Mitigation: schemas in peer `*.schemas.ts` files, not imported by functional entry points. Tree-shake test enforces.
- **Local `TestingError` diverging from `@suaveplan/error`.** Mitigation: shape locked to `{ code, cause, context }` today. A future adapter is a one-file bridge.
- **`@opentelemetry/api` version churn.** Mitigation: pin via catalog. The API spec itself has been stable across major versions of the SDK.
- **fast-check timeouts on WSL2.** Mitigation: cap `numRuns: 100` in CI; larger runs nightly only. Generator sizes capped per-test.
- **Benchmark flakiness in CI.** Mitigation: mitata stabilization (min samples, outlier trimming), 3-strike retry, 10% absolute floor.
- **Node-only APIs sneaking in (fs/path/url in `createTestConfig`).** Mitigation: `createTestConfig` is explicitly a Node-side factory (runs in vitest's Node context), not a browser-runnable helper. Tree-shake test verifies that importing a browser-runnable helper (e.g. `FakeClock`) does NOT pull in `createTestConfig`'s Node deps.