Files
suaveplan/openspec/specs/testing-dom/spec.md
2026-05-27 23:29:53 +01:00

196 lines
14 KiB
Markdown

# testing-dom
## Purpose
The `@suaveplan/testing-dom` package provides DOM- and React-side test helpers with dual-runner support for Vitest and `bun:test`. It ships a `createTestConfig()` factory tuned for WSL2 (forks pool, polyfills, optional `@vitejs/plugin-react`), a `renderComponent` wrapper around `@testing-library/react` with auto-cleanup and `userEvent` integration, side-effect polyfills for `crypto`/`TextEncoder`/`TextDecoder`, and ambient-typed `@testing-library/jest-dom` matcher extensions. The package is a Layer 0 dependency: its `dependencies` and `peerDependencies` MUST NOT contain any `@suaveplan/*` workspace package, and structured errors flow through `TestingDomError` with stable `TESTING_DOM.<DOMAIN>.<REASON>` codes.
## Requirements
### Requirement: Layer 0 dep-graph isolation
The `@suaveplan/testing-dom` `dependencies` and `peerDependencies` MUST NOT contain any `@suaveplan/*` workspace package. `devDependencies` MAY include `@suaveplan/testing` (utilities) for the package's own tests, but the production runtime closure MUST NOT contain it.
#### Scenario: Runtime dep-graph guard
- **WHEN** `bunx turbo ls --filter=@suaveplan/testing-dom...` is run (forward closure of runtime deps)
- **THEN** no `@suaveplan/*` package appears
#### Scenario: devDep allowance
- **WHEN** `package.json` is parsed
- **THEN** `dependencies` and `peerDependencies` are free of `@suaveplan/*` AND `devDependencies` may contain `@suaveplan/testing` (this dep is not visible to consumers)
**Verified by:** `tests/dep-graph.test.ts::"no @suaveplan/* in dependencies or peerDependencies"` AND `tests/dep-graph.test.ts::"devDependencies may contain @suaveplan/testing only (utilities for our own tests)"`
### Requirement: Dual-runner conditional exports preserved
The package `exports["."]` SHALL route the `vitest` condition and the `default` condition to the vitest barrel and the `bun` condition to the bun-test barrel. The exports MUST expose `describe`, `it`, `test`, `expect`, `vi`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers` under both conditions.
#### Scenario: Vitest consumer
- **WHEN** a consumer running under vitest imports `{ describe, it, expect, vi }` from `@suaveplan/testing-dom`
- **THEN** the symbols come from `vitest`
#### Scenario: bun-test consumer
- **WHEN** a consumer running under `bun test` imports the same names
- **THEN** the symbols come from `bun:test`
**Verified by:** `tests/exports-map.spec-conformance.test.ts::"routes vitest + default to the index barrel"` AND `tests/exports-map.spec-conformance.test.ts::"routes the bun condition to the bun-test barrel"` AND `tests/exports-map.spec-conformance.test.ts::"vitest and default conditions share the same types entry"` AND `tests/exports-map.spec-conformance.test.ts::"bun condition has its own types entry"` AND `src/runner/mocks.test.ts::"creates a mock that records calls"`
### Requirement: `createTestConfig()` produces WSL2-safe configs
The factory SHALL emit a vitest config with `pool: "forks"`, `setupFiles: ["@suaveplan/testing-dom/polyfills"]`, `fileParallelism: false` when `environment === "happy-dom"`, and dynamically-loaded `@vitejs/plugin-react` if that optional peer is installed. When the caller's runner is `bun:test`, the factory SHALL return a `BunTestConfig` instead.
#### Scenario: Happy-dom config
- **WHEN** `createTestConfig({ environment: "happy-dom" })` is awaited and `@vitejs/plugin-react` is present
- **THEN** the returned config includes the React plugin, has `pool === "forks"`, `fileParallelism === false`, and `setupFiles` includes the polyfills path
#### Scenario: Missing React peer doesn't break non-React consumers
- **WHEN** `createTestConfig({ environment: "happy-dom" })` is awaited and the React peer is NOT installed
- **THEN** the factory returns the vitest config without the plugin, no error thrown
**Verified by:** `src/config/config.test.ts::"returns a vitest config by default"` AND `src/config/config.test.ts::"forces pool=forks and includes polyfill setupFile"` AND `src/config/config.test.ts::"disables fileParallelism for happy-dom by default"` AND `src/config/config.test.ts::"returns a BunTestConfig when runner=bun:test"` AND `src/config/config.test.ts::"throws CONFIG_UNKNOWN_RUNNER for an unknown runner"`
### Requirement: Polyfills are side-effect-only
The `@suaveplan/testing-dom/polyfills` subpath MUST resolve to a side-effecting module that installs `globalThis.crypto` (when missing), `globalThis.TextEncoder`, and `globalThis.TextDecoder`. The module MUST export nothing.
#### Scenario: Idempotent polyfill install
- **WHEN** `import "@suaveplan/testing-dom/polyfills"` runs twice in the same process
- **THEN** the second import is a no-op (no errors, no overwrites)
**Verified by:** `tests/tree-shake.test.ts::"polyfills subpath has no functional surface"` AND `tests/exports-map.spec-conformance.test.ts::"polyfills + matchers resolve to side-effecting dist files"`
### Requirement: `renderComponent` wraps React render with cleanup and userEvent
`renderComponent(ui, options?)` SHALL render the React tree using `@testing-library/react`'s `render`, auto-register cleanup on `afterEach`, and return the standard `RenderResult` extended with a `userEvent` instance.
#### Scenario: Render and event dispatch
- **WHEN** `renderComponent(<Button />)` is called and the returned `userEvent.click(getByRole('button'))` is awaited
- **THEN** the click is dispatched and observable side-effects run
#### Scenario: Render failure produces RenderError
- **WHEN** the React tree throws synchronously during render
- **THEN** `renderComponent` throws `RenderError` with `code === "TESTING_DOM.RENDER.SETUP_FAILED"` and `cause` set to the original error
**Verified by:** `src/render/render-component.test.tsx::"renders simple JSX and exposes user"` AND `src/render/render-component.test.tsx::"dispatches a click via the returned user"` AND `src/render/render-component.test.tsx::"throws RenderError with cause when render explodes"`
### Requirement: jest-dom matchers extension
Importing `@suaveplan/testing-dom/matchers` SHALL extend vitest's `expect` with the `@testing-library/jest-dom` matcher set. Common matchers MUST be available afterward: `toBeInTheDocument`, `toHaveTextContent`, `toBeDisabled`, `toBeEnabled`, `toHaveAttribute`, `toBeVisible`, `toHaveClass`, `toHaveValue`.
#### Scenario: Matcher availability after side-effect import
- **WHEN** a test file does `import "@suaveplan/testing-dom/matchers"` and then asserts `expect(node).toBeInTheDocument()`
- **THEN** the assertion runs without "matcher does not exist" errors
**Verified by:** `src/matchers/matchers.test.tsx::"toBeInTheDocument"` AND `src/matchers/matchers.test.tsx::"toHaveTextContent"` AND `src/matchers/matchers.test.tsx::"toBeDisabled / toBeEnabled"` AND `src/matchers/matchers.test.tsx::"toHaveAttribute"` AND `src/matchers/matchers.test.tsx::"toHaveClass"`
### Requirement: Structured errors with stable codes
All errors thrown from `@suaveplan/testing-dom` MUST be subclasses of `TestingDomError extends Error` carrying `{ code, cause?, context? }`. Codes match `^TESTING_DOM\.[A-Z]+\.[A-Z_]+$`.
#### Scenario: Coded error
- **WHEN** any helper throws
- **THEN** the thrown value `instanceof TestingDomError` is true AND `err.code` matches the regex
**Verified by:** `src/errors/testing-dom-error.test.ts::"stores code, message, cause, context"` AND `src/errors/testing-dom-error.test.ts::"each preserves instanceof TestingDomError"` AND `src/errors/testing-dom-error.test.ts::"every value matches the pattern"`
### Requirement: Coverage and docs gates
Istanbul statement + branch + function + line coverage across `packages/testing/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) — current run reports 100/100/100/100
### Requirement: Test placement follows the genesis hard rule
Per-feature unit tests SHALL be co-located in `src/` (named `<feature>.test.ts` next to `<feature>.ts`). Cross-cutting tests (dep-graph guard, tree-shake guard, no-disallowed grep guard, property tests) MUST live in `tests/` sibling to `src/`.
#### Scenario: Per-feature unit tests live next to source
- **WHEN** a directory under `src/` contains a feature module like `render/render-component.ts`
- **THEN** its unit tests are at `src/render/render-component.test.ts` (sibling) AND not in `tests/`
#### Scenario: Cross-cutting tests live in tests/
- **WHEN** a test exercises package-level concerns (dep-graph, tree-shake, no-disallowed grep, property invariants over multiple modules)
- **THEN** it lives under `tests/` AND not in `src/`
**Verified by:** `tests/no-disallowed.test.ts::"src/ contains no disallowed constructs"` AND directory layout audit (`tests/` contains dep-graph, tree-shake, exports-map, no-disallowed; `src/` contains per-feature `*.test.ts(x)`)
### 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 (via the `@testing-library/jest-dom/vitest` subpath) 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)"`