6.3 KiB
Context
@suaveplan/testing-dom is the second testing-infrastructure package on genesis. It exists because:
- Every React component test needs a DOM environment (happy-dom or jsdom under vitest).
- The genesis testing-package separation rule (CLAUDE.md #9) puts the test-runner adapter here, not in
@suaveplan/testing(which stays universal/runner-agnostic). - The staged runner/config/polyfills code from the
enterprise-testingsplit (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 (
vitestdefault /buncondition) 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
TestingDomErrorhierarchy with{ code, cause, context }shape; codes followTESTING_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
fireEventhelpers in the default export —userEventv14 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
wrapperoption - Auto-cleans on
afterEachviacleanup()from@testing-library/react - Returns the standard
RenderResultextended with auserEventinstance 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,queryRolesimilar variantsbyLabelText,byText,byPlaceholderTextsimilarly Documentation in.mdfiles steers callers toward role/label/text queries;getByTestIdis 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:
renderComponentalways awaitsact()for the initial render. - bun-test path — same untestable-under-vitest issue as the original; excluded from coverage. Mitigation: add a
bun:smokescript later that runs a tiny test fixture underbun testto verify the dual-runner contract works in anger. @vitejs/plugin-reactoptional peer — missing peer must not break non-React consumers. Mitigation: dynamic import insidetry/catchincreateTestConfig(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 needFakeClock,Factory, etc. Documented in CLAUDE.md exception note.