Files
2026-05-27 23:29:53 +01:00

13 KiB

ADDED Requirements

Requirement: Universal purity

The @suaveplan/testing package SHALL depend on zero browser-only, Node-only, or React APIs at the module-graph level for its core runtime exports. The ./config subpath and ./polyfills subpath MAY reference Node built-ins because they are explicitly Node-side and are never pulled transitively by browser-runnable exports.

Scenario: Browser-runnable exports avoid Node built-ins

  • WHEN a browser target imports FakeClock, RequestBuilder, ResponseBuilder, Factory, FixtureManager, MemoryStore, or any helper
  • THEN the resolved module graph MUST NOT reference node:fs, node:path, node:url, process, Buffer, or any Node-only API

Scenario: Node-side subpaths are segregated

  • WHEN a consumer imports @suaveplan/testing/config or @suaveplan/testing/polyfills
  • THEN those imports resolve to modules that are allowed to reference Node built-ins AND the core export (@suaveplan/testing) MUST NOT transitively re-import them

Requirement: No @suaveplan/* workspace dependencies

The @suaveplan/testing package dependencies, peerDependencies, and devDependencies MUST NOT list any @suaveplan/* scoped package. External catalog deps only (vitest, happy-dom, ws, tslib, zod, @opentelemetry/api, fast-check, mitata, @types/*, build tooling).

Scenario: Dep-graph guard

  • WHEN bunx turbo ls --filter=@suaveplan/testing... is run
  • THEN no @suaveplan/* package appears in the forward closure of @suaveplan/testing

Requirement: Public types validated via Zod

Every public function's input and output types SHALL be modeled by a Zod schema exported from a sibling <module>.schemas.ts file under a stable namespace (e.g. RequestBuilderSchemas.input). Schemas MUST NOT be imported by functional entry points; consumers opt in by importing *.schemas.ts directly.

Scenario: Runtime validation available on opt-in

  • WHEN a consumer imports FactorySchemas.buildOptions from @suaveplan/testing/factories/factory/factory.schemas and calls .parse(value)
  • THEN invalid input is rejected with a ZodError whose issues[].path points to the offending field

Scenario: Schemas do not bloat the functional bundle

  • WHEN a build imports only Factory from @suaveplan/testing
  • THEN the resolved bundle MUST NOT include Zod or any *.schemas.ts module

Requirement: Structured error hierarchy

The package SHALL throw only subclasses of TestingError extends Error with a stable code of the form TESTING.<DOMAIN>.<REASON> (plain uppercase ASCII, dots as separators). Errors MUST carry { code, cause?, context? } fields. The package MUST NOT throw plain Error or TypeError from any public code path.

Scenario: Timer overflow throws a coded error

  • WHEN FakeClock is asked to schedule more than MAX_TIMERS timers
  • THEN it throws TestingError with code === "TESTING.CLOCK.MAX_TIMERS_EXCEEDED" AND context.limit === MAX_TIMERS

Scenario: Fixture setup failure preserves cause

  • WHEN a fixture's setup function throws
  • THEN FixtureManager.setupAll throws TestingError with code === "TESTING.FIXTURES.SETUP_FAILED" AND cause is the original thrown value AND context.fixtureName is the failing fixture's name

Scenario: Abort signal triggered mid-wait

  • WHEN waitFor(predicate, { signal }) runs with a signal that aborts during polling
  • THEN the returned promise rejects with TestingError where code === "TESTING.ABORTED" AND cause === signal.reason

Requirement: Deterministic clock and RNG via dependency injection

Every public function that reads time, randomness, or iterates SHALL accept an optional { clock?: Clock; rng?: Rng; signal?: AbortSignal } options parameter. Zero-config callers MUST get behaviorally stable defaults: defaultClock (backed by Date.now / performance.now), cryptoRng (backed by crypto.getRandomValues), and no abort.

Scenario: Injected clock controls observed time

  • WHEN waitFor(predicate, { clock: fakeClock }) runs and fakeClock.advance(1000) is called
  • THEN the helper observes 1000 ms elapsed AND does NOT read Date.now() or performance.now() directly

Scenario: Injected RNG produces deterministic factory output

  • WHEN Factory.build({ rng: seededRng(42) }) is called twice with the same factory definition
  • THEN both calls return byte-identical output (for factories whose definition is otherwise deterministic)

Scenario: Module-scope non-determinism absent

  • WHEN the built dist/ is grep'd for Math.random( or Date.now( or performance.now(
  • THEN those symbols appear only inside the defaultClock / cryptoRng module (and FakeClock's internal simulation), never at module scope in other files

Requirement: Cooperative cancellation via AbortSignal

Every iterative or async helper SHALL check its signal?.aborted property at bounded intervals (≤ 256 items or ≤ 1 KB scanned per check) and throw TestingError("TESTING.ABORTED", …) on first observed abort without returning partial results.

Scenario: Batch factory aborts within one tick

  • WHEN Factory.buildBatch(10000, { signal }) is running and signal.abort() is called after 200 ms
  • THEN the promise rejects with TESTING.ABORTED within one event-loop tick of the abort

Scenario: Fixture teardown aborts between fixtures, never mid-fixture

  • WHEN FixtureManager.teardownAll({ signal }) is mid-teardown and the signal fires
  • THEN the current fixture's teardown completes AND the next fixture is NOT started AND the promise rejects with TESTING.ABORTED

Requirement: Dual-runner conditional exports

The package exports map SHALL route @suaveplan/testing via a vitest condition to the vitest-backed barrel, a bun condition to the bun-test shim, and a default condition to the vitest barrel. The vitest barrel MUST re-export describe, it, test, expect, afterAll, afterEach, beforeAll, beforeEach, vi, fn, mock, spy, stub, fakeClock, restoreMocks, restoreTimers. The bun-test shim MUST expose the same names via bun-test primitives.

Scenario: Vitest consumer resolves vitest primitives

  • WHEN a package with vitest in its test runner configuration imports { describe, it, expect } from @suaveplan/testing
  • THEN resolution MUST pick the vitest condition and the imported symbols MUST be vitest's

Scenario: Bun-test consumer resolves bun-test primitives

  • WHEN a package configured for bun test imports { describe, it, expect } from @suaveplan/testing
  • THEN resolution MUST pick the bun condition and the imported symbols MUST come from bun's test runner

Requirement: Observability via @opentelemetry/api

Every public entrypoint SHALL be instrumented with an OTEL counter (testing.<domain>.<op>.count), a histogram (testing.<domain>.<op>.duration_ms), and an error counter (testing.<domain>.<op>.errors with code attribute on error). Spans SHALL be created only for iterative operations (waitFor, FixtureManager.setupAll/teardownAll, Factory.buildBatch). When no OTEL SDK is registered, all instrumentation MUST be a no-op at runtime.

Scenario: Counter increments on every call

  • WHEN RequestBuilder methods are called N times during a test run with a registered OTEL SDK
  • THEN the metric testing.builders.request.count has recorded N increments

Scenario: Error counter records code attribute

  • WHEN FakeClock.schedule throws TESTING.CLOCK.MAX_TIMERS_EXCEEDED
  • THEN the metric testing.clock.schedule.errors records an increment with attribute code = "TESTING.CLOCK.MAX_TIMERS_EXCEEDED"

Scenario: Zero overhead without SDK

  • WHEN the benchmark suite runs without any OTEL SDK registered
  • THEN the difference between the OTEL-wrapped public entrypoint and an identical unwrapped baseline MUST be less than 5% per op

Requirement: Coverage and docs gates

Istanbul statement + branch coverage across packages/universal/testing/src/ (excluding *.bench.ts and *.schemas.ts) MUST be ≥ 95%. Every implementation file (*.ts other than *.test.ts, *.bench.ts, *.schemas.ts, inner-dir *.types.ts, barrel index.ts) MUST have a sibling .md file of ≥ 200 words (excluding code fences) with a simple and an advanced usage example.

Scenario: Coverage gate in CI

  • WHEN bunx turbo test:coverage --filter=@suaveplan/testing runs
  • THEN reported statement coverage is ≥ 95% AND branch coverage is ≥ 95%

Scenario: Docs coverage enforced

  • WHEN a script enumerates implementation files under src/ (excluding the stated exclusions) and checks for sibling .md files
  • THEN every implementation file has a sibling .md AND every .md is ≥ 200 non-code words

Requirement: No disallowed constructs in src/

Files under packages/universal/testing/src/ MUST NOT contain the any keyword, @ts-ignore, @ts-expect-error, TODO, FIXME, @deprecated, it.skip, describe.skip, test.skip, .only, or any mock/stub of runtime behaviour intended for production code paths.

Scenario: Biome scan clean

  • WHEN bunx biome check src/ runs with the project's strict config
  • THEN the scan reports zero noExplicitAny diagnostics AND zero suppressed directives

Scenario: Grep guard

  • WHEN grep -rnE "TODO|FIXME|@deprecated|\\.skip\\(|\\.only\\(|ts-ignore|ts-expect-error" src/ runs
  • THEN zero matches are produced

Requirement: sideEffects: false and subpath exports

The package MUST declare "sideEffects": false in package.json AND provide explicit subpath exports for every module intended as a distinct import surface. A tree-shake test MUST prove that importing one submodule does not pull unrelated submodules.

Scenario: Tree-shake test passes

  • WHEN a test imports only FakeClock from @suaveplan/testing and reads the resolved bundle text
  • THEN the bundle MUST NOT contain identifiers unique to RequestBuilder, MemoryStore, FixtureManager, or Factory

Requirement: Property-based coverage for pure helpers

Pure functions and deterministic state machines in the package (clock arithmetic, builder state transitions, factory deterministic output, memory-store TTL expiry, wait-for polling) SHALL have fast-check property tests covering round-trip, idempotency, associativity, and boundary invariants as applicable.

Scenario: Builder composition is associative

  • WHEN fast-check runs over arbitrary RequestBuilder presets a, b, c applied in different groupings
  • THEN (a then b) then c produces the same final state as a then (b then c)

Scenario: FakeClock advance + schedule commutes within a tick

  • WHEN fast-check runs over sequences of advance and schedule calls that never cross a timer threshold
  • THEN reordering within a single synchronous batch produces the same observed state

Requirement: Benchmark gate in CI

A mitata benchmark suite under src/__benchmarks__/ SHALL record baselines in benchmarks/baselines.json. CI SHALL rerun the benchmarks (3-strike, take fastest), compare to baselines, and fail if any op regresses by more than 10%. Baseline updates MUST land on a dedicated, reviewer-approved commit separate from functional changes.

Scenario: Regression gate trips

  • WHEN a PR introduces a 15% slowdown on Factory.build
  • THEN the CI benchmark step fails the build

Scenario: Baseline update is intentional

  • WHEN a maintainer intentionally updates benchmarks/baselines.json
  • THEN the baseline commit is separate from functional code changes and is reviewed before landing

Requirement: Plugin surface on FixtureManager

FixtureManager SHALL accept plugins implementing { name: string; beforeEach?; afterEach?; onError? } and invoke their hooks around each fixture's own setup/teardown lifecycle. Plugins MUST NOT be able to cancel a teardown mid-flight except via onError.

Scenario: Plugin hook fires around each fixture

  • WHEN a FixtureManager with a plugin registered runs setupAll over two fixtures
  • THEN the plugin's beforeEach is called exactly twice in fixture-registration order AND afterEach is called exactly twice in reverse order

Requirement: createTestConfig() absorbs WSL2 pool and polyfill concerns

The @suaveplan/testing/config factory SHALL produce a Vitest config with pool: "forks", setupFiles: ["@suaveplan/testing/polyfills"], and — when the caller requests happy-dom — fileParallelism: false AND dynamically-loaded @vitejs/plugin-react if that optional peer is installed. When the caller's environment requests bun-test, the factory SHALL return an equivalent bun-test config instead.

Scenario: Happy-dom caller gets React plugin

  • WHEN createTestConfig({ environment: "happy-dom" }) is awaited and @vitejs/plugin-react is installed
  • THEN the returned config includes the React plugin in its plugins array AND pool === "forks" AND setupFiles includes the polyfills path

Scenario: bun-test caller gets bun config

  • WHEN createTestConfig({ runner: "bun:test" }) is awaited
  • THEN the returned value is a BunTestConfig with pool not set (bun-test has no concept of pool) AND any warnings about missing preload hooks are logged once