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

3.9 KiB

testing-dom: runner generics + matchers ambient + async-span hardening

Package: @suaveplan/testing-dom Status: PROPOSED Type: bug-fix

Why

Three latent defects in @suaveplan/testing-dom block correct downstream typing and produce wrong observability data:

  1. Runner-generic erasure on the bun-test path. src/runner/bun-test.ts#fn<TArgs,TReturn> returns ReturnType<typeof bunMock> — an opaque type. The vitest path returns Mock and (after this change) RunnerMock<TArgs, TReturn>. A consumer writing const m = fn<[string], number>() gets a typed callable under vitest and an opaque mock under bun-test, breaking the dual-runner contract.
  2. jest-dom matchers have no ambient type augmentation. src/matchers/matchers.ts calls expect.extend(matchers) at runtime but consumers see Property 'toBeInTheDocument' does not exist because the file never pulls in the @testing-library/jest-dom ambient module declaration.
  3. instrumented() ends spans synchronously even for async fns. Async-wrapped functions get a span duration of ~0 ms regardless of how long they actually run, corrupting every histogram. Plus setupUser hard-imports vitest, defeating the bun-test conditional path.

What changes

  • ADDED: RunnerMock<TArgs, TReturn> type alias — exported from ./runner/index.js, ./runner/vitest.js, ./runner/bun-test.js, and the package barrel ./index.js. Mirrors on both runner paths.
  • ADDED: SpyHandle re-export at the package barrel.
  • MODIFIED: fn<TArgs,TReturn>() (both runner paths) — now returns RunnerMock<TArgs, TReturn> carrying the precise call signature.
  • MODIFIED: spy<T,K>() and stub<T,K>() on the bun-test path — return SpyHandle (alias for ReturnType<typeof bunSpyOn>).
  • MODIFIED: src/matchers/matchers.ts — adds import type {} from "@testing-library/jest-dom" so the ambient module declaration ships with the side-effect import.
  • MODIFIED: src/user/setup-user.ts — routes vi through ../runner/index.js instead of hard-importing vitest.
  • MODIFIED: src/observability/instrumented.ts — detects PromiseLike return values and ends the OTel span (and records the histogram) only after the promise settles.
  • MODIFIED: openspec/specs/testing-dom/spec.md — coverage gate raised from ≥95% to 100% to match CLAUDE.md's pre-publish gate. Path reference updated from packages/universal/testing-dom/ to packages/dev/testing-dom/.

Out of scope

  • Re-organising the runner barrel — the existing dual-runner conditional-exports shape is preserved.
  • Refactoring instrumented() away from tracer.startActiveSpan — keeping the same OTel surface, only fixing span lifetime.
  • Introducing a runner-detection helper inside setupUser — the runner adapter already exists.

Verification approach

  • src/runner/runner.spec-conformance.test.ts — pins fn<TArgs,TReturn>() typing on the vitest path AND symmetric typing on the bun-test path via import type; pins the RunnerMock / SpyHandle exports at both runner barrels and the package barrel. Uses expectTypeOf.
  • src/matchers/matchers.spec-conformance.test.tsx — pins expectTypeOf(expect(el).toBeInTheDocument).toBeFunction() etc., proving the ambient augmentation reached the consumer.
  • src/user/setup-user.test.ts::"imports vi via the runner adapter (no direct vitest hard-import)" — source-level guard against regression.
  • src/observability/instrumented.test.ts::"ends span only after async fn settles (resolved)" — observable timing assertion (elapsed >= 10ms).
  • src/observability/instrumented.test.ts::"ends span only after async fn settles (rejected)" and "… (rejected with non-Error)" — pin the rejection branch including recordException for non-Error throws.

Migration impact

None — additive types and bug-fix-only behavioral change. Consumers that previously typed mocks with ReturnType<typeof fn> continue to compile.