Files
suaveplan/openspec/archive/2026-04-19-enterprise-testing-dom/design.md
2026-05-27 23:29:53 +01:00

6.3 KiB

Context

@suaveplan/testing-dom is the second testing-infrastructure package on genesis. It exists because:

  1. Every React component test needs a DOM environment (happy-dom or jsdom under vitest).
  2. The genesis testing-package separation rule (CLAUDE.md #9) puts the test-runner adapter here, not in @suaveplan/testing (which stays universal/runner-agnostic).
  3. The staged runner/config/polyfills code from the enterprise-testing split (packages/universal/testing-dom-staging/) is its starting material.

Layer 0 dependency profile: external catalog deps only. No @suaveplan/* workspace deps. The package's own tests run under vitest with happy-dom.

Goals / Non-Goals

Goals:

  • Absorb staged runner/config/polyfills cleanly from testing-dom-staging/.
  • Preserve the dual-runner conditional export (vitest default / bun condition) for the runner re-exports.
  • Provide thin React/DOM helpers built on @testing-library/react + @testing-library/jest-dom + @testing-library/user-event (v14+).
  • Local TestingDomError hierarchy with { code, cause, context } shape; codes follow TESTING_DOM.<DOMAIN>.<REASON>.
  • createTestConfig() (moved from testing) absorbs WSL2 pool/polyfill defaults and dynamic React-plugin loading.
  • ≥ 95% test coverage; co-located unit tests + cross-cutting tests/ per the test-placement hard rule.

Non-Goals:

  • Forking @testing-library/* internals — the package is a thin wrapper.
  • Legacy fireEvent helpers in the default export — userEvent v14 only.
  • Wrapping @suaveplan/error / @suaveplan/opentelemetry / @suaveplan/schema-registry — these are forbidden workspace deps. Local equivalents.

Decisions

D1: Local TestingDomError hierarchy

Mirrors @suaveplan/testing's TestingError shape but with TESTING_DOM.<DOMAIN>.<REASON> codes. Subclasses: RenderError, MatcherError, ConfigError, AbortedError. Reason: rule #8/#9 — no @suaveplan/* workspace deps. Shape-compatible with future @suaveplan/error adapter.

D2: Runner adapter via conditional exports (preserved from staging)

package.json exports["."] routes vitest condition → vitest barrel, bun condition → bun-test barrel, default → vitest. mocks.ts provides runner-neutral fn/spy/stub/mock/fakeClock shims. The vitest dynamic-mock bracket-access trick (vi["mock"]) carries over from the staging code. Coverage exception: runner/bun-test.ts is excluded from istanbul coverage because vitest cannot resolve bun:test (same exception we already documented for testing).

D3: createTestConfig() factory

Lifts createTestConfig, detectRunner, detectEnvironment, createVitestConfig, createBunTestConfig verbatim from staging. Hardcodes pool: "forks" (WSL2 vmThreads/threads fail), setupFiles: ["@suaveplan/testing-dom/polyfills"], fileParallelism: false for happy-dom, optional dynamic @vitejs/plugin-react import.

D4: React render wrapper — renderComponent

Thin wrapper over @testing-library/react's render that:

  • Wraps the tree in any provider stack the caller passes (e.g. router, query client) via a wrapper option
  • Auto-cleans on afterEach via cleanup() from @testing-library/react
  • Returns the standard RenderResult extended with a userEvent instance for ergonomic event dispatching
  • Returns TestingDomError("TESTING_DOM.RENDER.SETUP_FAILED") if the render itself throws

D5: Accessibility-first query aliases

Re-export Testing Library queries verbatim AND add ergonomic aliases:

  • byRole(name, opts) → screen.getByRole(name, opts) (strict get; throws on miss)
  • findRole, queryRole similar variants
  • byLabelText, byText, byPlaceholderText similarly Documentation in .md files steers callers toward role/label/text queries; getByTestId is re-exported but flagged as a fallback only.

D6: User events via userEvent v14

Single export: setupUser(options?) returns a UserEvent instance with pointerEventsCheck: 0 default for happy-dom (which doesn't fully simulate pointer events). When vitest fake timers are active (detected via vi.isFakeTimers()), wires userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) automatically.

D7: jest-dom matchers extended via vitest's expect.extend

A side-effect import path @suaveplan/testing-dom/matchers extends vitest's expect with the @testing-library/jest-dom matcher set. Imported via setupFiles or per-test as a side effect.

D8: Polyfills file

Lifted from staging — installs globalThis.crypto (from node:crypto.webcrypto), globalThis.TextEncoder/TextDecoder (from node:util) when missing. Side-effecting, exports nothing. Subpath: @suaveplan/testing-dom/polyfills.

D9: OTEL via @opentelemetry/api (gated)

Optional debug instrumentation behind process.env.TESTING_DOM_OTEL_ENABLED === "1". Counters: testing_dom.render.count, testing_dom.user.click.count. Histograms: testing_dom.render.duration_ms. Disabled by default — adds zero overhead in normal test runs.

D10: Tests use @suaveplan/testing (utilities) directly

The package's own tests import primitive utilities (FakeClock, Factory, etc.) directly from @suaveplan/testing. This is the one allowed cross-genesis-package dep — but only as a devDependency, not a runtime dep. Production users of testing-dom never transitively pull @suaveplan/testing.

Risks / Trade-offs

  • happy-dom limitations — pointer events, intersection observer, resize observer aren't fully simulated. Mitigation: document, provide opt-in shims for the most-needed gaps.
  • React 19 act() boundaries — incorrect handling of async state updates leaks errors. Mitigation: renderComponent always awaits act() for the initial render.
  • bun-test path — same untestable-under-vitest issue as the original; excluded from coverage. Mitigation: add a bun:smoke script later that runs a tiny test fixture under bun test to verify the dual-runner contract works in anger.
  • @vitejs/plugin-react optional peer — missing peer must not break non-React consumers. Mitigation: dynamic import inside try/catch in createTestConfig (preserved from staging).
  • devDependency on @suaveplan/testing — D10 violates the spirit of "no @suaveplan/* deps in testing-infra packages" but only at devDep level. Tests need FakeClock, Factory, etc. Documented in CLAUDE.md exception note.