Initial Commit
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tasks — testing-dom: runner generics + matchers ambient + async-span hardening
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `openspec/specs/testing-dom/spec.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.3 Draft `proposal.md`
|
||||
- [x] 1.4 Draft `specs/testing-dom/spec.md` delta
|
||||
- [x] 1.5 Every requirement in the delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Add `RunnerMock<TArgs, TReturn>` to `src/runner/mocks.ts` (vitest path) and `src/runner/bun-test.ts` (bun-test path)
|
||||
- [x] 2.2 Have `fn<TArgs,TReturn>()` return `RunnerMock<TArgs,TReturn>` on both paths
|
||||
- [x] 2.3 Add `SpyHandle` to bun-test path; re-export from `runner/vitest.ts`, `runner/index.ts`, package barrel
|
||||
- [x] 2.4 Add `import type {} from "@testing-library/jest-dom"` to `src/matchers/matchers.ts`
|
||||
- [x] 2.5 Replace hard `vitest` import in `src/user/setup-user.ts` with runner-adapter import
|
||||
- [x] 2.6 Add async-aware span lifecycle in `src/observability/instrumented.ts` (Promise detection + `.then`/`.catch` cleanup)
|
||||
- [x] 2.7 Update spec coverage gate to 100% and fix package path reference
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/runner/runner.spec-conformance.test.ts` — runner generics pinned via `expectTypeOf` on both paths
|
||||
- [x] 3.2 `src/matchers/matchers.spec-conformance.test.tsx` — ambient augmentation pinned via `expectTypeOf(expect(el).toBeInTheDocument).toBeFunction()`
|
||||
- [x] 3.3 `src/user/setup-user.test.ts` — added source-level pin against `vitest` hard-import
|
||||
- [x] 3.4 `src/observability/instrumented.test.ts` — added 3 async-span tests (resolve, reject, reject-non-Error)
|
||||
- [x] 3.5 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` updated for `bun-test.md`, `mocks.md`, `matchers.md`, `setup-user.md`, `instrumented.md`
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/testing-dom` — green
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/testing-dom` — 100/100/100/100
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/testing-dom` — exit 0
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/testing-dom` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` assertions on every literal-type claim
|
||||
- [x] 6.2 No new boundary inputs added — existing Zod surface unchanged
|
||||
- [x] 6.3 No new error codes
|
||||
- [x] 6.4 Strict-mode N/A here
|
||||
- [x] 6.5 OTel span lifetime now asserted via observable elapsed time
|
||||
- [x] 6.6 N/A — no lifecycle dedup claim
|
||||
- [x] 6.7 No retroactive tick — every box reflects work in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/testing-dom/spec.md`
|
||||
- [ ] 7.2 Move folder → `openspec/archive/2026-04-27-testing-dom-runner-and-matchers-hardening/`
|
||||
Reference in New Issue
Block a user