# 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` returns `ReturnType` — an opaque type. The vitest path returns `Mock` and (after this change) `RunnerMock`. 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` 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()` (both runner paths) — now returns `RunnerMock` carrying the precise call signature. - MODIFIED: `spy()` and `stub()` on the bun-test path — return `SpyHandle` (alias for `ReturnType`). - 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()` 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` continue to compile.