13 KiB
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,traitsonFactory) 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:
vitestcondition →src/runner/vitest.ts(re-exports vitest primitives)buncondition →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/)
RequestBuilderclass; quick helpersget,post,put,patch,del.ResponseBuilderclass; quick helpersok,created,noContent,badRequest,unauthorized,notFound,serverError.- Types:
RequestBuilderPreset,RequestBuilderState,ResponseBuilderPreset,ResponseBuilderState,TestRequest,TestResponse,HttpMethod,HttpHeaders,QueryParams.
Clock (src/clock/)
FakeClockclass (implements the localClockinterface).- Types:
ClockSnapshot,FakeClockOptions,ScheduledTimer.
Factories (src/factories/)
defineFactory<T>(definition),Factory<T>class.- Types:
FactoryBuildOptions,FactoryDefaults,FactoryDefinition,BatchBuildResult.
Fixtures (src/fixtures/)
FixtureManagerclass.- Types:
FixtureDefinition,FixtureState,SetupAllResult,TeardownAllResult.
Storage (src/storage/)
MemoryStoreclass.- 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/
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
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:
// 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
// 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:
"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)
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: falsefor browser envs- React plugin loaded only when
environment === "happy-dom"and@vitejs/plugin-reactis 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/testingroot bundle. Mitigation: schemas in peer*.schemas.tsfiles, not imported by functional entry points. Tree-shake test enforces. - Local
TestingErrordiverging from@suaveplan/error. Mitigation: shape locked to{ code, cause, context }today. A future adapter is a one-file bridge. @opentelemetry/apiversion 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: 100in 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:createTestConfigis 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 increateTestConfig's Node deps.