Initial Commit
This commit is contained in:
251
openspec/archive/2026-04-19-enterprise-testing/design.md
Normal file
251
openspec/archive/2026-04-19-enterprise-testing/design.md
Normal file
@@ -0,0 +1,251 @@
|
||||
## 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.
|
||||
Reference in New Issue
Block a user