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

14 KiB

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)"