Files
suaveplan/openspec/archive/2026-04-27-testing-dom-runner-and-matchers-hardening/proposal.md
2026-05-27 23:29:53 +01:00

43 lines
3.9 KiB
Markdown

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