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

13 KiB
Raw Blame History

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/

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: 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.