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

5.8 KiB

Capability Spec Delta: testing-dom (testing-dom-runner-and-matchers-hardening)

Package: @suaveplan/testing-dom Status: PROPOSED

ADDED Requirements

Requirement: Runner-neutral RunnerMock<TArgs, TReturn> type alias

The package SHALL export a RunnerMock<TArgs extends unknown[], TReturn> type alias from both runner-conditional barrels (./runner/vitest.js and ./runner/bun-test.js) AND from the package barrel (./index.js). Both runner paths SHALL implement fn<TArgs, TReturn>(impl?) returning RunnerMock<TArgs, TReturn> so consumer code typed as const m = fn<[string], number>() resolves to a callable (arg: string) => number regardless of which runner is active.

Scenario: vitest path preserves call-signature typing

  • WHEN a consumer running under vitest declares const m = fn<[string], number>()
  • THEN m("hello") type-checks AND ReturnType<typeof m> equals number

Scenario: bun-test path mirrors the vitest typing surface

  • WHEN the same declaration runs under the bun conditional export
  • THEN the imported fn returns a RunnerMock<TArgs, TReturn> carrying the same call signature

Scenario: SpyHandle type alias re-exported

  • WHEN a consumer imports SpyHandle from @suaveplan/testing-dom
  • THEN the type matches ReturnType<typeof spy> on the active runner path

Verified by: src/runner/runner.spec-conformance.test.ts::"fn<[string], number>() preserves call-signature typing" AND src/runner/runner.spec-conformance.test.ts::"fn<[number, number], number>(impl) preserves call-signature typing" AND src/runner/runner.spec-conformance.test.ts::"BunRunnerMock<TArgs, TReturn> shape mirrors the vitest path" AND src/runner/runner.spec-conformance.test.ts::"BunSpyHandle is exported and PkgRunnerMock matches both paths" AND src/runner/runner.spec-conformance.test.ts::"spy returns a SpyHandle assignable to the package's exported type" AND src/runner/runner.spec-conformance.test.ts::"stub returns a SpyHandle assignable to the package's exported type"


Requirement: jest-dom matchers ship ambient type augmentation

The @suaveplan/testing-dom/matchers side-effect import SHALL bring the @testing-library/jest-dom ambient module declaration into the consumer's compilation unit, so expect(el).toBeInTheDocument() and the other common jest-dom matchers type-check without the consumer adding @testing-library/jest-dom to their own tsconfig.json#compilerOptions.types.

Scenario: matcher names are typed as functions

  • WHEN a test file does import "@suaveplan/testing-dom/matchers" and writes expect(node).toBeInTheDocument
  • THEN the expression has type () => void (a callable matcher) under tsc --noEmit

Verified by: src/matchers/matchers.spec-conformance.test.tsx::"expect(element).toBeInTheDocument is typed as a function" AND src/matchers/matchers.spec-conformance.test.tsx::"every common matcher is typed as a function on expect(...)"


Requirement: setupUser resolves vi through the runner adapter

src/user/setup-user.ts SHALL import vi from the runner adapter (./runner/index.js) rather than directly from vitest. This preserves the dual-runner contract: when the bun conditional export is active, the runner adapter resolves to the bun-test path's vi alias backed by bun:test's jest namespace.

Scenario: source-level guard against vitest hard-import

  • WHEN src/user/setup-user.ts is read
  • THEN the file contains no from "vitest" import AND it imports from ../runner/index.js

Verified by: src/user/setup-user.test.ts::"imports vi via the runner adapter (no direct vitest hard-import)"


Requirement: instrumented() ends spans only after async fns settle

instrumented(name, fn) SHALL detect when the wrapped function returns a PromiseLike value and SHALL defer span.end() and the duration histogram record until the returned promise settles. On rejection, the span SHALL recordException (wrapping non-Error rejections in new Error(String(reason))) before ending. On resolution, the duration recorded SHALL reflect the wall-clock interval between invocation and settlement.

Scenario: async resolve

  • WHEN the wrapped fn awaits ≥10ms before returning
  • THEN the awaited result equals the inner return value AND the elapsed wall-clock time is ≥10ms (proving the wrapper did not synchronously close the span)

Scenario: async reject

  • WHEN the wrapped async fn throws an Error
  • THEN the returned promise rejects with the same error (no swallowing)

Scenario: async reject with non-Error reason

  • WHEN the wrapped async fn rejects with a string
  • THEN the returned promise rejects with the same string AND recordException is invoked with a wrapped Error

Verified by: src/observability/instrumented.test.ts::"ends span only after async fn settles (resolved)" AND src/observability/instrumented.test.ts::"ends span only after async fn settles (rejected)" AND src/observability/instrumented.test.ts::"ends span only after async fn settles (rejected with non-Error)"

MODIFIED Requirements

Requirement: Coverage and docs gates

Istanbul statement + branch + function + line coverage across packages/dev/testing-dom/src/ (excluding *.bench.ts, *.schemas.ts, runner/bun-test.ts) MUST be 100% on every dimension. Every implementation file (*.ts other than test/bench/schemas/types-in-feature-dirs/index barrels) MUST have a sibling .md of ≥ 200 words with simple + advanced examples.

Scenario: CI coverage gate

  • WHEN bunx turbo test:coverage --filter=@suaveplan/testing-dom runs
  • THEN statement, branch, function, and line coverage all = 100%

Verified by: CI gate (bunx turbo test:coverage --filter=@suaveplan/testing-dom exits non-zero on regression)