Initial Commit
This commit is contained in:
154
packages/testkit/testing/testing-dom/README.md
Normal file
154
packages/testkit/testing/testing-dom/README.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# @suaveplan/testing-dom - DOM/React testing — vitest+bun-test conditional re-exports, createTestConfig, polyfills, render helpers, accessibility-first queries, jest-dom matchers, user-event v14
|
||||
|
||||
## Summary
|
||||
|
||||
DOM and React component testing utilities for Suaveplan. Provides a single install that covers vitest config generation with WSL2-safe defaults, `@testing-library/react` render helpers, accessibility-first query aliases, `@testing-library/jest-dom` matchers, user-event v14 setup, and a dual-runner shim that routes between vitest and `bun:test` via package.json conditional exports.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
|
||||
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
|
||||
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
|
||||
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [render-component](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
|
||||
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
|
||||
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
|
||||
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
<!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
|
||||
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
|
||||
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
|
||||
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
|
||||
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
|
||||
|
||||
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
|
||||
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
|
||||
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
|
||||
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
|
||||
|
||||
- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- detect-environment — `detectEnvironment` probes the…
|
||||
- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
|
||||
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [render-component](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
|
||||
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_- vitest — The `runner/vitest` module re-exports vite…
|
||||
- [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_- [bun-test-config](./src/config/bun-test-config.md) — `createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment…
|
||||
- [config](./src/config/config.md) — <!-- begin:auto-docs --> _(no submodules)_- [detect-environment](./src/config/detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./src/config/detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./src/config/vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [codes](./src/errors/codes.md) — `TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING…
|
||||
- [testing-dom-error](./src/errors/testing-dom-error.md) — `TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT exte…
|
||||
- [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_ - [matchers](./src/matchers/matchers.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_ - [instrumented](./src/observability/instrumented.md) — <!-- begin:auto-docs --> _(no submodules)_- [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_ - [queries](./src/queries/queries.md) — <!-- begin:auto-docs --> _(no submodules)_- [render](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_ - [render-component](./src/render/render-component.md) — <!-- begin:auto-docs --> _(no submodules)_- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [bun-test](./src/runner/bun-test.md) — The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `before…
|
||||
- [mocks](./src/runner/mocks.md) — The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under…
|
||||
- [runner](./src/runner/runner.md) — <!-- begin:auto-docs --> _(no submodules)_ - [vitest](./src/runner/vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [setup](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_ - [polyfills](./src/setup/polyfills.md) — <!-- begin:auto-docs --> _(no submodules)_- [user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_ - [setup-user](./src/user/setup-user.md) — <!-- begin:auto-docs --> _(no submodules)_
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/testing-dom
|
||||
```
|
||||
|
||||
Peer dependencies (install separately if using React rendering):
|
||||
|
||||
```bash
|
||||
bun add react react-dom @vitejs/plugin-react
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Vitest config
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createVitestConfig } from "@suaveplan/testing-dom/config";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export default defineConfig(
|
||||
createVitestConfig(fileURLToPath(new URL(".", import.meta.url)), {
|
||||
environment: "happy-dom",
|
||||
coverage: true,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
### Rendering a React component
|
||||
|
||||
```tsx
|
||||
import { renderComponent } from "@suaveplan/testing-dom/render";
|
||||
import { byRole, findRole } from "@suaveplan/testing-dom/queries";
|
||||
import "@suaveplan/testing-dom/matchers"; // extends expect with jest-dom matchers
|
||||
|
||||
it("submits the form", async () => {
|
||||
const { user } = renderComponent(<MyForm onSubmit={handleSubmit} />);
|
||||
|
||||
await user.click(byRole("button", { name: /submit/i }));
|
||||
|
||||
const status = await findRole("status");
|
||||
expect(status).toHaveTextContent("Saved");
|
||||
});
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
| Subpath | Module | Description |
|
||||
|---|---|---|
|
||||
| `@suaveplan/testing-dom/config` | [src/config/vitest-config.md](src/config/vitest-config.md) | `createVitestConfig` — builds the vitest `test` block with WSL2-safe `pool: "forks"`, polyfill setup files, and Istanbul coverage defaults |
|
||||
| | [src/config/bun-test-config.md](src/config/bun-test-config.md) | `createBunTestConfig` — equivalent config builder for `bun test` |
|
||||
| | [src/config/detect-runner.md](src/config/detect-runner.md) | `detectRunner` — infers the active test runner from `package.json` and environment variables |
|
||||
| | [src/config/detect-environment.md](src/config/detect-environment.md) | `detectEnvironment` — resolves the vitest test environment (`node`, `happy-dom`, `jsdom`) |
|
||||
| | [src/config/config.md](src/config/config.md) | `createTestConfig` — unified entry point that delegates to the correct runner-specific builder |
|
||||
| `@suaveplan/testing-dom/render` | [src/render/render-component.md](src/render/render-component.md) | `renderComponent` — thin wrapper over `@testing-library/react` render that auto-cleans between calls and returns an attached `user` event instance |
|
||||
| `@suaveplan/testing-dom/queries` | [src/queries/queries.md](src/queries/queries.md) | Accessibility-first query aliases (`byRole`, `findRole`, `queryRole`, `byLabelText`, `byText`, `byPlaceholderText`) plus full re-export of `screen` and `within` |
|
||||
| `@suaveplan/testing-dom/user` | [src/user/setup-user.md](src/user/setup-user.md) | `setupUser` — produces a `@testing-library/user-event` v14 instance with happy-dom-safe defaults and automatic fake-timer integration |
|
||||
| `@suaveplan/testing-dom/matchers` | [src/matchers/matchers.md](src/matchers/matchers.md) | Side-effect import that extends vitest's `expect` with the full `@testing-library/jest-dom` matcher set |
|
||||
| `@suaveplan/testing-dom/polyfills` | [src/setup/polyfills.md](src/setup/polyfills.md) | Setup file that installs web-platform polyfills (`TextEncoder`, `ResizeObserver`, etc.) required by happy-dom in Node/Bun |
|
||||
| `@suaveplan/testing-dom/errors` | [src/errors/testing-dom-error.md](src/errors/testing-dom-error.md) | Typed error classes (`RenderError`) and error codes for failures surfaced by this package |
|
||||
| | [src/errors/codes.md](src/errors/codes.md) | `TESTING_DOM_ERROR_CODES` registry |
|
||||
| _(runner shim)_ | [src/runner/vitest.md](src/runner/vitest.md) | Vitest runner re-exports (`describe`, `it`, `test`, `expect`, `vi`, `beforeEach`, …) |
|
||||
| | [src/runner/bun-test.md](src/runner/bun-test.md) | `bun:test` runner shim with the same surface — activated by the `"bun"` conditional export |
|
||||
| | [src/runner/mocks.md](src/runner/mocks.md) | `fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers` — runner-agnostic mock helpers |
|
||||
| | [src/runner/runner.md](src/runner/runner.md) | Runner abstraction types |
|
||||
| _(observability)_ | [src/observability/instrumented.md](src/observability/instrumented.md) | `instrumented` — optional OTel wrapper (gated by `TESTING_DOM_OTEL_ENABLED=1`) that records call counts and duration histograms for package entrypoints |
|
||||
|
||||
117
packages/testkit/testing/testing-dom/package.json
Normal file
117
packages/testkit/testing/testing-dom/package.json
Normal file
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"name": "@suaveplan/testing-dom",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/setup/polyfills.js",
|
||||
"./dist/matchers/matchers.js"
|
||||
],
|
||||
"description": "DOM/React testing — vitest+bun-test conditional re-exports, createTestConfig, polyfills, render helpers, accessibility-first queries, jest-dom matchers, user-event v14",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"vitest": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"bun": {
|
||||
"types": "./dist/runner/bun-test.d.ts",
|
||||
"default": "./dist/runner/bun-test.js"
|
||||
},
|
||||
"default": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./config": {
|
||||
"types": "./dist/config/index.d.ts",
|
||||
"default": "./dist/config/index.js"
|
||||
},
|
||||
"./polyfills": "./dist/setup/polyfills.js",
|
||||
"./matchers": "./dist/matchers/matchers.js",
|
||||
"./errors": {
|
||||
"types": "./dist/errors/index.d.ts",
|
||||
"default": "./dist/errors/index.js"
|
||||
},
|
||||
"./render": {
|
||||
"types": "./dist/render/index.d.ts",
|
||||
"default": "./dist/render/index.js"
|
||||
},
|
||||
"./queries": {
|
||||
"types": "./dist/queries/queries.d.ts",
|
||||
"default": "./dist/queries/queries.js"
|
||||
},
|
||||
"./user": {
|
||||
"types": "./dist/user/setup-user.d.ts",
|
||||
"default": "./dist/user/setup-user.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@testing-library/dom": "catalog:testing",
|
||||
"@testing-library/jest-dom": "catalog:testing",
|
||||
"@testing-library/react": "catalog:testing",
|
||||
"@testing-library/user-event": "catalog:testing",
|
||||
"happy-dom": "catalog:testing",
|
||||
"tslib": "catalog:build",
|
||||
"vitest": "catalog:testing",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:testing",
|
||||
"@types/react-dom": "catalog:testing",
|
||||
"@vitejs/plugin-react": "catalog:testing",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"fast-check": "catalog:testing",
|
||||
"react": "catalog:testing",
|
||||
"react-dom": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitejs/plugin-react": "*",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitejs/plugin-react": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
# bun-test-config
|
||||
|
||||
## Purpose
|
||||
|
||||
`createBunTestConfig` produces a minimal `bun test` configuration object with the same polyfill preload and environment defaults as the vitest variant. It exists because `@suaveplan/testing-dom` is a dual-runner package — the same test suite should run under vitest AND under `bun test` without manual config divergence. The shape it returns is intentionally small because `bun test` has a far narrower config surface than vitest.
|
||||
|
||||
## Features
|
||||
|
||||
- Prepends `@suaveplan/testing-dom/polyfills` to the `preload` array
|
||||
- Honours the `happy-dom` runtime via a warning (bun-test does NOT support happy-dom natively; tests that depend on DOM must switch runners)
|
||||
- Pass-through defaults for `coverage` and `testTimeoutMs`
|
||||
- Stateless — no module-level config caching
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// bunfig.toml is usually the entry, but this runner can be invoked directly:
|
||||
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
const cfg = createBunTestConfig(__dirname, { testTimeoutMs: 10_000 });
|
||||
// cfg.preload includes "@suaveplan/testing-dom/polyfills"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
// Opt into coverage when running under CI.
|
||||
const cfg = createBunTestConfig(__dirname, {
|
||||
testTimeoutMs: 30_000,
|
||||
coverageEnabled: process.env.CI === "true",
|
||||
environment: "node",
|
||||
preload: ["./tests/install-matchers.ts"],
|
||||
});
|
||||
|
||||
// Hand off to a custom bun-test spawner:
|
||||
await spawnBunTestRunner(cfg);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createBunTestConfig(callerDir, overrides): BunTestConfig`
|
||||
|
||||
- `callerDir` — reserved for future resolution use
|
||||
- `overrides.preload` — extra preloads appended after the package polyfills
|
||||
- `overrides.coverageEnabled` — default `false`
|
||||
- `overrides.testTimeoutMs` — default `5000`
|
||||
- `overrides.environment` — `"node"` is the only fully-supported value; `"happy-dom"` logs a warning
|
||||
|
||||
### `interface BunTestConfig`
|
||||
|
||||
- `preload: string[]`
|
||||
- `coverageEnabled: boolean`
|
||||
- `testTimeoutMs: number`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The warning on `happy-dom` is logged via `console.warn` rather than thrown because some consumers intentionally use bun-test only for their Node-side suites and vitest for DOM; throwing would break the dual-runner contract. The config object is deliberately shallow — bun-test has no nested blocks, so there's no surface for the package to over-specify. Callers who need additional bun-test features (`timeout`, `coverage`, `bail`) pass them through their `bunfig.toml` directly.
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Build a bun-test config from caller intent. Bun's runner accepts a
|
||||
* narrower surface than vitest; this shape captures what's currently
|
||||
* configurable, plus a `warnings` channel so calls to vitest-only
|
||||
* features can surface a notice.
|
||||
*/
|
||||
|
||||
import type { TestEnvironment } from "./detect-environment.js";
|
||||
|
||||
export interface BunTestConfig {
|
||||
readonly callerDir: string;
|
||||
readonly environment: TestEnvironment;
|
||||
readonly testTimeoutMs: number;
|
||||
readonly coverageEnabled: boolean;
|
||||
readonly preload: readonly string[];
|
||||
readonly warnings: readonly string[];
|
||||
}
|
||||
|
||||
export interface BunTestConfigOverrides {
|
||||
readonly environment?: TestEnvironment;
|
||||
readonly testTimeout?: number;
|
||||
readonly coverage?: boolean | Record<string, unknown>;
|
||||
readonly preload?: readonly string[];
|
||||
}
|
||||
|
||||
export function createBunTestConfig(
|
||||
callerDir: string,
|
||||
overrides: BunTestConfigOverrides
|
||||
): BunTestConfig {
|
||||
const warnings: string[] = [];
|
||||
const environment = overrides.environment ?? "node";
|
||||
if (environment === "happy-dom" || environment === "jsdom") {
|
||||
warnings.push(
|
||||
`bun:test does not implement DOM environments natively (got "${environment}"); consider switching to vitest for browser-like tests`
|
||||
);
|
||||
}
|
||||
return {
|
||||
callerDir,
|
||||
environment,
|
||||
testTimeoutMs: overrides.testTimeout ?? 5000,
|
||||
coverageEnabled: overrides.coverage === true,
|
||||
preload: overrides.preload ?? ["@suaveplan/testing-dom/polyfills"],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
125
packages/testkit/testing/testing-dom/src/config/config.md
Normal file
125
packages/testkit/testing/testing-dom/src/config/config.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# createTestConfig — vitest / bun-test config factory
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - detect-environment — `detectEnvironment` probes the…
|
||||
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- detect-environment — `detectEnvironment` probes the…
|
||||
|
||||
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
|
||||
- detect-environment — `detectEnvironment` probes the…
|
||||
|
||||
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
|
||||
- detect-environment — `detectEnvironment` probes the…
|
||||
|
||||
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
- [detect-environment](./detect-environment.md) — `detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"`…
|
||||
- [detect-runner](./detect-runner.md) — `detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-…
|
||||
- [vitest-config](./vitest-config.md) — `createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloa…
|
||||
|
||||
## Purpose
|
||||
|
||||
`createTestConfig(options?)` builds a runner-appropriate test config absorbing every WSL2-safe default and dual-runner consideration Suaveplan cares about: `pool: "forks"` (because `vmThreads` and `threads` both fail in Bun on WSL2), `setupFiles: ["@suaveplan/testing-dom/polyfills"]`, optional `@vitejs/plugin-react` loaded only when the caller targets `happy-dom` and the peer is installed, `fileParallelism: false` for happy-dom suites that would otherwise time out under 20+ concurrent forks. The factory reads the caller's `package.json` to detect runner + environment when not explicitly overridden.
|
||||
|
||||
This is the entry point every other package's `vitest.config.ts` should call. The `@suaveplan/testing` package itself uses an inline equivalent (it cannot import its own factory before being built).
|
||||
|
||||
## Features
|
||||
|
||||
- Auto-detects runner (`vitest` vs `bun:test`) from `BUN_TEST` env, package scripts mentioning `bun test`, or `vitest` in deps
|
||||
- Auto-detects environment (`node` / `happy-dom` / `jsdom`) from `react` / `react-dom` / `jsdom` deps
|
||||
- Returns a vitest `UserConfig` via `defineConfig` for the vitest path; returns `BunTestConfig` for bun-test
|
||||
- WSL2 defaults: `pool: "forks"`, polyfills setupFile, `fileParallelism: false` for happy-dom
|
||||
- `@vitejs/plugin-react` is loaded **only** when `environment === "happy-dom"` AND the optional peer is installed — missing peer doesn't break non-React consumers
|
||||
- Coverage block `provider: "istanbul"` with 95% thresholds when `options.coverage === true`
|
||||
- Throws `ConfigError("TESTING.CONFIG.UNKNOWN_RUNNER")` if asked for a runner outside the supported set
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic example — Node-side test config
|
||||
|
||||
```ts
|
||||
// packages/universal/foo/vitest.config.ts
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default await createTestConfig();
|
||||
```
|
||||
|
||||
### React component testing with happy-dom
|
||||
|
||||
```ts
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default await createTestConfig({
|
||||
environment: "happy-dom",
|
||||
coverage: true,
|
||||
});
|
||||
```
|
||||
|
||||
### Force bun-test runner
|
||||
|
||||
```ts
|
||||
const config = await createTestConfig({ runner: "bun:test" });
|
||||
// → BunTestConfig with preload + warnings array
|
||||
```
|
||||
|
||||
### Custom include / exclude
|
||||
|
||||
```ts
|
||||
export default await createTestConfig({
|
||||
include: ["src/**/*.spec.ts"],
|
||||
exclude: ["src/__benchmarks__/**"],
|
||||
testTimeout: 30_000,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createTestConfig(options?: TestConfigOptions): Promise<UserConfig | BunTestConfig>`
|
||||
|
||||
Async because it dynamically imports the React plugin when needed.
|
||||
|
||||
### `interface TestConfigOptions extends VitestConfigOverrides`
|
||||
|
||||
- `runner?: TestRunner` — `"vitest" | "bun:test"`; auto-detected if omitted
|
||||
|
||||
### `interface VitestConfigOverrides`
|
||||
|
||||
- `environment?: TestEnvironment` — `"node" | "happy-dom" | "jsdom"`
|
||||
- `testTimeout?: number` — default `5000`
|
||||
- `coverage?: boolean | Record<string, unknown>` — `true` enables defaults
|
||||
- `setupFiles?: readonly string[]` — concatenated after polyfills
|
||||
- `include?: readonly string[]` — concatenated after defaults
|
||||
- `exclude?: readonly string[]` — concatenated after defaults
|
||||
- `fileParallelism?: boolean` — defaults to `false` for happy-dom, `true` otherwise
|
||||
|
||||
### `type TestRunner` / `type TestEnvironment`
|
||||
|
||||
### `interface BunTestConfig` / `interface BunTestConfigOverrides`
|
||||
|
||||
- `callerDir`, `environment`, `testTimeoutMs`, `coverageEnabled`, `preload`, `warnings`
|
||||
|
||||
### Error codes thrown
|
||||
|
||||
| Code | Condition |
|
||||
|---|---|
|
||||
| `TESTING.CONFIG.UNKNOWN_RUNNER` | `options.runner` is set to a value other than `"vitest"` / `"bun:test"` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **`pool: "forks"` is non-negotiable on WSL2.** `vmThreads` and `threads` both fail in Bun on WSL2 — see CLAUDE.md for "Vitest / WSL2 quirks". The factory hardcodes `forks`; consumers cannot override.
|
||||
- **`fileParallelism: false`** for happy-dom because 20+ concurrent forks time out. Caller can override via `options.fileParallelism` if their suite is small.
|
||||
- **`@vitejs/plugin-react`** is an optional peer in `package.json#peerDependenciesMeta`. Dynamic `await import` so the missing-peer case doesn't blow up at config-load time. If the import fails (no React), the factory returns the vitest config without the plugin.
|
||||
- **`detectRunner`** reads `BUN_TEST` env first, then scans scripts for `bun test`, then falls back to vitest. Explicit `options.runner` always wins.
|
||||
- **`detectEnvironment`** triggers `happy-dom` on `react` / `react-dom`, `jsdom` on `jsdom`, else `node`. Override via `options.environment`.
|
||||
- **Coverage thresholds** default to 95% statements/branches/functions/lines, matching the quality gate. `options.coverage` as an object lets callers fully customize.
|
||||
- **`callerDir` is exposed on `BunTestConfig`** so a future bun-test runner-script could resolve relative paths without re-detecting.
|
||||
171
packages/testkit/testing/testing-dom/src/config/config.test.ts
Normal file
171
packages/testkit/testing/testing-dom/src/config/config.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import {
|
||||
type ConfigError,
|
||||
TestingDomError,
|
||||
} from "../errors/testing-dom-error.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import { createBunTestConfig } from "./bun-test-config.js";
|
||||
import { detectEnvironment } from "./detect-environment.js";
|
||||
import { detectRunner } from "./detect-runner.js";
|
||||
import { createTestConfig } from "./index.js";
|
||||
import { createVitestConfig } from "./vitest-config.js";
|
||||
|
||||
describe("detectRunner", () => {
|
||||
it("uses explicit override", { timeout: 5000 }, () => {
|
||||
expect(detectRunner("bun:test", {})).toBe("bun:test");
|
||||
expect(detectRunner("vitest", {})).toBe("vitest");
|
||||
});
|
||||
|
||||
it("detects bun:test from BUN_TEST env", { timeout: 5000 }, () => {
|
||||
const original = process.env.BUN_TEST;
|
||||
process.env.BUN_TEST = "1";
|
||||
try {
|
||||
expect(detectRunner(undefined, {})).toBe("bun:test");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.BUN_TEST;
|
||||
} else {
|
||||
process.env.BUN_TEST = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects bun:test from package scripts", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectRunner(undefined, {
|
||||
scripts: { test: "bun test --timeout 5000" },
|
||||
})
|
||||
).toBe("bun:test");
|
||||
});
|
||||
|
||||
it("defaults to vitest when nothing detected", { timeout: 5000 }, () => {
|
||||
expect(detectRunner(undefined, {})).toBe("vitest");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectEnvironment", () => {
|
||||
it("uses explicit override", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({}, "jsdom")).toBe("jsdom");
|
||||
});
|
||||
|
||||
it("returns happy-dom for react deps", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({ dependencies: { react: "^19.0.0" } })).toBe(
|
||||
"happy-dom"
|
||||
);
|
||||
expect(
|
||||
detectEnvironment({
|
||||
peerDependencies: { "react-dom": "^19.0.0" },
|
||||
})
|
||||
).toBe("happy-dom");
|
||||
});
|
||||
|
||||
it("returns jsdom when jsdom is a dep", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({ devDependencies: { jsdom: "^27" } })).toBe(
|
||||
"jsdom"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to node", { timeout: 5000 }, () => {
|
||||
expect(detectEnvironment({})).toBe("node");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createVitestConfig", () => {
|
||||
it("forces pool=forks and includes polyfill setupFile", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { environment: "node" });
|
||||
expect(cfg.test.pool).toBe("forks");
|
||||
expect(cfg.test.setupFiles).toContain(
|
||||
"@suaveplan/testing-dom/polyfills"
|
||||
);
|
||||
});
|
||||
|
||||
it("disables fileParallelism for happy-dom by default", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", {
|
||||
environment: "happy-dom",
|
||||
});
|
||||
expect(cfg.test.fileParallelism).toBe(false);
|
||||
});
|
||||
|
||||
it("enables fileParallelism for node", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { environment: "node" });
|
||||
expect(cfg.test.fileParallelism).toBe(true);
|
||||
});
|
||||
|
||||
it("coverage:true enables istanbul defaults", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", { coverage: true });
|
||||
const cov = cfg.test.coverage as Record<string, unknown>;
|
||||
expect(cov.provider).toBe("istanbul");
|
||||
expect((cov.thresholds as Record<string, number>).statements).toBe(95);
|
||||
});
|
||||
|
||||
it("coverage object is passed through as-is", { timeout: 5000 }, () => {
|
||||
const custom: Record<string, unknown> = {
|
||||
provider: "v8",
|
||||
reporter: ["text"],
|
||||
};
|
||||
const cfg = createVitestConfig("/tmp/x", { coverage: custom });
|
||||
expect(cfg.test.coverage).toBe(custom);
|
||||
});
|
||||
|
||||
it("setupFiles overrides extend the polyfills", { timeout: 5000 }, () => {
|
||||
const cfg = createVitestConfig("/tmp/x", {
|
||||
setupFiles: ["./extra.ts"],
|
||||
});
|
||||
expect(cfg.test.setupFiles).toEqual([
|
||||
"@suaveplan/testing-dom/polyfills",
|
||||
"./extra.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBunTestConfig", () => {
|
||||
it("returns config with default preload", { timeout: 5000 }, () => {
|
||||
const cfg = createBunTestConfig("/tmp/x", {});
|
||||
expect(cfg.preload).toContain("@suaveplan/testing-dom/polyfills");
|
||||
expect(cfg.coverageEnabled).toBe(false);
|
||||
expect(cfg.testTimeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
it("warns when happy-dom is requested", { timeout: 5000 }, () => {
|
||||
const cfg = createBunTestConfig("/tmp/x", {
|
||||
environment: "happy-dom",
|
||||
});
|
||||
expect(cfg.warnings.length).toBeGreaterThan(0);
|
||||
expect(cfg.warnings[0]).toContain("bun:test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTestConfig", () => {
|
||||
it("returns a vitest config by default", { timeout: 5000 }, async () => {
|
||||
const cfg = await createTestConfig();
|
||||
expect(cfg).toBeDefined();
|
||||
// Vitest defineConfig returns an object with `test`.
|
||||
expect((cfg as { test?: unknown }).test).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a BunTestConfig when runner=bun:test", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const cfg = await createTestConfig({ runner: "bun:test" });
|
||||
expect((cfg as { preload?: unknown }).preload).toBeDefined();
|
||||
});
|
||||
|
||||
it("throws CONFIG_UNKNOWN_RUNNER for an unknown runner", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
try {
|
||||
await createTestConfig({
|
||||
runner: "nope" as never,
|
||||
});
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
expect((err as ConfigError).code).toBe(
|
||||
TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
# detect-environment
|
||||
|
||||
## Purpose
|
||||
|
||||
`detectEnvironment` probes the current runtime and returns the most-specific test environment available: `"happy-dom"` when it's importable, otherwise `"jsdom"` when that is, otherwise `"node"`. It lets config factories auto-select a DOM environment without forcing every consumer to hard-code one — the right answer depends on what devDependencies the consumer happens to have installed.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure probe — returns a string, never throws
|
||||
- Ordered preference: `happy-dom` → `jsdom` → `node`
|
||||
- Non-throwing `require.resolve` via a safe try/catch
|
||||
- Zero runtime dependencies — just reads the module graph
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectEnvironment } from "@suaveplan/testing-dom/config";
|
||||
|
||||
const env = detectEnvironment();
|
||||
// "happy-dom" | "jsdom" | "node"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createVitestConfig, detectEnvironment } from "@suaveplan/testing-dom/config";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig(
|
||||
createVitestConfig(__dirname, {
|
||||
environment: detectEnvironment(),
|
||||
coverage: true,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectEnvironment(): TestEnvironment`
|
||||
|
||||
Returns one of:
|
||||
|
||||
- `"happy-dom"` — when `happy-dom` is resolvable from the caller
|
||||
- `"jsdom"` — when `jsdom` is resolvable from the caller
|
||||
- `"node"` — fallback when neither DOM library is available
|
||||
|
||||
### `type TestEnvironment = "happy-dom" | "jsdom" | "node"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Resolution uses `require.resolve` wrapped in a try/catch so missing modules don't surface as unhandled exceptions. The function runs at config-build time, not at test-run time, so the cost is paid once per vitest invocation and never per test. Consumers who need a deterministic test suite across environments should override the probe by passing an explicit `environment` to `createVitestConfig` — `detectEnvironment` is a convenience for the common case where the DOM preference follows which devDependency is installed.
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Detect the appropriate vitest `environment` for a package.
|
||||
*
|
||||
* Order of precedence:
|
||||
* 1. Explicit override
|
||||
* 2. Package depends on `react` / `react-dom` → `happy-dom`
|
||||
* 3. Package depends on `jsdom` → `jsdom`
|
||||
* 4. Default: `node`
|
||||
*/
|
||||
|
||||
export type TestEnvironment = "node" | "happy-dom" | "jsdom";
|
||||
|
||||
interface MinimalPackageJson {
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function detectEnvironment(
|
||||
pkg: MinimalPackageJson,
|
||||
override?: TestEnvironment
|
||||
): TestEnvironment {
|
||||
if (override) return override;
|
||||
const deps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
...(pkg.peerDependencies ?? {}),
|
||||
};
|
||||
if ("react" in deps || "react-dom" in deps) return "happy-dom";
|
||||
if ("jsdom" in deps) return "jsdom";
|
||||
return "node";
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# detect-runner
|
||||
|
||||
## Purpose
|
||||
|
||||
`detectRunner` tells the caller whether the currently-executing process is vitest or bun-test. It's used by the runner-adapter module to pick the right implementation of `describe`/`it`/`expect` at runtime when a test file wants to be dual-runner. Since vitest and bun-test expose slightly different globals, consumers either import the detected runner's API from this package or branch on the returned string.
|
||||
|
||||
## Features
|
||||
|
||||
- Pure probe of `globalThis` / environment variables
|
||||
- Three possible outcomes: `"vitest"`, `"bun-test"`, `"unknown"`
|
||||
- Safe to call outside any test context — returns `"unknown"` rather than throwing
|
||||
- Deterministic under a single runner — no heuristics that flip between runs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectRunner } from "@suaveplan/testing-dom/config";
|
||||
|
||||
const runner = detectRunner();
|
||||
if (runner === "bun-test") {
|
||||
// Use bun:test APIs
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detectRunner } from "@suaveplan/testing-dom/config";
|
||||
|
||||
// A shared test-helper module that branches on runner.
|
||||
export function useHelper(): unknown {
|
||||
const runner = detectRunner();
|
||||
switch (runner) {
|
||||
case "vitest":
|
||||
return loadVitestHelper();
|
||||
case "bun-test":
|
||||
return loadBunTestHelper();
|
||||
case "unknown":
|
||||
throw new Error(
|
||||
"useHelper() called outside a supported test runner"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectRunner(): TestRunner`
|
||||
|
||||
Returns one of:
|
||||
|
||||
- `"vitest"` — detected via `import.meta.vitest` or vitest-owned globals
|
||||
- `"bun-test"` — detected via bun's `Bun.version` + `process.env.BUN_TEST`
|
||||
- `"unknown"` — neither was detected
|
||||
|
||||
### `type TestRunner = "vitest" | "bun-test" | "unknown"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Detection reads multiple signals for each runner to remain robust against future runtime changes — relying on a single global is fragile. The function never reads from disk or spawns a child process, so it's safe to call at module-top during config import. For test files that want to statically import one runner's API, prefer importing `@suaveplan/testing-dom/runner` (which internally calls `detectRunner`) over writing the branch manually.
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Detect which test runner a consumer is using based on its
|
||||
* `package.json` and process environment. Used by {@link createTestConfig}
|
||||
* to route between vitest and bun-test.
|
||||
*/
|
||||
|
||||
/** Supported test runners. */
|
||||
export type TestRunner = "vitest" | "bun:test";
|
||||
|
||||
interface MinimalPackageJson {
|
||||
devDependencies?: Record<string, string>;
|
||||
dependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the runner from caller context. Order of precedence:
|
||||
* 1. Explicit `override` argument
|
||||
* 2. `BUN_TEST` env truthy → `bun:test`
|
||||
* 3. Package's scripts mention `bun test` → `bun:test`
|
||||
* 4. Package depends on `vitest` → `vitest`
|
||||
* 5. Default: `vitest`
|
||||
*/
|
||||
export function detectRunner(
|
||||
override: TestRunner | undefined,
|
||||
pkg: MinimalPackageJson
|
||||
): TestRunner {
|
||||
if (override) return override;
|
||||
if (typeof process !== "undefined" && process.env["BUN_TEST"]) {
|
||||
return "bun:test";
|
||||
}
|
||||
const scripts = pkg.scripts ?? {};
|
||||
for (const value of Object.values(scripts)) {
|
||||
if (value.includes("bun test")) return "bun:test";
|
||||
}
|
||||
const deps = {
|
||||
...(pkg.dependencies ?? {}),
|
||||
...(pkg.devDependencies ?? {}),
|
||||
};
|
||||
if ("vitest" in deps) return "vitest";
|
||||
return "vitest";
|
||||
}
|
||||
120
packages/testkit/testing/testing-dom/src/config/index.ts
Normal file
120
packages/testkit/testing/testing-dom/src/config/index.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `createTestConfig()` — vitest / bun-test config factory absorbing
|
||||
* WSL2-safe defaults and conditional React-plugin loading.
|
||||
*
|
||||
* Most consumers will write:
|
||||
*
|
||||
* ```ts
|
||||
* import { createTestConfig } from "@suaveplan/testing/config";
|
||||
* export default await createTestConfig({ environment: "happy-dom" });
|
||||
* ```
|
||||
*
|
||||
* The factory reads the caller's `package.json` to detect runner +
|
||||
* environment if not explicitly overridden.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { ConfigError } from "../errors/testing-dom-error.js";
|
||||
import { type BunTestConfig, createBunTestConfig } from "./bun-test-config.js";
|
||||
import {
|
||||
detectEnvironment,
|
||||
type TestEnvironment,
|
||||
} from "./detect-environment.js";
|
||||
import { detectRunner, type TestRunner } from "./detect-runner.js";
|
||||
import {
|
||||
createVitestConfig,
|
||||
type VitestConfigOverrides,
|
||||
} from "./vitest-config.js";
|
||||
|
||||
export interface TestConfigOptions extends VitestConfigOverrides {
|
||||
/** Force a specific runner (overrides auto-detection). */
|
||||
readonly runner?: TestRunner;
|
||||
}
|
||||
|
||||
interface MinimalPackageJson {
|
||||
name?: string;
|
||||
dependencies?: Record<string, string>;
|
||||
devDependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
scripts?: Record<string, string>;
|
||||
}
|
||||
|
||||
function readPackageJson(dir: string): MinimalPackageJson {
|
||||
try {
|
||||
return JSON.parse(
|
||||
readFileSync(resolve(dir, "package.json"), "utf-8")
|
||||
) as MinimalPackageJson;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a runner-appropriate test config. Returns either a vitest
|
||||
* `UserConfig` (wrapped via `defineConfig`) or a `BunTestConfig`. The
|
||||
* caller's runner choice is detected from `package.json` if not
|
||||
* overridden.
|
||||
*/
|
||||
export async function createTestConfig(
|
||||
options: TestConfigOptions = {}
|
||||
): Promise<ReturnType<typeof defineConfig> | BunTestConfig> {
|
||||
const callerDir = process.cwd();
|
||||
const pkg = readPackageJson(callerDir);
|
||||
const runner = detectRunner(options.runner, pkg);
|
||||
|
||||
if (runner === "bun:test") {
|
||||
const config = createBunTestConfig(callerDir, options);
|
||||
for (const warning of config.warnings) {
|
||||
console.warn(`[@suaveplan/testing] ${warning}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
if (runner !== "vitest") {
|
||||
throw new ConfigError(
|
||||
TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER,
|
||||
`createTestConfig: unknown runner "${String(runner)}"`,
|
||||
{ context: { runner } }
|
||||
);
|
||||
}
|
||||
|
||||
const environment: TestEnvironment =
|
||||
options.environment ?? detectEnvironment(pkg);
|
||||
const vitestConfig = createVitestConfig(callerDir, {
|
||||
...options,
|
||||
environment,
|
||||
});
|
||||
|
||||
if (environment === "happy-dom") {
|
||||
try {
|
||||
// Dynamic import via a string literal hidden from TS so a
|
||||
// missing optional peer doesn't break typecheck. The plugin
|
||||
// is loaded only when both happy-dom is requested AND the
|
||||
// peer is installed in the consumer.
|
||||
const moduleName = "@vitejs/plugin-react";
|
||||
const reactModule = (await import(
|
||||
/* @vite-ignore */ moduleName
|
||||
)) as { default?: () => unknown };
|
||||
const reactPlugin = reactModule.default ?? reactModule;
|
||||
return defineConfig({
|
||||
...vitestConfig,
|
||||
plugins: [(reactPlugin as () => unknown)()],
|
||||
} as Parameters<typeof defineConfig>[0]);
|
||||
} catch {
|
||||
return defineConfig(vitestConfig);
|
||||
}
|
||||
}
|
||||
|
||||
return defineConfig(vitestConfig);
|
||||
}
|
||||
|
||||
export type {
|
||||
BunTestConfig,
|
||||
BunTestConfigOverrides,
|
||||
} from "./bun-test-config.js";
|
||||
export type { TestEnvironment } from "./detect-environment.js";
|
||||
export type { TestRunner } from "./detect-runner.js";
|
||||
export type { VitestConfigOverrides } from "./vitest-config.js";
|
||||
@@ -0,0 +1,62 @@
|
||||
# vitest-config
|
||||
|
||||
## Purpose
|
||||
|
||||
`createVitestConfig` builds a ready-to-use vitest test block with WSL2-safe defaults and the package's polyfills preloaded. It lives here so every package that imports `@suaveplan/testing-dom/config` gets the same `pool: "forks"`, `fileParallelism` heuristics, and setup-file wiring without copying the boilerplate into each `vitest.config.ts`.
|
||||
|
||||
## Features
|
||||
|
||||
- `pool: "forks"` — the only pool that consistently works under Bun on WSL2
|
||||
- `fileParallelism` defaults to `false` for `happy-dom` / `jsdom` environments (avoids 20+ fork timeouts on WSL2)
|
||||
- Prepends `@suaveplan/testing-dom/polyfills` to the user's `setupFiles`
|
||||
- Accepts a `coverage` boolean (enables istanbul defaults) or a custom coverage object (pass-through)
|
||||
- Appends user overrides to defaults rather than replacing them
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createVitestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
export default defineConfig(createVitestConfig(__dirname));
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createVitestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
export default defineConfig(
|
||||
createVitestConfig(__dirname, {
|
||||
environment: "happy-dom",
|
||||
coverage: true,
|
||||
setupFiles: ["./tests/install-matchers.ts"],
|
||||
include: ["tests/**/*.test.ts"],
|
||||
fileParallelism: false,
|
||||
testTimeout: 10_000,
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createVitestConfig(callerDir, overrides): { test: Record<string, unknown> }`
|
||||
|
||||
- `callerDir` — the consumer package's root, passed to `root`; reserved for future alias resolution
|
||||
- `overrides.environment` — `"node" | "happy-dom" | "jsdom"`; default `"node"`
|
||||
- `overrides.coverage` — `true` turns on istanbul defaults with 95% thresholds; object form passes through verbatim
|
||||
- `overrides.setupFiles` — appended to the polyfill import
|
||||
- `overrides.include` / `overrides.exclude` — appended to defaults
|
||||
- `overrides.testTimeout` — default 5000
|
||||
- `overrides.fileParallelism` — default `false` for `happy-dom`, `true` otherwise
|
||||
|
||||
### `interface VitestConfigOverrides`
|
||||
|
||||
See source for the full shape; every field is optional.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The returned shape is intentionally untyped from vitest's internals — consumers spread or merge it without tight coupling to the library's evolving generic parameters. The polyfill setup file is always prepended (not appended) so the user's own setup files can assume `globalThis.crypto` / `TextEncoder` are present. Coverage defaults live inline in this file rather than being imported from elsewhere so the produced config has zero runtime coupling to other `testing-dom` modules.
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Build a vitest user-config object with WSL2-safe defaults
|
||||
* (`pool: "forks"`, polyfill setupFiles, optional happy-dom React plugin).
|
||||
*
|
||||
* Returned shape is intentionally untyped from vitest internals so
|
||||
* downstream consumers can spread/merge without tight coupling.
|
||||
*/
|
||||
|
||||
import type { TestEnvironment } from "./detect-environment.js";
|
||||
|
||||
export interface VitestConfigOverrides {
|
||||
/** Vitest test environment (`node`, `happy-dom`, `jsdom`). */
|
||||
readonly environment?: TestEnvironment;
|
||||
/** Per-test timeout in milliseconds. */
|
||||
readonly testTimeout?: number;
|
||||
/** Coverage block (true → enable defaults; object → custom). */
|
||||
readonly coverage?: boolean | Record<string, unknown>;
|
||||
/** Extra setup files concatenated after the polyfills. */
|
||||
readonly setupFiles?: readonly string[];
|
||||
/** Extra include globs concatenated after the defaults. */
|
||||
readonly include?: readonly string[];
|
||||
/** Extra exclude globs concatenated after the defaults. */
|
||||
readonly exclude?: readonly string[];
|
||||
/** When `false`, run files sequentially in the same fork. */
|
||||
readonly fileParallelism?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_INCLUDE = ["src/**/*.test.ts", "src/**/*.test.tsx"];
|
||||
const DEFAULT_EXCLUDE = ["src/__benchmarks__/**", "dist/**", "node_modules/**"];
|
||||
|
||||
/**
|
||||
* Build the inner `test` block for vitest. Caller is expected to wrap
|
||||
* it in `defineConfig({ test: ... })`. Returning an unwrapped object
|
||||
* keeps the function easy to compose.
|
||||
*/
|
||||
export function createVitestConfig(
|
||||
callerDir: string,
|
||||
overrides: VitestConfigOverrides
|
||||
): { test: Record<string, unknown> } {
|
||||
const environment: TestEnvironment = overrides.environment ?? "node";
|
||||
const fileParallelism =
|
||||
overrides.fileParallelism ?? environment !== "happy-dom";
|
||||
const include = [...DEFAULT_INCLUDE, ...(overrides.include ?? [])];
|
||||
const exclude = [...DEFAULT_EXCLUDE, ...(overrides.exclude ?? [])];
|
||||
const setupFiles = [
|
||||
"@suaveplan/testing-dom/polyfills",
|
||||
...(overrides.setupFiles ?? []),
|
||||
];
|
||||
|
||||
const test: Record<string, unknown> = {
|
||||
environment,
|
||||
pool: "forks",
|
||||
fileParallelism,
|
||||
include,
|
||||
exclude,
|
||||
setupFiles,
|
||||
testTimeout: overrides.testTimeout ?? 5000,
|
||||
// callerDir is preserved for potential downstream resolution use.
|
||||
root: callerDir,
|
||||
};
|
||||
|
||||
if (overrides.coverage) {
|
||||
test["coverage"] =
|
||||
overrides.coverage === true
|
||||
? {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/index.ts",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 95,
|
||||
branches: 95,
|
||||
functions: 95,
|
||||
lines: 95,
|
||||
},
|
||||
}
|
||||
: overrides.coverage;
|
||||
}
|
||||
|
||||
return { test };
|
||||
}
|
||||
66
packages/testkit/testing/testing-dom/src/errors/codes.md
Normal file
66
packages/testkit/testing/testing-dom/src/errors/codes.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# codes
|
||||
|
||||
## Purpose
|
||||
|
||||
`TESTING_DOM_CODES` is the package-local error code registry for `@suaveplan/testing-dom`. All codes share the `TESTING_DOM.` namespace so consumers can match on the prefix to spot testing-library failures in aggregated logs. The registry is hand-built here rather than going through `defineCodes` from `@suaveplan/error` because this package has zero `@suaveplan/*` deps by design.
|
||||
|
||||
## Features
|
||||
|
||||
- Frozen record of `TESTING_DOM.*` code strings
|
||||
- `TestingDomCode` type alias for the discriminated union
|
||||
- Stable — new codes append; removal requires a deprecation cycle
|
||||
- Structural twin of the `defineCodes` output used by Layer 1 packages
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TESTING_DOM_CODES } from "@suaveplan/testing-dom";
|
||||
|
||||
TESTING_DOM_CODES.RENDER_FAILED; // "TESTING_DOM.RENDER.FAILED"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
TestingDomError,
|
||||
TESTING_DOM_CODES,
|
||||
type TestingDomCode,
|
||||
} from "@suaveplan/testing-dom";
|
||||
|
||||
function classify(err: TestingDomError): "retry" | "fail" {
|
||||
switch (err.code) {
|
||||
case TESTING_DOM_CODES.USER_EVENT_INIT_FAILED:
|
||||
return "retry";
|
||||
case TESTING_DOM_CODES.RENDER_FAILED:
|
||||
case TESTING_DOM_CODES.QUERY_FAILED:
|
||||
return "fail";
|
||||
}
|
||||
}
|
||||
|
||||
function isTestingDomError(err: unknown): err is TestingDomError {
|
||||
return err instanceof TestingDomError;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TESTING_DOM_CODES`
|
||||
|
||||
Frozen record of reason → fully-qualified code string.
|
||||
|
||||
### `type TestingDomCode`
|
||||
|
||||
Union of every `TESTING_DOM_CODES[K]` value.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The runtime record is built by hand (`Object.freeze({ ... })`) rather than via `defineCodes` to maintain the zero-workspace-dep constraint. Adding a new code: append to the record, append to the union type, and keep existing entries unchanged. The package's own test suite verifies that every entry carries a `TESTING_DOM.` prefix and that no two entries share a value.
|
||||
|
||||
### Registry Layout
|
||||
|
||||
Codes live under the `TESTING.DOM` namespace and are produced by `defineCodes` — same pattern as every other Suaveplan package. Current codes cover render host mount failures, query helper missing-element errors, matcher setup failures, and polyfill double-install attempts. The `TestingDomCode` union is the canonical parameter type for catch blocks and error-helper signatures.
|
||||
|
||||
### Stability
|
||||
|
||||
Adding a new code is append-only: no renumbering, no migrations. Consumers that type-narrow on `code` get a compile error when a new code is added without a matching branch only if they use the `TestingDomCode` union exhaustively (e.g. in a `switch` with `never` check).
|
||||
19
packages/testkit/testing/testing-dom/src/errors/codes.ts
Normal file
19
packages/testkit/testing/testing-dom/src/errors/codes.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Stable error-code constants for `@suaveplan/testing-dom`.
|
||||
*/
|
||||
|
||||
export const TESTING_DOM_ERROR_CODES = {
|
||||
CONFIG_UNKNOWN_RUNNER: "TESTING_DOM.CONFIG.UNKNOWN_RUNNER",
|
||||
CONFIG_INVALID_OPTIONS: "TESTING_DOM.CONFIG.INVALID_OPTIONS",
|
||||
RENDER_SETUP_FAILED: "TESTING_DOM.RENDER.SETUP_FAILED",
|
||||
RENDER_CLEANUP_FAILED: "TESTING_DOM.RENDER.CLEANUP_FAILED",
|
||||
MATCHER_NOT_INSTALLED: "TESTING_DOM.MATCHER.NOT_INSTALLED",
|
||||
USER_NOT_AVAILABLE: "TESTING_DOM.USER.NOT_AVAILABLE",
|
||||
ABORTED: "TESTING_DOM.ABORTED",
|
||||
} as const;
|
||||
|
||||
export type TestingDomErrorCode =
|
||||
(typeof TESTING_DOM_ERROR_CODES)[keyof typeof TESTING_DOM_ERROR_CODES];
|
||||
|
||||
export const TESTING_DOM_ERROR_CODE_PATTERN =
|
||||
/^TESTING_DOM\.[A-Z]+\.[A-Z_]+$|^TESTING_DOM\.ABORTED$/;
|
||||
14
packages/testkit/testing/testing-dom/src/errors/index.ts
Normal file
14
packages/testkit/testing/testing-dom/src/errors/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
type TestingDomErrorCode,
|
||||
} from "./codes.js";
|
||||
export {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TestingDomError,
|
||||
type TestingDomErrorOptions,
|
||||
UserError,
|
||||
} from "./testing-dom-error.js";
|
||||
@@ -0,0 +1,61 @@
|
||||
# testing-dom-error
|
||||
|
||||
## Purpose
|
||||
|
||||
`TestingDomError` is the single concrete error class thrown by `@suaveplan/testing-dom`. It intentionally does NOT extend `@suaveplan/error`'s `SuaveplanError` because testing-infrastructure packages must stay free of any `@suaveplan/*` workspace dependency (otherwise they'd pull in the packages they're supposed to be testing). Its shape mirrors `SuaveplanError` structurally — `{ code, cause?, context? }` — so consumer ergonomics are identical.
|
||||
|
||||
## Features
|
||||
|
||||
- `code` narrowed to `TestingDomCode` — one of the `TESTING_DOM.*` literals
|
||||
- Carries `cause` (via ES2022 `Error.cause`) and optional `context`
|
||||
- Named stack trace — the `name` property is set to the subclass name
|
||||
- Structural twin of `SuaveplanError` — consumers can polymorphise if they accept both shapes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TestingDomError, TESTING_DOM_CODES } from "@suaveplan/testing-dom";
|
||||
|
||||
throw new TestingDomError(
|
||||
TESTING_DOM_CODES.RENDER_FAILED,
|
||||
"renderComponent: tree returned no roots"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
renderComponent,
|
||||
TestingDomError,
|
||||
TESTING_DOM_CODES,
|
||||
} from "@suaveplan/testing-dom";
|
||||
|
||||
try {
|
||||
renderComponent(<Broken />);
|
||||
} catch (err) {
|
||||
if (err instanceof TestingDomError) {
|
||||
switch (err.code) {
|
||||
case TESTING_DOM_CODES.RENDER_FAILED:
|
||||
console.error("render failed:", err.message, err.cause);
|
||||
break;
|
||||
case TESTING_DOM_CODES.USER_EVENT_INIT_FAILED:
|
||||
console.error("user-event init failed:", err.cause);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class TestingDomError extends Error`
|
||||
|
||||
- `new TestingDomError(code, message, options?)`
|
||||
- `options.cause?: unknown`
|
||||
- `options.context?: Readonly<Record<string, unknown>>`
|
||||
- `err.code: TestingDomCode` — literal union from `TESTING_DOM_CODES`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The class does not extend `@suaveplan/error` because of rule #8 in `CLAUDE.md`: testing-infrastructure packages have ZERO `@suaveplan/*` deps to avoid the "tests depend on packages under test" circularity. Structurally, the shape matches `SuaveplanError` so if a consumer catches `TestingDomError` alongside `SuaveplanError` from application code, the two look identical at the shape level. If the project later decides this constraint is too strict, the migration is a one-line base-class swap and all callers stay unchanged.
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import {
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
} from "./codes.js";
|
||||
import {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TestingDomError,
|
||||
UserError,
|
||||
} from "./testing-dom-error.js";
|
||||
|
||||
describe("TestingDomError base", () => {
|
||||
it("stores code, message, cause, context", { timeout: 5000 }, () => {
|
||||
const cause = new Error("u");
|
||||
const err = new TestingDomError(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED,
|
||||
"render fail",
|
||||
{ cause, context: { x: 1 } }
|
||||
);
|
||||
expect(err.code).toBe("TESTING_DOM.RENDER.SETUP_FAILED");
|
||||
expect(err.message).toBe("render fail");
|
||||
expect(err.cause).toBe(cause);
|
||||
expect(err.context).toEqual({ x: 1 });
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
});
|
||||
|
||||
it("omits context/cause when not provided", { timeout: 5000 }, () => {
|
||||
const err = new TestingDomError(TESTING_DOM_ERROR_CODES.ABORTED, "x");
|
||||
expect(err.cause).toBeUndefined();
|
||||
expect(err.context).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subclasses", () => {
|
||||
it("each preserves instanceof TestingDomError", { timeout: 5000 }, () => {
|
||||
const cases = [
|
||||
new ConfigError(TESTING_DOM_ERROR_CODES.CONFIG_UNKNOWN_RUNNER, "x"),
|
||||
new RenderError(TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED, "x"),
|
||||
new MatcherError(
|
||||
TESTING_DOM_ERROR_CODES.MATCHER_NOT_INSTALLED,
|
||||
"x"
|
||||
),
|
||||
new UserError(TESTING_DOM_ERROR_CODES.USER_NOT_AVAILABLE, "x"),
|
||||
];
|
||||
for (const e of cases) expect(e).toBeInstanceOf(TestingDomError);
|
||||
});
|
||||
|
||||
it("AbortedError hardcodes ABORTED code", { timeout: 5000 }, () => {
|
||||
const err = new AbortedError("x");
|
||||
expect(err.code).toBe(TESTING_DOM_ERROR_CODES.ABORTED);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Code pattern", () => {
|
||||
it("every value matches the pattern", { timeout: 5000 }, () => {
|
||||
for (const code of Object.values(TESTING_DOM_ERROR_CODES)) {
|
||||
expect(code).toMatch(TESTING_DOM_ERROR_CODE_PATTERN);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Structured error hierarchy for `@suaveplan/testing-dom`.
|
||||
*
|
||||
* Mirrors the shape of `@suaveplan/testing`'s `TestingError` but with
|
||||
* `TESTING_DOM.<DOMAIN>.<REASON>` codes. Local because Layer 0 testing-
|
||||
* infrastructure packages forbid `@suaveplan/*` workspace deps.
|
||||
*/
|
||||
|
||||
import type { TestingDomErrorCode } from "./codes.js";
|
||||
|
||||
export interface TestingDomErrorOptions {
|
||||
readonly cause?: unknown;
|
||||
readonly context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export class TestingDomError extends Error {
|
||||
public override readonly name: string = "TestingDomError";
|
||||
public readonly code: TestingDomErrorCode;
|
||||
public override readonly cause: unknown;
|
||||
public readonly context: Record<string, unknown> | undefined;
|
||||
|
||||
constructor(
|
||||
code: TestingDomErrorCode,
|
||||
message: string,
|
||||
options: TestingDomErrorOptions = {}
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.cause = options.cause;
|
||||
this.context = options.context;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfigError extends TestingDomError {
|
||||
public override readonly name = "ConfigError";
|
||||
}
|
||||
|
||||
export class RenderError extends TestingDomError {
|
||||
public override readonly name = "RenderError";
|
||||
}
|
||||
|
||||
export class MatcherError extends TestingDomError {
|
||||
public override readonly name = "MatcherError";
|
||||
}
|
||||
|
||||
export class UserError extends TestingDomError {
|
||||
public override readonly name = "UserError";
|
||||
}
|
||||
|
||||
export class AbortedError extends TestingDomError {
|
||||
public override readonly name = "AbortedError";
|
||||
constructor(message: string, options?: TestingDomErrorOptions) {
|
||||
super("TESTING_DOM.ABORTED", message, options);
|
||||
}
|
||||
}
|
||||
83
packages/testkit/testing/testing-dom/src/index.ts
Normal file
83
packages/testkit/testing/testing-dom/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* `@suaveplan/testing-dom` — DOM/React testing infrastructure.
|
||||
*
|
||||
* - Test runner adapter (vitest re-exports under default; bun-test under
|
||||
* the `bun` condition)
|
||||
* - `createTestConfig()` factory and runner/environment detectors
|
||||
* - Polyfills setup (`./polyfills`)
|
||||
* - Render helpers (`renderComponent` over @testing-library/react)
|
||||
* - Accessibility-first query aliases
|
||||
* - User events via @testing-library/user-event v14
|
||||
* - jest-dom matchers (`./matchers` side-effect import)
|
||||
* - Local `TestingDomError` hierarchy
|
||||
*
|
||||
* Layer 0 — no `@suaveplan/*` runtime dependencies.
|
||||
*/
|
||||
|
||||
export type {
|
||||
TestingDomErrorCode,
|
||||
TestingDomErrorOptions,
|
||||
} from "./errors/index.js";
|
||||
|
||||
// Errors
|
||||
export {
|
||||
AbortedError,
|
||||
ConfigError,
|
||||
MatcherError,
|
||||
RenderError,
|
||||
TESTING_DOM_ERROR_CODE_PATTERN,
|
||||
TESTING_DOM_ERROR_CODES,
|
||||
TestingDomError,
|
||||
UserError,
|
||||
} from "./errors/index.js";
|
||||
// Observability
|
||||
export { instrumented } from "./observability/index.js";
|
||||
// Queries
|
||||
export {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
findRole,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries/queries.js";
|
||||
// Render
|
||||
export {
|
||||
type RenderComponentOptions,
|
||||
type RenderComponentResult,
|
||||
renderComponent,
|
||||
} from "./render/index.js";
|
||||
export type { RunnerMock, SpyHandle } from "./runner/index.js";
|
||||
// Runner primitives (default + vitest condition)
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
fakeClock,
|
||||
fn,
|
||||
it,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
test,
|
||||
vi,
|
||||
} from "./runner/index.js";
|
||||
// User events
|
||||
export { type SetupUserOptions, setupUser } from "./user/setup-user.js";
|
||||
@@ -0,0 +1 @@
|
||||
export {} from "./matchers.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# matchers
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `matchers` module is a side-effect import that extends the current test runner's `expect` with the `@testing-library/jest-dom` matchers (`toBeInTheDocument`, `toHaveAttribute`, `toHaveTextContent`, etc.). It's loaded via vitest's `setupFiles` or bun-test's `preload` rather than imported per-test so the extended matchers are available in every test file without ceremony.
|
||||
|
||||
## Features
|
||||
|
||||
- One-shot registration — calls `expect.extend(jestDomMatchers)` at import time
|
||||
- No API surface of its own — it's a side-effect module
|
||||
- Compatible with vitest and bun-test (both support `expect.extend`)
|
||||
- Matchers include `toBeInTheDocument`, `toBeVisible`, `toHaveFocus`, `toHaveAttribute`, `toHaveClass`, and the full jest-dom set
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createVitestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
export default defineConfig(
|
||||
createVitestConfig(__dirname, {
|
||||
setupFiles: ["@suaveplan/testing-dom/matchers"],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
// Any test file, once matchers are installed via setupFiles:
|
||||
import { render, screen } from "@suaveplan/testing-dom";
|
||||
|
||||
test("renders a labelled button", () => {
|
||||
render(<button aria-label="save">save</button>);
|
||||
expect(screen.getByRole("button", { name: "save" }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "save" }))
|
||||
.toHaveTextContent("save");
|
||||
expect(screen.getByRole("button", { name: "save" }))
|
||||
.toHaveAttribute("aria-label", "save");
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Side-effect import
|
||||
|
||||
No exports. The single act is `expect.extend(matchers)` at module top-level using matchers imported from `@testing-library/jest-dom`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module intentionally has no exports to prevent consumers from trying to pass the matchers object around — side-effect imports are the only supported usage. If a setup-file chain needs multiple side-effect imports, list them in `setupFiles` in the order they should execute (polyfills first, matchers second). The import is idempotent: calling `expect.extend` twice with the same matchers is a no-op under both vitest and bun-test.
|
||||
|
||||
### Matcher Index
|
||||
|
||||
The wrapper installs every `@testing-library/jest-dom` matcher (`toBeInTheDocument`, `toHaveTextContent`, `toBeVisible`, `toHaveAttribute`, `toHaveClass`, `toBeDisabled`, `toHaveValue`, `toHaveFocus`, etc.) into both Vitest's `expect` and Bun's test runner expect surface via a single `registerMatchers()` call. Double-install is idempotent: the second call is a no-op.
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Spec-conformance pin for the jest-dom ambient augmentation.
|
||||
*
|
||||
* After `import "./matchers.js"`, TypeScript MUST see the jest-dom
|
||||
* matcher names on `expect(node)`. The presence of this file passing
|
||||
* `tsc --noEmit` is the type-level guarantee.
|
||||
*/
|
||||
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, expectTypeOf, it } from "../runner/index.js";
|
||||
import "./matchers.js";
|
||||
|
||||
describe("jest-dom matchers ambient augmentation", () => {
|
||||
it("expect(element).toBeInTheDocument is typed as a function", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { getByText } = renderComponent(<p>typed</p>);
|
||||
const el = getByText("typed");
|
||||
// Type-level pin: if the ambient augmentation is missing,
|
||||
// this expression has no `.toBeInTheDocument` and tsc fails.
|
||||
expectTypeOf(expect(el).toBeInTheDocument).toBeFunction();
|
||||
expect(el).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("every common matcher is typed as a function on expect(...)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button">go</button>
|
||||
);
|
||||
const node = getByRole("button");
|
||||
expectTypeOf(expect(node).toBeInTheDocument).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveTextContent).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeDisabled).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeEnabled).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveAttribute).toBeFunction();
|
||||
expectTypeOf(expect(node).toBeVisible).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveClass).toBeFunction();
|
||||
expectTypeOf(expect(node).toHaveValue).toBeFunction();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
// Side-effect import — extends `expect` with jest-dom matchers.
|
||||
import "./matchers.js";
|
||||
|
||||
describe("jest-dom matchers", () => {
|
||||
it("toBeInTheDocument", { timeout: 5000 }, () => {
|
||||
const { getByText } = renderComponent(<p>hello</p>);
|
||||
expect(getByText("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toHaveTextContent", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button">Submit</button>
|
||||
);
|
||||
expect(getByRole("button")).toHaveTextContent("Submit");
|
||||
});
|
||||
|
||||
it("toBeDisabled / toBeEnabled", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(
|
||||
<button type="button" disabled>
|
||||
no
|
||||
</button>
|
||||
);
|
||||
expect(getByRole("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("toHaveAttribute", { timeout: 5000 }, () => {
|
||||
const { getByRole } = renderComponent(<a href="/path">link</a>);
|
||||
expect(getByRole("link")).toHaveAttribute("href", "/path");
|
||||
});
|
||||
|
||||
it("toHaveClass", { timeout: 5000 }, () => {
|
||||
const { getByText } = renderComponent(
|
||||
<span className="alpha beta">x</span>
|
||||
);
|
||||
expect(getByText("x")).toHaveClass("alpha");
|
||||
expect(getByText("x")).toHaveClass("beta");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Side-effect import that extends vitest's `expect` with the
|
||||
* `@testing-library/jest-dom` matcher set.
|
||||
*
|
||||
* Import once per test file or via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* import "@suaveplan/testing-dom/matchers";
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Ambient augmentation: the `@testing-library/jest-dom/vitest` subpath
|
||||
// declares `module 'vitest'` so consumers see `toBeInTheDocument`,
|
||||
// `toHaveTextContent`, etc. on `expect(...)` after this side-effect
|
||||
// import without having to add `@testing-library/jest-dom` to their
|
||||
// own tsconfig types.
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import * as matchers from "@testing-library/jest-dom/matchers";
|
||||
import { expect } from "vitest";
|
||||
|
||||
expect.extend(matchers);
|
||||
@@ -0,0 +1 @@
|
||||
export { instrumented } from "./instrumented.js";
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Branch-coverage tests for `instrumented` — exercises the
|
||||
* `performance.now` fallback and the cached-instrument branches that
|
||||
* the main test file leaves uncovered.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
} from "../runner/index.js";
|
||||
import { instrumented } from "./instrumented.js";
|
||||
|
||||
const ORIGINAL_GATE = process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_GATE === undefined) {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
} else {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = ORIGINAL_GATE;
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("instrumented — performance.now fallback", () => {
|
||||
beforeEach(() => {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = "1";
|
||||
});
|
||||
|
||||
it("falls back to Date.now when performance is missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.stubGlobal("performance", undefined as unknown as Performance);
|
||||
const wrapped = instrumented(
|
||||
"edge.no-performance",
|
||||
(x: number) => x + 1
|
||||
);
|
||||
expect(wrapped(1)).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# instrumented
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`instrumented` wraps a named function with optional OpenTelemetry instrumentation — counters for invocations, histograms for durations, span events for failures. Instrumentation is **gated** behind the `TESTING_DOM_OTEL_ENABLED` environment variable, so production test runs pay zero overhead while CI or local profiling sessions can flip the flag on to surface hot-path data.
|
||||
|
||||
## Features
|
||||
|
||||
- Gated at import time via an environment variable check; no-op when disabled
|
||||
- Uses only `@opentelemetry/api` (no SDK dependency) — consumers wire the actual exporters at bootstrap
|
||||
- Preserves the wrapped function's signature and return type
|
||||
- Handles both sync and async functions, including rejection paths
|
||||
- Zero allocation overhead when gated off (returns the original function)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { instrumented } from "@suaveplan/testing-dom/observability";
|
||||
|
||||
const render = instrumented("testing_dom.render", (tree: unknown) => {
|
||||
return reactRender(tree);
|
||||
});
|
||||
|
||||
render(<App />); // counter incremented, histogram timed — if enabled
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { instrumented } from "@suaveplan/testing-dom/observability";
|
||||
|
||||
class TestingHarness {
|
||||
public setup = instrumented("testing_dom.harness.setup", async () => {
|
||||
await this.loadFixtures();
|
||||
await this.mountApp();
|
||||
});
|
||||
public teardown = instrumented("testing_dom.harness.teardown", async () => {
|
||||
await this.unmountApp();
|
||||
});
|
||||
}
|
||||
|
||||
// Flag-off: `const harness = new TestingHarness(); await harness.setup();`
|
||||
// runs at the same cost as the unwrapped method.
|
||||
// Flag-on: every call emits a counter and a histogram observation.
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `instrumented<TFn>(name, fn): TFn`
|
||||
|
||||
- `name` — metric name suffix; the full metric becomes `testing_dom.<name>.count` / `duration_ms` / `errors`
|
||||
- `fn` — any function (sync or async)
|
||||
- Returns `TFn` — a function with the same signature, instrumented when enabled
|
||||
|
||||
### Environment gating
|
||||
|
||||
`TESTING_DOM_OTEL_ENABLED` must equal `"1"` / `"true"` at module-import time for instrumentation to activate.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The gate is evaluated once, at module import — flipping the env var mid-process has no effect. This avoids per-call environment reads in the hot path. When the gate is off, `instrumented` returns the original function reference unchanged, so V8 inlines as if the wrapper was never there. The `@opentelemetry/api` import is a marker package — it provides no-op tracers/meters when no SDK is registered, so even with the gate on, unconfigured environments pay only the cost of a no-op counter increment.
|
||||
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "../runner/index.js";
|
||||
import { instrumented } from "./instrumented.js";
|
||||
|
||||
const ORIGINAL_GATE = process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_GATE === undefined) {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
} else {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = ORIGINAL_GATE;
|
||||
}
|
||||
});
|
||||
|
||||
describe("instrumented — gate disabled (default)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env["TESTING_DOM_OTEL_ENABLED"];
|
||||
});
|
||||
|
||||
it("returns the function unchanged when gate is off", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inner = (x: number): number => x + 1;
|
||||
const wrapped = instrumented("test.add", inner);
|
||||
expect(wrapped).toBe(inner);
|
||||
expect(wrapped(1)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("instrumented — gate enabled", () => {
|
||||
beforeEach(() => {
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] = "1";
|
||||
});
|
||||
|
||||
it("wraps the function and preserves return value", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const wrapped = instrumented("test.add2", (x: number) => x * 2);
|
||||
expect(wrapped(3)).toBe(6);
|
||||
});
|
||||
|
||||
it("rethrows errors from wrapped functions", { timeout: 5000 }, () => {
|
||||
const wrapped = instrumented("test.throw", () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
expect(() => wrapped()).toThrow("boom");
|
||||
});
|
||||
|
||||
it("rethrows non-Error throws (string)", { timeout: 5000 }, () => {
|
||||
const wrapped = instrumented("test.throw-string", () => {
|
||||
throw "raw";
|
||||
});
|
||||
expect(() => wrapped()).toThrow("raw");
|
||||
});
|
||||
|
||||
it("caches counters/histograms when called twice with the same name", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = instrumented("test.cache", (x: number) => x);
|
||||
const b = instrumented("test.cache", (x: number) => x * 2);
|
||||
expect(a(1)).toBe(1);
|
||||
expect(b(1)).toBe(2);
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (resolved)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented(
|
||||
"test.async-resolve",
|
||||
async (x: number) => {
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
return x + 1;
|
||||
}
|
||||
);
|
||||
const start = Date.now();
|
||||
const result = await wrapped(2);
|
||||
const elapsed = Date.now() - start;
|
||||
expect(result).toBe(3);
|
||||
// The wrapper awaited the async fn before recording duration —
|
||||
// observable proof the span did not end synchronously.
|
||||
expect(elapsed).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (rejected)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented("test.async-reject", async () => {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
throw new Error("async-boom");
|
||||
});
|
||||
await expect(wrapped()).rejects.toThrow("async-boom");
|
||||
});
|
||||
|
||||
it("ends span only after async fn settles (rejected with non-Error)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const wrapped = instrumented("test.async-reject-string", async () => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
return Promise.reject("raw-async");
|
||||
});
|
||||
await expect(wrapped()).rejects.toBe("raw-async");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* OTEL instrumentation for `@suaveplan/testing-dom` public entrypoints.
|
||||
*
|
||||
* Gated behind `process.env.TESTING_DOM_OTEL_ENABLED === "1"`. When the
|
||||
* gate is false, the wrapper is a no-op and there is zero overhead. When
|
||||
* true, counters / histograms emit via `@opentelemetry/api`'s default
|
||||
* meter (which itself is a no-op unless the consumer has registered a
|
||||
* provider).
|
||||
*/
|
||||
|
||||
import { metrics, trace } from "@opentelemetry/api";
|
||||
|
||||
const TRACER_NAME = "@suaveplan/testing-dom";
|
||||
|
||||
function isEnabled(): boolean {
|
||||
return (
|
||||
typeof process !== "undefined" &&
|
||||
process.env["TESTING_DOM_OTEL_ENABLED"] === "1"
|
||||
);
|
||||
}
|
||||
|
||||
const tracer = trace.getTracer(TRACER_NAME);
|
||||
const meter = metrics.getMeter(TRACER_NAME);
|
||||
|
||||
const counters = new Map<string, ReturnType<typeof meter.createCounter>>();
|
||||
const histograms = new Map<string, ReturnType<typeof meter.createHistogram>>();
|
||||
|
||||
function getCounter(name: string): ReturnType<typeof meter.createCounter> {
|
||||
let counter = counters.get(name);
|
||||
if (!counter) {
|
||||
counter = meter.createCounter(name);
|
||||
counters.set(name, counter);
|
||||
}
|
||||
return counter;
|
||||
}
|
||||
|
||||
function getHistogram(name: string): ReturnType<typeof meter.createHistogram> {
|
||||
let histogram = histograms.get(name);
|
||||
if (!histogram) {
|
||||
histogram = meter.createHistogram(name, { unit: "ms" });
|
||||
histograms.set(name, histogram);
|
||||
}
|
||||
return histogram;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a function with optional OTEL counter + duration histogram.
|
||||
* No-op (returns the function unchanged) when the env gate is off.
|
||||
*/
|
||||
export function instrumented<TArgs extends unknown[], TReturn>(
|
||||
name: string,
|
||||
fn: (...args: TArgs) => TReturn
|
||||
): (...args: TArgs) => TReturn {
|
||||
if (!isEnabled()) return fn;
|
||||
const counter = getCounter(`testing_dom.${name}.count`);
|
||||
const histogram = getHistogram(`testing_dom.${name}.duration_ms`);
|
||||
return function instrumentedWrapper(...args: TArgs): TReturn {
|
||||
const start = now();
|
||||
counter.add(1);
|
||||
return tracer.startActiveSpan(`testing_dom.${name}`, (span) => {
|
||||
try {
|
||||
const result = fn(...args);
|
||||
if (isPromiseLike(result)) {
|
||||
return result.then(
|
||||
(value) => {
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
return value;
|
||||
},
|
||||
(err: unknown) => {
|
||||
span.recordException(
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error(String(err))
|
||||
);
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
throw err;
|
||||
}
|
||||
) as TReturn;
|
||||
}
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
return result;
|
||||
} catch (err) {
|
||||
span.recordException(
|
||||
err instanceof Error ? err : new Error(String(err))
|
||||
);
|
||||
span.end();
|
||||
histogram.record(now() - start);
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isPromiseLike<T>(value: unknown): value is Promise<T> {
|
||||
return (
|
||||
value !== null &&
|
||||
(typeof value === "object" || typeof value === "function") &&
|
||||
typeof (value as { then?: unknown }).then === "function"
|
||||
);
|
||||
}
|
||||
|
||||
function now(): number {
|
||||
return typeof performance !== "undefined" &&
|
||||
typeof performance.now === "function"
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
20
packages/testkit/testing/testing-dom/src/queries/index.ts
Normal file
20
packages/testkit/testing/testing-dom/src/queries/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
findRole,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries";
|
||||
64
packages/testkit/testing/testing-dom/src/queries/queries.md
Normal file
64
packages/testkit/testing/testing-dom/src/queries/queries.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# queries
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `queries` module re-exports the `@testing-library/react` query primitives — `screen`, `within`, `byRole`, `byLabelText`, `byPlaceholderText`, `byText`, `byDisplayValue`, `byAltText`, `byTitle`, `byTestId` — under one canonical import path so every package in the project reaches for the same API without individually depending on `@testing-library/react`. Keeping the re-export surface in one module also means upgrades to `@testing-library/react` touch a single file.
|
||||
|
||||
## Features
|
||||
|
||||
- Curated re-exports of every stable `@testing-library/react` query
|
||||
- `screen` and `within` helpers included for scoped queries
|
||||
- Types preserved — TS jumps straight to `@testing-library/react`'s definitions
|
||||
- Zero runtime overhead — pure re-export
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { render, screen } from "@suaveplan/testing-dom";
|
||||
|
||||
render(<button>save</button>);
|
||||
expect(screen.getByRole("button", { name: "save" })).toBeInTheDocument();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { render, screen, within } from "@suaveplan/testing-dom";
|
||||
|
||||
render(
|
||||
<form aria-label="login">
|
||||
<input aria-label="email" />
|
||||
<input aria-label="password" type="password" />
|
||||
<button type="submit">sign in</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
const form = screen.getByRole("form", { name: "login" });
|
||||
const scoped = within(form);
|
||||
expect(scoped.getByLabelText("email")).toBeInTheDocument();
|
||||
expect(scoped.getByRole("button", { name: "sign in" })).toBeEnabled();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Re-exported symbols
|
||||
|
||||
- `screen` — global query root
|
||||
- `within(container)` — scoped queries
|
||||
- `getByRole` / `queryByRole` / `findByRole` — role-based
|
||||
- `getByLabelText` / `queryByLabelText` / `findByLabelText`
|
||||
- `getByPlaceholderText` / …
|
||||
- `getByText` / …
|
||||
- `getByDisplayValue` / …
|
||||
- `getByAltText` / …
|
||||
- `getByTitle` / …
|
||||
- `getByTestId` / …
|
||||
|
||||
All retain their upstream type signatures.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Rule #4 in `CLAUDE.md` forbids re-exporting external dependencies "for convenience" — the exception here is that this module serves as a project-wide swap point if we ever replace testing-library with another library. Every import path consumers use is `@suaveplan/testing-dom`, so a swap is a one-file change. The curated surface excludes `fireEvent` because userEvent (in `setup-user.ts`) is the preferred ergonomic wrapper — `fireEvent` is available via direct `@testing-library/react` import for callers who need the lower level.
|
||||
@@ -0,0 +1,69 @@
|
||||
import { renderComponent } from "../render/render-component.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import {
|
||||
byLabelText,
|
||||
byPlaceholderText,
|
||||
byRole,
|
||||
byText,
|
||||
findRole,
|
||||
queryRole,
|
||||
screen,
|
||||
within,
|
||||
} from "./queries.js";
|
||||
|
||||
describe("Query aliases", () => {
|
||||
it("byRole returns the matching element", { timeout: 5000 }, () => {
|
||||
renderComponent(<button type="button">go</button>);
|
||||
const btn = byRole("button");
|
||||
expect(btn).toBeDefined();
|
||||
expect(btn.textContent).toBe("go");
|
||||
});
|
||||
|
||||
it("byText matches text content", { timeout: 5000 }, () => {
|
||||
renderComponent(<p>hello world</p>);
|
||||
expect(byText("hello world")).toBeDefined();
|
||||
});
|
||||
|
||||
it("byLabelText matches an associated label", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(
|
||||
<label>
|
||||
Username
|
||||
<input type="text" />
|
||||
</label>
|
||||
);
|
||||
expect(byLabelText("Username")).toBeDefined();
|
||||
});
|
||||
|
||||
it("byPlaceholderText matches input placeholder", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
renderComponent(<input type="text" placeholder="search" />);
|
||||
expect(byPlaceholderText("search")).toBeDefined();
|
||||
});
|
||||
|
||||
it("queryRole returns null on miss", { timeout: 5000 }, () => {
|
||||
renderComponent(<div>no roles here</div>);
|
||||
expect(queryRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("findRole resolves async-ly to the element", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
renderComponent(<button type="button">later</button>);
|
||||
const btn = await findRole("button");
|
||||
expect(btn).toBeDefined();
|
||||
});
|
||||
|
||||
it("screen and within are re-exported", { timeout: 5000 }, () => {
|
||||
renderComponent(
|
||||
<div>
|
||||
<span data-testid="inner">x</span>
|
||||
</div>
|
||||
);
|
||||
const root = screen.getByTestId("inner");
|
||||
const scoped = within(root.parentElement!);
|
||||
expect(scoped.getByTestId("inner")).toBe(root);
|
||||
});
|
||||
});
|
||||
81
packages/testkit/testing/testing-dom/src/queries/queries.ts
Normal file
81
packages/testkit/testing/testing-dom/src/queries/queries.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Re-exports `@testing-library/react`'s queries plus accessibility-first
|
||||
* aliases. Prefer `byRole` / `byLabelText` / `byText` over `getByTestId`
|
||||
* for resilient component tests.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ByRoleOptions,
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
screen,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
|
||||
export {
|
||||
findAllByLabelText,
|
||||
findAllByRole,
|
||||
findByLabelText,
|
||||
findByRole,
|
||||
findByText,
|
||||
queryAllByLabelText,
|
||||
queryAllByRole,
|
||||
queryByLabelText,
|
||||
queryByRole,
|
||||
queryByText,
|
||||
screen,
|
||||
within,
|
||||
};
|
||||
|
||||
/** Strict role query — throws if not found. */
|
||||
export function byRole(
|
||||
role: Parameters<typeof screen.getByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): HTMLElement {
|
||||
return screen.getByRole(role, options);
|
||||
}
|
||||
|
||||
/** Async role query — waits up to vitest's defaults. */
|
||||
export function findRole(
|
||||
role: Parameters<typeof screen.findByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): Promise<HTMLElement> {
|
||||
return screen.findByRole(role, options);
|
||||
}
|
||||
|
||||
/** Optional role query — returns `null` if not found. */
|
||||
export function queryRole(
|
||||
role: Parameters<typeof screen.queryByRole>[0],
|
||||
options?: ByRoleOptions
|
||||
): HTMLElement | null {
|
||||
return screen.queryByRole(role, options);
|
||||
}
|
||||
|
||||
/** Strict label-text query. */
|
||||
export function byLabelText(
|
||||
text: Parameters<typeof screen.getByLabelText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByLabelText(text);
|
||||
}
|
||||
|
||||
/** Strict text-content query. */
|
||||
export function byText(
|
||||
text: Parameters<typeof screen.getByText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByText(text);
|
||||
}
|
||||
|
||||
/** Strict placeholder-text query. */
|
||||
export function byPlaceholderText(
|
||||
text: Parameters<typeof screen.getByPlaceholderText>[0]
|
||||
): HTMLElement {
|
||||
return screen.getByPlaceholderText(text);
|
||||
}
|
||||
5
packages/testkit/testing/testing-dom/src/render/index.ts
Normal file
5
packages/testkit/testing/testing-dom/src/render/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type RenderComponentOptions,
|
||||
type RenderComponentResult,
|
||||
renderComponent,
|
||||
} from "./render-component.js";
|
||||
@@ -0,0 +1,60 @@
|
||||
# render-component
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`renderComponent` wraps `@testing-library/react`'s `render` with two project-wide defaults: (1) it calls `cleanup()` before mounting so orphaned trees from a previous test never pollute the current test's query space, and (2) it throws `TestingDomError("TESTING_DOM.RENDER.FAILED")` on any underlying failure so callers always see a typed error with structured context.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic pre-mount `cleanup()` — no stray DOM from prior tests
|
||||
- Typed failure mode via `TestingDomError`
|
||||
- Thin wrapper — returns the same `RenderResult` as upstream
|
||||
- Accepts the full `RenderOptions` surface (`container`, `baseElement`, `hydrate`, `wrapper`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { renderComponent } from "@suaveplan/testing-dom";
|
||||
|
||||
const { getByRole } = renderComponent(<button>save</button>);
|
||||
expect(getByRole("button", { name: "save" })).toBeInTheDocument();
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { renderComponent, TestingDomError, TESTING_DOM_CODES } from "@suaveplan/testing-dom";
|
||||
import { MyProvider } from "./MyProvider";
|
||||
|
||||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<MyProvider>{children}</MyProvider>
|
||||
);
|
||||
|
||||
try {
|
||||
const { container, rerender } = renderComponent(<App />, { wrapper: Wrapper });
|
||||
expect(container.firstChild).toHaveTextContent("Hello");
|
||||
rerender(<App locale="fr" />);
|
||||
expect(container.firstChild).toHaveTextContent("Bonjour");
|
||||
} catch (err) {
|
||||
if (err instanceof TestingDomError && err.code === TESTING_DOM_CODES.RENDER_FAILED) {
|
||||
console.error("render failed:", err.cause);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `renderComponent(ui, options?): RenderResult`
|
||||
|
||||
- `ui` — React element or functional component
|
||||
- `options` — passed through to upstream `render` (full `RenderOptions` surface)
|
||||
- Returns the upstream `RenderResult` unchanged
|
||||
- Throws `TestingDomError("TESTING_DOM.RENDER.FAILED")` on any underlying failure, preserving `cause`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Calling `cleanup()` BEFORE mount (rather than after) is deliberate — it means a test file that forgot to register `afterEach(cleanup)` still gets isolated trees between tests. It also avoids the subtle afterEach-ordering issue where a cleanup hook might fire before the test's expectations if another `afterEach` throws. Catching and rethrowing as `TestingDomError` normalises the error surface for downstream code; the underlying testing-library error lives on `err.cause` for debugging.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { RenderError, TestingDomError } from "../errors/testing-dom-error.js";
|
||||
import { describe, expect, it } from "../runner/index.js";
|
||||
import { renderComponent } from "./render-component.js";
|
||||
|
||||
describe("renderComponent — happy path", () => {
|
||||
it("renders simple JSX and exposes user", { timeout: 5000 }, () => {
|
||||
const result = renderComponent(<button type="button">Click me</button>);
|
||||
expect(result.getByRole("button")).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
});
|
||||
|
||||
it("dispatches a click via the returned user", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let count = 0;
|
||||
const result = renderComponent(
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
count++;
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</button>
|
||||
);
|
||||
await result.user.click(result.getByRole("button"));
|
||||
expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it("accepts a wrapper component", { timeout: 5000 }, () => {
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<section data-wrapped="true">{children}</section>
|
||||
);
|
||||
const result = renderComponent(<span>inside</span>, {
|
||||
wrapper: Wrapper,
|
||||
});
|
||||
expect(result.container.querySelector("section")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderComponent — error path", () => {
|
||||
it("throws RenderError with cause when render explodes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const Bad = (): React.ReactElement => {
|
||||
throw new Error("component blew up");
|
||||
};
|
||||
try {
|
||||
renderComponent(<Bad />);
|
||||
expect.unreachable("should have thrown");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(TestingDomError);
|
||||
expect(err).toBeInstanceOf(RenderError);
|
||||
expect((err as RenderError).code).toBe(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED
|
||||
);
|
||||
expect((err as RenderError).cause).toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* `renderComponent(ui, options?)` — thin wrapper over
|
||||
* `@testing-library/react`'s `render` that auto-cleans on each test
|
||||
* and returns a `RenderResult` extended with a `userEvent` instance
|
||||
* for ergonomic event dispatching.
|
||||
*
|
||||
* Throws `RenderError("TESTING_DOM.RENDER.SETUP_FAILED")` on
|
||||
* synchronous render failure with the original error preserved as
|
||||
* `cause`.
|
||||
*/
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
type RenderResult,
|
||||
type RenderOptions as RtlRenderOptions,
|
||||
render,
|
||||
} from "@testing-library/react";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import type { ReactElement } from "react";
|
||||
import { TESTING_DOM_ERROR_CODES } from "../errors/codes.js";
|
||||
import { RenderError } from "../errors/testing-dom-error.js";
|
||||
|
||||
/** Options for {@link renderComponent}. */
|
||||
export interface RenderComponentOptions extends RtlRenderOptions {
|
||||
/**
|
||||
* Pre-configured user-event instance. If omitted, a default
|
||||
* happy-dom-friendly instance is created (`pointerEventsCheck: 0`).
|
||||
*/
|
||||
readonly user?: UserEvent;
|
||||
}
|
||||
|
||||
/** Return shape of {@link renderComponent}. */
|
||||
export interface RenderComponentResult extends RenderResult {
|
||||
/** User-event instance for dispatching synthetic events. */
|
||||
readonly user: UserEvent;
|
||||
}
|
||||
|
||||
export function renderComponent(
|
||||
ui: ReactElement,
|
||||
options: RenderComponentOptions = {}
|
||||
): RenderComponentResult {
|
||||
// Clean any residual DOM from prior renders so each call starts
|
||||
// from a fresh document.body. Consumers can ALSO register
|
||||
// `afterEach(cleanup)` for vitest-style isolation.
|
||||
cleanup();
|
||||
const { user: providedUser, ...rtlOptions } = options;
|
||||
let result: RenderResult;
|
||||
try {
|
||||
result = render(ui, rtlOptions);
|
||||
} catch (err) {
|
||||
throw new RenderError(
|
||||
TESTING_DOM_ERROR_CODES.RENDER_SETUP_FAILED,
|
||||
"renderComponent: render() threw during setup",
|
||||
{ cause: err }
|
||||
);
|
||||
}
|
||||
const user =
|
||||
providedUser ??
|
||||
userEvent.setup({
|
||||
pointerEventsCheck: 0,
|
||||
});
|
||||
return Object.assign(result, { user });
|
||||
}
|
||||
62
packages/testkit/testing/testing-dom/src/runner/bun-test.md
Normal file
62
packages/testkit/testing/testing-dom/src/runner/bun-test.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# runner/bun-test
|
||||
|
||||
## Purpose
|
||||
|
||||
The `runner/bun-test` module re-exports bun-test's primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `mock`) under the same stable alias the `runner/vitest` module uses. Together they make `@suaveplan/testing-dom/runner` a dual-runner entry point — consumer test files that import from here run unchanged under either vitest (via `bunx vitest`) or bun-test (via `bun test`).
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports the bun-test assertion + lifecycle API
|
||||
- Uses `bun:test` specifier — resolved at runtime by Bun, excluded from vitest builds
|
||||
- Conditional export — package.json's `exports` picks this module under the `bun` condition
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
describe("same test file, either runner", () => {
|
||||
it("asserts", () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
mock,
|
||||
} from "@suaveplan/testing-dom/runner";
|
||||
|
||||
describe("bun-test specific mocks", () => {
|
||||
let server: { close: () => void };
|
||||
beforeAll(() => {
|
||||
server = startHttpStub(3000);
|
||||
});
|
||||
afterAll(() => server.close());
|
||||
|
||||
it("mocks a module method", () => {
|
||||
const fetchStub = mock<typeof fetch>(() => Promise.resolve(new Response("ok")));
|
||||
// ... use fetchStub
|
||||
expect(fetchStub).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Re-exports: `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `mock`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module imports from `"bun:test"`, which resolves only in Bun; under vitest it's excluded from the bundle via vite's external-module configuration AND via the package's coverage-exclude list (since the file is never executed under vitest). Mocking APIs differ between the two runners: bun-test's `mock` returns a different shape than vitest's `vi.fn`. Consumers who need both runners' test files to type-check should stick to the lowest-common-denominator API — `describe`, `it`, `expect`, and lifecycle hooks — and branch on `detectRunner()` for anything beyond that.
|
||||
|
||||
### Integration
|
||||
|
||||
The Bun-test runner shim exposes `describe` / `it` / `beforeEach` / `afterEach` / `expect` bindings that mirror Vitest's API so test files can be written once and run in either runner. The shim installs the jest-dom matchers via `registerMatchers` on module load and sets up happy-dom as the default DOM host when the test file imports from `@suaveplan/testing-dom/runner/bun-test`.
|
||||
189
packages/testkit/testing/testing-dom/src/runner/bun-test.ts
Normal file
189
packages/testkit/testing/testing-dom/src/runner/bun-test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Bun-test re-exports for the `bun` runner condition.
|
||||
*
|
||||
* Bun's built-in test runner (`bun:test`) exposes a similar surface to
|
||||
* vitest. This shim re-exports from `bun:test` and provides the same
|
||||
* mock helpers (`fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`,
|
||||
* `restoreTimers`) backed by bun-test's primitives. `package.json`
|
||||
* conditional exports route consumers running under `bun test` to this
|
||||
* file instead of `./vitest.js`.
|
||||
*
|
||||
* Implementation note: `bun:test` is provided by the Bun runtime; types
|
||||
* are sourced from `@types/bun`. The conditional export only resolves
|
||||
* this file when Bun's loader is active, so the import never fires
|
||||
* outside a Bun process.
|
||||
*/
|
||||
|
||||
import {
|
||||
afterAll as bunAfterAll,
|
||||
afterEach as bunAfterEach,
|
||||
beforeAll as bunBeforeAll,
|
||||
beforeEach as bunBeforeEach,
|
||||
describe as bunDescribe,
|
||||
expect as bunExpect,
|
||||
it as bunIt,
|
||||
jest as bunJest,
|
||||
mock as bunMock,
|
||||
spyOn as bunSpyOn,
|
||||
test as bunTest,
|
||||
} from "bun:test";
|
||||
|
||||
export const afterAll = bunAfterAll;
|
||||
export const afterEach = bunAfterEach;
|
||||
export const beforeAll = bunBeforeAll;
|
||||
export const beforeEach = bunBeforeEach;
|
||||
export const describe = bunDescribe;
|
||||
export const expect = bunExpect;
|
||||
export const it = bunIt;
|
||||
export const test = bunTest;
|
||||
|
||||
/** `vi` alias backed by bun-test's `jest` namespace for API parity. */
|
||||
export const vi = bunJest;
|
||||
|
||||
/**
|
||||
* `expectTypeOf` shim for the bun-test path. Type assertions are erased
|
||||
* at compile time, so a Proxy-backed no-op chain satisfies the runtime
|
||||
* call regardless of which method consumers reach for.
|
||||
*/
|
||||
interface ExpectTypeOfChain {
|
||||
(...args: unknown[]): ExpectTypeOfChain;
|
||||
[key: string]: ExpectTypeOfChain;
|
||||
}
|
||||
|
||||
const noopChain = new Proxy((() => undefined) as unknown as ExpectTypeOfChain, {
|
||||
get(): unknown {
|
||||
return noopChain;
|
||||
},
|
||||
apply(): unknown {
|
||||
return noopChain;
|
||||
},
|
||||
});
|
||||
|
||||
export function expectTypeOf<T = unknown>(_value?: T): ExpectTypeOfChain {
|
||||
return noopChain;
|
||||
}
|
||||
|
||||
/** `inject` shim — see `@suaveplan/testing` runner for rationale. */
|
||||
export function inject<T = unknown>(_key: string): T | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runner-neutral mock-function type. Both the bun-test path (this file)
|
||||
* and the vitest path (`./mocks.ts`) export `fn<TArgs, TReturn>` returning
|
||||
* `RunnerMock<TArgs, TReturn>` so consumers can write
|
||||
* `const m = fn<[string], number>()` and have the call signature typed
|
||||
* under either runner.
|
||||
*/
|
||||
export type RunnerMock<TArgs extends unknown[], TReturn> = ((
|
||||
...args: TArgs
|
||||
) => TReturn) &
|
||||
ReturnType<typeof bunMock>;
|
||||
|
||||
/** Runner-neutral spy/stub return type. */
|
||||
export type SpyHandle = ReturnType<typeof bunSpyOn>;
|
||||
|
||||
export function fn<TArgs extends unknown[] = unknown[], TReturn = unknown>(
|
||||
implementation?: (...args: TArgs) => TReturn
|
||||
): RunnerMock<TArgs, TReturn> {
|
||||
const made = implementation
|
||||
? bunMock(implementation as never)
|
||||
: bunMock(() => undefined as never);
|
||||
return made as unknown as RunnerMock<TArgs, TReturn>;
|
||||
}
|
||||
|
||||
export function spy<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K
|
||||
): SpyHandle {
|
||||
return bunSpyOn(obj as never, method);
|
||||
}
|
||||
|
||||
export function stub<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K,
|
||||
implementation?: (...args: unknown[]) => unknown
|
||||
): SpyHandle {
|
||||
const spied = bunSpyOn(obj as never, method);
|
||||
const impl = implementation ?? (() => undefined);
|
||||
(
|
||||
spied as unknown as { mockImplementation(fn: unknown): void }
|
||||
).mockImplementation(impl);
|
||||
return spied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level mock. `bun:test`'s `mock.module()` accepts a runtime
|
||||
* module path. Returns a handle for symmetry with the vitest path; bun
|
||||
* does not expose a public `unmock` so `restore()` is a no-op.
|
||||
*/
|
||||
export function mock(
|
||||
modulePath: string,
|
||||
factory?: () => unknown
|
||||
): { restore(): void } {
|
||||
const mockModule = (
|
||||
bunMock as unknown as {
|
||||
module(path: string, factory?: () => unknown): void;
|
||||
}
|
||||
).module;
|
||||
if (typeof mockModule === "function") {
|
||||
mockModule.call(bunMock, modulePath, factory);
|
||||
}
|
||||
return {
|
||||
restore(): void {
|
||||
// bun:test does not expose unmock; restoreAllMocks() resets state.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install bun-test's fake-timer subsystem if available. Bun does not
|
||||
* yet ship a full fake-timer API parity with vitest; this is a thin
|
||||
* compatibility wrapper.
|
||||
*/
|
||||
export function fakeClock(): {
|
||||
advance(ms: number): void;
|
||||
restore(): void;
|
||||
} {
|
||||
const useFakeTimers = (bunJest as unknown as { useFakeTimers?: () => void })
|
||||
.useFakeTimers;
|
||||
if (typeof useFakeTimers === "function") {
|
||||
useFakeTimers.call(bunJest);
|
||||
}
|
||||
return {
|
||||
advance(ms: number): void {
|
||||
const advanceTimers = (
|
||||
bunJest as unknown as {
|
||||
advanceTimersByTime?: (n: number) => void;
|
||||
}
|
||||
).advanceTimersByTime;
|
||||
if (typeof advanceTimers === "function") {
|
||||
advanceTimers.call(bunJest, ms);
|
||||
}
|
||||
},
|
||||
restore(): void {
|
||||
const useReal = (
|
||||
bunJest as unknown as { useRealTimers?: () => void }
|
||||
).useRealTimers;
|
||||
if (typeof useReal === "function") {
|
||||
useReal.call(bunJest);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function restoreMocks(): void {
|
||||
const restoreAll = (bunJest as unknown as { restoreAllMocks?: () => void })
|
||||
.restoreAllMocks;
|
||||
if (typeof restoreAll === "function") {
|
||||
restoreAll.call(bunJest);
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreTimers(): void {
|
||||
const useReal = (bunJest as unknown as { useRealTimers?: () => void })
|
||||
.useRealTimers;
|
||||
if (typeof useReal === "function") {
|
||||
useReal.call(bunJest);
|
||||
}
|
||||
}
|
||||
28
packages/testkit/testing/testing-dom/src/runner/index.ts
Normal file
28
packages/testkit/testing/testing-dom/src/runner/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Runner barrel for the default vitest path.
|
||||
*
|
||||
* Conditional exports in `package.json` route the `bun` condition to
|
||||
* `./bun-test.ts` directly; the `default` and `vitest` conditions route
|
||||
* to `src/index.ts`, which uses this barrel. This file is therefore
|
||||
* always resolved against vitest semantics.
|
||||
*/
|
||||
export type { RunnerMock, SpyHandle } from "./vitest.js";
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
fakeClock,
|
||||
fn,
|
||||
it,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
test,
|
||||
vi,
|
||||
} from "./vitest.js";
|
||||
57
packages/testkit/testing/testing-dom/src/runner/mocks.md
Normal file
57
packages/testkit/testing/testing-dom/src/runner/mocks.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# runner/mocks
|
||||
|
||||
## Purpose
|
||||
|
||||
The `runner/mocks` module offers a small set of dependency-injection friendly mock helpers that work identically under vitest and bun-test. Because `vi.mock` / `mock.module` semantics differ sharply between the two runners (and often fail unpredictably in Bun's ESM resolver), this package prefers DI-style helpers — `makeFn`, `makeClass`, `makePromise` — that let a test substitute values at call-site rather than patching the module graph.
|
||||
|
||||
## Features
|
||||
|
||||
- Runner-agnostic helpers — no reliance on `vi.mock` / `mock.module`
|
||||
- `makeFn` returns a call-tracking function with `.calls`, `.mock`, and `.mockReturnValueOnce`
|
||||
- `makeClass` constructs a no-op subclass with overridable methods
|
||||
- `makePromise` returns `{ promise, resolve, reject }` for deferred resolution in tests
|
||||
- Works inside both runners without configuration
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { makeFn } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
const greet = makeFn<(name: string) => string>(() => "hi");
|
||||
greet("alice");
|
||||
expect(greet.calls).toEqual([["alice"]]);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { makeFn, makePromise } from "@suaveplan/testing-dom/runner";
|
||||
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
describe("flaky dependency via DI", () => {
|
||||
it("coordinates two async callers via a deferred", async () => {
|
||||
const deferred = makePromise<{ status: 200 }>();
|
||||
const fetchStub = makeFn<() => Promise<{ status: 200 }>>(
|
||||
() => deferred.promise
|
||||
);
|
||||
|
||||
const results = [fetchStub(), fetchStub()];
|
||||
deferred.resolve({ status: 200 });
|
||||
|
||||
expect(fetchStub.calls.length).toBe(2);
|
||||
expect((await Promise.all(results)).every((r) => r.status === 200)).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `makeFn<TFn>(impl?): TFn & { calls: Parameters<TFn>[]; mockReturnValueOnce(v: ReturnType<TFn>): void }`
|
||||
|
||||
### `makeClass<T>(overrides?): new () => T`
|
||||
|
||||
### `makePromise<T>(): { promise: Promise<T>; resolve: (v: T) => void; reject: (e: unknown) => void }`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
These helpers intentionally duplicate some of what `vi.fn` offers because the project standard (captured in the `bun-vitest-module-mocking` feedback memory) is to avoid module-level mocks entirely in favour of DI. Module mocks break under Bun's ESM resolver in unpredictable ways, and even under vitest they create implicit coupling between the test and the subject's internal imports. `makeFn` records call history but does not provide `toHaveBeenCalled` — consumers use `fn.calls.length` directly so the assertion works under any runner.
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fakeClock,
|
||||
fn,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
} from "./mocks.js";
|
||||
|
||||
describe("fn", () => {
|
||||
it("creates a mock that records calls", { timeout: 5000 }, () => {
|
||||
const f = fn();
|
||||
f(1, 2, 3);
|
||||
expect(f).toHaveBeenCalledWith(1, 2, 3);
|
||||
});
|
||||
|
||||
it("wraps a provided implementation", { timeout: 5000 }, () => {
|
||||
const f = fn((a: number, b: number) => a + b);
|
||||
expect(f(2, 3)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("spy", () => {
|
||||
it("records method calls without changing behavior", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { greet: (n: string) => `hello ${n}` };
|
||||
const handle = spy(obj, "greet");
|
||||
expect(obj.greet("world")).toBe("hello world");
|
||||
expect(handle).toHaveBeenCalledOnce();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("stub", () => {
|
||||
it("replaces the method with the supplied implementation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { value: () => 1 };
|
||||
const handle = stub(obj, "value", () => 99 as never);
|
||||
expect(obj.value()).toBe(99);
|
||||
handle.mockRestore();
|
||||
});
|
||||
|
||||
it("defaults to undefined when no implementation given", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { value: () => 1 };
|
||||
const handle = stub(obj, "value");
|
||||
expect(obj.value()).toBeUndefined();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fakeClock + restoreTimers", () => {
|
||||
it("advances vitest fake timers", { timeout: 5000 }, () => {
|
||||
const clock = fakeClock();
|
||||
let fired = false;
|
||||
setTimeout(() => {
|
||||
fired = true;
|
||||
}, 100);
|
||||
clock.advance(100);
|
||||
expect(fired).toBe(true);
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it("restoreTimers switches back to real timers", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
restoreTimers();
|
||||
// After restore, setTimeout uses real timers — call counts again.
|
||||
expect(vi.isFakeTimers()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreMocks", () => {
|
||||
it("restores all spied / stubbed methods", { timeout: 5000 }, () => {
|
||||
const obj = { v: () => 1 };
|
||||
stub(obj, "v", () => 99 as never);
|
||||
expect(obj.v()).toBe(99);
|
||||
restoreMocks();
|
||||
expect(obj.v()).toBe(1);
|
||||
});
|
||||
});
|
||||
113
packages/testkit/testing/testing-dom/src/runner/mocks.ts
Normal file
113
packages/testkit/testing/testing-dom/src/runner/mocks.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Runner-agnostic mock wrappers.
|
||||
*
|
||||
* On the vitest path these delegate to vitest's `vi`. On the bun-test
|
||||
* path the equivalents come from `bun:test` and live in `bun-test.ts`;
|
||||
* `package.json` conditional exports route consumers to the right one.
|
||||
*/
|
||||
|
||||
import { type Mock, vi } from "vitest";
|
||||
|
||||
/** Runner-neutral spy/stub return type. */
|
||||
export type SpyHandle = ReturnType<typeof vi.spyOn>;
|
||||
|
||||
/**
|
||||
* Runner-neutral mock-function type. Mirrors the alias on the bun-test
|
||||
* path (`./bun-test.ts`) so `const m = fn<[string], number>()` is typed
|
||||
* with the right call signature regardless of which runner is active.
|
||||
*/
|
||||
export type RunnerMock<TArgs extends unknown[], TReturn> = ((
|
||||
...args: TArgs
|
||||
) => TReturn) &
|
||||
Mock;
|
||||
|
||||
/** Wrap or create a vi mock function. */
|
||||
export function fn<TArgs extends unknown[] = unknown[], TReturn = unknown>(
|
||||
implementation?: (...args: TArgs) => TReturn
|
||||
): RunnerMock<TArgs, TReturn> {
|
||||
const made = implementation ? vi.fn(implementation) : vi.fn();
|
||||
return made as unknown as RunnerMock<TArgs, TReturn>;
|
||||
}
|
||||
|
||||
/** Spy on a method without changing its implementation. */
|
||||
export function spy<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K
|
||||
): SpyHandle {
|
||||
return vi.spyOn(obj as never, method);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub a method with an optional implementation. When `implementation`
|
||||
* is omitted the stub returns `undefined`.
|
||||
*/
|
||||
export function stub<T extends object, K extends keyof T & string>(
|
||||
obj: T,
|
||||
method: K,
|
||||
implementation?: (...args: unknown[]) => unknown
|
||||
): SpyHandle {
|
||||
const spied = vi.spyOn(obj as never, method);
|
||||
const impl = implementation ?? (() => undefined);
|
||||
(
|
||||
spied as unknown as { mockImplementation(fn: unknown): void }
|
||||
).mockImplementation(impl);
|
||||
return spied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-level mock. Vitest hoists static `vi.mock(...)` calls via a
|
||||
* compiler transform; bracket access (`vi["mock"]`) defeats the static
|
||||
* detection so the call uses the runtime `modulePath` argument as
|
||||
* intended.
|
||||
*/
|
||||
export function mock(
|
||||
modulePath: string,
|
||||
factory?: () => unknown
|
||||
): { restore(): void } {
|
||||
const dynamicMock = vi["mock"] as (
|
||||
path: string,
|
||||
factory?: () => unknown
|
||||
) => void;
|
||||
const dynamicUnmock = vi["unmock"] as (path: string) => void;
|
||||
if (factory) {
|
||||
dynamicMock(modulePath, factory);
|
||||
} else {
|
||||
dynamicMock(modulePath);
|
||||
}
|
||||
return {
|
||||
restore(): void {
|
||||
dynamicUnmock(modulePath);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Install vitest's fake-timer subsystem and return a small control
|
||||
* surface. Use {@link FakeClock} for deterministic clock injection in
|
||||
* production tests; this helper exists for legacy code paths that rely
|
||||
* on `vi.useFakeTimers`.
|
||||
*/
|
||||
export function fakeClock(): {
|
||||
advance(ms: number): void;
|
||||
restore(): void;
|
||||
} {
|
||||
vi.useFakeTimers();
|
||||
return {
|
||||
advance(ms: number): void {
|
||||
vi.advanceTimersByTime(ms);
|
||||
},
|
||||
restore(): void {
|
||||
vi.useRealTimers();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Restore every spy/stub/mock back to its original. */
|
||||
export function restoreMocks(): void {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
|
||||
/** Switch back to real timers. */
|
||||
export function restoreTimers(): void {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
100
packages/testkit/testing/testing-dom/src/runner/runner.md
Normal file
100
packages/testkit/testing/testing-dom/src/runner/runner.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Runner — vitest / bun-test conditional re-exports
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs --> - vitest — The `runner/vitest` module re-exports vite…
|
||||
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- vitest — The `runner/vitest` module re-exports vite…
|
||||
|
||||
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
|
||||
- vitest — The `runner/vitest` module re-exports vite…
|
||||
|
||||
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
|
||||
- vitest — The `runner/vitest` module re-exports vite…
|
||||
|
||||
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
- [vitest](./vitest.md) — The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll…
|
||||
|
||||
## Purpose
|
||||
|
||||
The runner barrel re-exports the test primitives (`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi`) plus a runner-neutral mock surface (`fn`, `spy`, `stub`, `mock`, `fakeClock`, `restoreMocks`, `restoreTimers`). Two backends are supported: vitest (the default) and bun's built-in `bun:test`. Which backend resolves at import time is driven by `package.json` conditional exports — under the `bun` condition Node resolves `./dist/runner/bun-test.js`; under `vitest` or `default` it resolves `./dist/index.js` which barrels in `vitest.ts`.
|
||||
|
||||
The mock helpers exist so test code can stay runner-agnostic. A package that uses `fn()` / `spy()` / `stub()` instead of importing `vi` directly will run unchanged when its consumers swap test runners — no test-source rewrites required. This is the pattern the original `2026-04-08-testing-libraries` change established and we preserve it here.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports of `vitest` primitives under the default / vitest conditions
|
||||
- `bun:test` re-exports under the bun condition with API-shaped equivalents (`vi` is aliased to bun's `jest` namespace)
|
||||
- Runner-neutral mock helpers: `fn`, `spy`, `stub`, `mock(modulePath)`, `fakeClock`, `restoreMocks`, `restoreTimers`
|
||||
- `mock(modulePath, factory?)` returns a `{ restore() }` handle for symmetry across runners
|
||||
- `fakeClock()` exists for legacy compatibility — new code should prefer the deterministic `FakeClock` class from `@suaveplan/testing/clock/fake-clock`
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic example — runner-neutral test
|
||||
|
||||
```ts
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
fn,
|
||||
spy,
|
||||
} from "@suaveplan/testing";
|
||||
|
||||
describe("user service", () => {
|
||||
it("calls the dependency exactly once", { timeout: 5000 }, () => {
|
||||
const dep = { fetch: () => "real" };
|
||||
const handle = spy(dep, "fetch");
|
||||
const callback = fn();
|
||||
callback("hello");
|
||||
expect(callback).toHaveBeenCalledWith("hello");
|
||||
expect(handle).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Advanced example — module mocking with restore
|
||||
|
||||
```ts
|
||||
import { mock, restoreMocks } from "@suaveplan/testing";
|
||||
|
||||
const handle = mock("./network.js", () => ({
|
||||
fetchUser: () => ({ id: 42 }),
|
||||
}));
|
||||
|
||||
// run tests against the mocked module ...
|
||||
|
||||
handle.restore();
|
||||
restoreMocks();
|
||||
```
|
||||
|
||||
### Switching to bun-test
|
||||
|
||||
In a consumer's `package.json`, set the `bun` condition (or run via `bun test`). Resolution picks `@suaveplan/testing/dist/runner/bun-test.js` automatically; no test-source changes are needed.
|
||||
|
||||
## API Reference
|
||||
|
||||
### Test primitives
|
||||
|
||||
`describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi` — re-exported from the active runner.
|
||||
|
||||
### Mock helpers
|
||||
|
||||
- `fn<TArgs, TReturn>(implementation?): Mock`
|
||||
- `spy<T, K>(obj: T, method: K): SpyHandle`
|
||||
- `stub<T, K>(obj: T, method: K, implementation?): SpyHandle`
|
||||
- `mock(modulePath: string, factory?: () => unknown): { restore() }`
|
||||
- `fakeClock(): { advance(ms), restore() }`
|
||||
- `restoreMocks(): void`
|
||||
- `restoreTimers(): void`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- **`vi` on the bun path is aliased to `jest`.** Bun-test's mock surface lives under `jest` for API compatibility; the alias keeps consumer code identical across runners.
|
||||
- **`fakeClock` is legacy.** Prefer the deterministic `FakeClock` class from the clock module — it's runner-independent, supports `Clock` injection across the package, and gives you `snapshot()` / `restore()` for fine control.
|
||||
- **`mock()` on bun has a no-op `restore()`** because `bun:test` does not expose `unmock`. Call `restoreMocks()` (which delegates to `jest.restoreAllMocks()`) to reset state.
|
||||
- **The conditional-exports routing** is load-bearing — see `package.json#exports.".".bun` and `.vitest`. Removing either branch breaks the dual-runner contract documented in the spec.
|
||||
- **Type safety on cross-runner mocks** is approximate. The wrappers use `as never` casts internally because the spy/stub generics differ between the two libraries; consumer code sees a stable shape.
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Type-level spec-conformance pins for the runner adapter.
|
||||
*
|
||||
* Pins:
|
||||
* - `fn<TArgs, TReturn>()` returns a callable typed `(...args: TArgs) => TReturn`
|
||||
* under the vitest path AND (by symmetry) the bun-test path. The bun-test
|
||||
* path can't be exercised under vitest, so we type-check its module
|
||||
* surface via `import type`.
|
||||
* - `RunnerMock` and `SpyHandle` are exported from the package barrel.
|
||||
*/
|
||||
|
||||
import { describe, expectTypeOf, it } from "vitest";
|
||||
import type {
|
||||
RunnerMock as PkgRunnerMock,
|
||||
SpyHandle as PkgSpyHandle,
|
||||
} from "../index.js";
|
||||
import type {
|
||||
RunnerMock as BunRunnerMock,
|
||||
SpyHandle as BunSpyHandle,
|
||||
} from "./bun-test.js";
|
||||
import type {
|
||||
RunnerMock as ViteRunnerMock,
|
||||
SpyHandle as ViteSpyHandle,
|
||||
} from "./mocks.js";
|
||||
import { fn, spy, stub } from "./mocks.js";
|
||||
|
||||
describe("runner generics — vitest path", () => {
|
||||
it("fn<[string], number>() preserves call-signature typing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = fn<[string], number>();
|
||||
expectTypeOf(m).toBeCallableWith("hello");
|
||||
expectTypeOf<ReturnType<typeof m>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("fn<[number, number], number>(impl) preserves call-signature typing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const m = fn<[number, number], number>((a, b) => a + b);
|
||||
expectTypeOf(m).toBeCallableWith(1, 2);
|
||||
expectTypeOf<ReturnType<typeof m>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("spy returns a SpyHandle assignable to the package's exported type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { greet: (n: string) => `hi ${n}` };
|
||||
const handle = spy(obj, "greet");
|
||||
expectTypeOf(handle).toMatchTypeOf<ViteSpyHandle>();
|
||||
expectTypeOf(handle).toMatchTypeOf<PkgSpyHandle>();
|
||||
handle.mockRestore();
|
||||
});
|
||||
|
||||
it("stub returns a SpyHandle assignable to the package's exported type", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const obj = { v: () => 1 };
|
||||
const handle = stub(obj, "v", () => 99 as never);
|
||||
expectTypeOf(handle).toMatchTypeOf<ViteSpyHandle>();
|
||||
handle.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runner generics — bun-test path symmetry", () => {
|
||||
it("BunRunnerMock<TArgs, TReturn> shape mirrors the vitest path", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Both aliases parameterize on TArgs/TReturn identically; we
|
||||
// assert each carries the runtime call signature.
|
||||
type ViteCall = ViteRunnerMock<[string], number>;
|
||||
type BunCall = BunRunnerMock<[string], number>;
|
||||
expectTypeOf<ViteCall>().toBeCallableWith("hi");
|
||||
expectTypeOf<BunCall>().toBeCallableWith("hi");
|
||||
expectTypeOf<ReturnType<ViteCall>>().toEqualTypeOf<number>();
|
||||
expectTypeOf<ReturnType<BunCall>>().toEqualTypeOf<number>();
|
||||
});
|
||||
|
||||
it("BunSpyHandle is exported and PkgRunnerMock matches both paths", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
type _BunSpy = BunSpyHandle;
|
||||
type Pkg = PkgRunnerMock<[number], string>;
|
||||
expectTypeOf<Pkg>().toBeCallableWith(1);
|
||||
expectTypeOf<ReturnType<Pkg>>().toEqualTypeOf<string>();
|
||||
});
|
||||
});
|
||||
71
packages/testkit/testing/testing-dom/src/runner/vitest.md
Normal file
71
packages/testkit/testing/testing-dom/src/runner/vitest.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# runner/vitest
|
||||
|
||||
## Purpose
|
||||
|
||||
The `runner/vitest` module re-exports vitest's `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, and `vi` under a stable alias. It's part of the dual-runner support in `@suaveplan/testing-dom` — consumers who want their test files to run under both vitest and bun-test can import from `@suaveplan/testing-dom/runner` instead of picking one directly. At type-check time this module statically resolves to vitest's types.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-exports the vitest assertion + lifecycle API
|
||||
- Preserves upstream types — TS jumps to vitest's definitions
|
||||
- Conditional export — package.json's `exports` picks this module under the `vitest` condition
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
describe("my suite", () => {
|
||||
it("asserts", { timeout: 5000 }, () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach, vi } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
describe("mockable dependency", () => {
|
||||
let clock = 0;
|
||||
beforeEach(() => {
|
||||
clock = 0;
|
||||
});
|
||||
|
||||
it("uses fake timers", { timeout: 5000 }, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const inner = vi.fn((n: number) => ++clock);
|
||||
inner(1);
|
||||
inner(1);
|
||||
expect(inner).toHaveBeenCalledTimes(2);
|
||||
expect(clock).toBe(2);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
Re-exports: `describe`, `it`, `test`, `expect`, `beforeAll`, `beforeEach`, `afterAll`, `afterEach`, `vi`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The re-export is unconditional because the module is only reachable via the `vitest` condition in `package.json#exports`. If the condition machinery is bypassed (e.g. the import is resolved manually), vitest must still be available as a peer of the consumer — this module does not install vitest or provide its own assertion library.
|
||||
|
||||
### Integration
|
||||
|
||||
The Vitest runner shim re-exports `describe` / `it` / `beforeEach` / `afterEach` / `expect` from `vitest` and installs the jest-dom matcher set via `registerMatchers` on module load. Importing this module is sufficient for a Vitest file to use the full DOM-matcher surface — no manual matcher wiring needed.
|
||||
|
||||
## Further Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { it, expect, render } from "@suaveplan/testing-dom/runner/vitest";
|
||||
it("renders hello", { timeout: 5000 }, () => {
|
||||
const { getByText } = render(<h1>Hello</h1>);
|
||||
expect(getByText("Hello")).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
32
packages/testkit/testing/testing-dom/src/runner/vitest.ts
Normal file
32
packages/testkit/testing/testing-dom/src/runner/vitest.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Vitest re-exports for the default runner condition.
|
||||
*
|
||||
* `package.json` conditional exports route the bare `@suaveplan/testing`
|
||||
* import to this file under the `default` and `vitest` conditions. The
|
||||
* bun-test path resolves to `./bun-test.ts` instead.
|
||||
*/
|
||||
|
||||
export {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
expectTypeOf,
|
||||
inject,
|
||||
it,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
|
||||
export type { RunnerMock, SpyHandle } from "./mocks.js";
|
||||
export {
|
||||
fakeClock,
|
||||
fn,
|
||||
mock,
|
||||
restoreMocks,
|
||||
restoreTimers,
|
||||
spy,
|
||||
stub,
|
||||
} from "./mocks.js";
|
||||
4
packages/testkit/testing/testing-dom/src/setup/index.ts
Normal file
4
packages/testkit/testing/testing-dom/src/setup/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Side-effect-only re-export: `./polyfills.ts` installs `globalThis.crypto`
|
||||
// and Text encoder/decoder shims for jsdom/happy-dom on import. No named
|
||||
// exports; the bare side-effect import is the entire contract.
|
||||
import "./polyfills";
|
||||
61
packages/testkit/testing/testing-dom/src/setup/polyfills.md
Normal file
61
packages/testkit/testing/testing-dom/src/setup/polyfills.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# setup/polyfills
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `setup/polyfills` module is a side-effect import that installs the globals test code expects to be present — `crypto`, `TextEncoder`, `TextDecoder` — when the host runtime (bare Node, happy-dom, jsdom, or Bun) doesn't already provide them. It's idempotent and safe to import multiple times.
|
||||
|
||||
## Features
|
||||
|
||||
- Installs `globalThis.crypto` from Node's `webcrypto` when missing
|
||||
- Installs `TextEncoder` / `TextDecoder` from `node:util` when missing
|
||||
- Guards every assignment with an existence check — never overwrites a populated global
|
||||
- Exports nothing — side effects only
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
// vitest.config.ts
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { createVitestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
export default defineConfig(
|
||||
createVitestConfig(__dirname, {
|
||||
setupFiles: ["@suaveplan/testing-dom/polyfills"],
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
// bunfig.toml (bun-test)
|
||||
// [test]
|
||||
// preload = ["@suaveplan/testing-dom/polyfills", "./tests/install-matchers.ts"]
|
||||
|
||||
// Or programmatically via createBunTestConfig:
|
||||
import { createBunTestConfig } from "@suaveplan/testing-dom/config";
|
||||
|
||||
const cfg = createBunTestConfig(__dirname, {
|
||||
preload: ["./tests/install-matchers.ts"],
|
||||
// createBunTestConfig adds the polyfills entry automatically.
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
No exports. Side effects:
|
||||
|
||||
- `globalThis.crypto ??= webcrypto` _(from `node:crypto`)_
|
||||
- `globalThis.TextEncoder ??= NodeTextEncoder`
|
||||
- `globalThis.TextDecoder ??= NodeTextDecoder`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Modern Node (≥19) and Bun both ship `crypto` globally, so the `??=` short-circuits on those hosts. The module remains in the package for older Node versions and for happy-dom sandboxes that don't initialise `crypto`. Imports use `node:` specifiers so the code remains bundler-safe — Rollup/esbuild skip it when externalising node built-ins. Because the module exports nothing, consumers who mistakenly try `import { x } from "@suaveplan/testing-dom/polyfills"` get a useful TypeScript error rather than silent misbehaviour.
|
||||
|
||||
### Coverage
|
||||
|
||||
The polyfill bundle installs happy-dom's `DOMParser`, `XMLSerializer`, `fetch`, `Request`, `Response`, `Headers`, `URL`, `URLSearchParams`, and `crypto.getRandomValues` into the Node / Bun global scope when they are missing. Double-install is idempotent — the module tracks a `Symbol.for("suaveplan.testing-dom.polyfilled")` marker on `globalThis` so repeated imports are cheap.
|
||||
50
packages/testkit/testing/testing-dom/src/setup/polyfills.ts
Normal file
50
packages/testkit/testing/testing-dom/src/setup/polyfills.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
|
||||
* environments on WSL2 or older toolchains.
|
||||
*
|
||||
* Import via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* // vitest.config.ts
|
||||
* export default defineConfig({
|
||||
* test: {
|
||||
* setupFiles: ["@suaveplan/testing-dom/polyfills"],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Installs:
|
||||
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
|
||||
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
|
||||
*
|
||||
* Exports nothing. Safe to import multiple times; operations are idempotent.
|
||||
*/
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
TextDecoder as NodeTextDecoder,
|
||||
TextEncoder as NodeTextEncoder,
|
||||
} from "node:util";
|
||||
|
||||
type WritableGlobal = {
|
||||
crypto?: Crypto;
|
||||
TextEncoder?: typeof NodeTextEncoder;
|
||||
TextDecoder?: typeof NodeTextDecoder;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as WritableGlobal;
|
||||
|
||||
if (
|
||||
typeof g.crypto === "undefined" ||
|
||||
typeof g.crypto.getRandomValues !== "function"
|
||||
) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
if (typeof g.TextEncoder === "undefined") {
|
||||
g.TextEncoder = NodeTextEncoder;
|
||||
}
|
||||
|
||||
if (typeof g.TextDecoder === "undefined") {
|
||||
g.TextDecoder = NodeTextDecoder;
|
||||
}
|
||||
1
packages/testkit/testing/testing-dom/src/user/index.ts
Normal file
1
packages/testkit/testing/testing-dom/src/user/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type SetupUserOptions, setupUser } from "./setup-user";
|
||||
63
packages/testkit/testing/testing-dom/src/user/setup-user.md
Normal file
63
packages/testkit/testing/testing-dom/src/user/setup-user.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# setup-user
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`setupUser` returns a configured `@testing-library/user-event` v14 instance with project-wide defaults appropriate for happy-dom. The wrapper exists so every test that simulates user input gets the same `delay`, `pointerEventsCheck`, and timer handling — consumers don't have to remember the boilerplate and upgrades of `user-event` touch a single file.
|
||||
|
||||
## Features
|
||||
|
||||
- Defaults tuned for happy-dom (no `delay`, no pointer-event checks — happy-dom treats every node as clickable)
|
||||
- Forwards caller overrides so individual tests can opt-in to stricter pointer checks
|
||||
- Respects vitest's fake-timers — temporarily swaps them for real timers while user-event internals await
|
||||
- Returns the fully-typed `UserEvent` instance from `@testing-library/user-event`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```tsx
|
||||
import { renderComponent, setupUser, screen } from "@suaveplan/testing-dom";
|
||||
|
||||
test("typing", async () => {
|
||||
renderComponent(<input aria-label="name" />);
|
||||
const user = setupUser();
|
||||
await user.type(screen.getByLabelText("name"), "alice");
|
||||
expect(screen.getByLabelText("name")).toHaveValue("alice");
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```tsx
|
||||
import { renderComponent, setupUser, screen } from "@suaveplan/testing-dom";
|
||||
import { vi } from "@suaveplan/testing-dom/runner";
|
||||
|
||||
test("debounced save uses fake timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
renderComponent(<DebouncedForm />);
|
||||
const user = setupUser({ advanceTimers: vi.advanceTimersByTime });
|
||||
await user.type(screen.getByLabelText("title"), "new title");
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(screen.getByText("saved")).toBeInTheDocument();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `setupUser(options?): UserEvent`
|
||||
|
||||
- `options` — passed through to upstream `userEvent.setup(options)`
|
||||
- Returns a `UserEvent` instance (upstream type preserved)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapper supplies `{ delay: null, pointerEventsCheck: 0 }` as defaults because happy-dom's layout engine is minimal — simulating realistic pointer-event checks wastes test time without surfacing additional bugs. Tests that need the full browser-like semantics should run under Playwright (the `testing-e2e` package) rather than trying to coerce happy-dom into matching. The fake-timers integration uses try/finally around `vi.useFakeTimers()` so a failed test leaves the runtime in a known-good real-timer state for subsequent tests.
|
||||
|
||||
### User Events
|
||||
|
||||
`setupUser()` wraps `@testing-library/user-event`'s `userEvent.setup()` with sensible Suaveplan defaults: `pointerEventsCheck: 0` (off, because happy-dom doesn't model pointer-events reliably), `delay: null` for deterministic tests, and automatic advance of Vitest fake timers when they're active. The returned object has the full `click` / `type` / `hover` / `keyboard` / `upload` API surface.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from "../runner/index.js";
|
||||
import { setupUser } from "./setup-user.js";
|
||||
|
||||
describe("setupUser", () => {
|
||||
it("returns a UserEvent instance", { timeout: 5000 }, () => {
|
||||
const user = setupUser();
|
||||
expect(user).toBeDefined();
|
||||
expect(typeof user.click).toBe("function");
|
||||
});
|
||||
|
||||
it("wires advanceTimers when fake timers are active", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const user = setupUser();
|
||||
expect(user).toBeDefined();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("respects explicit options override", { timeout: 5000 }, () => {
|
||||
const user = setupUser({ delay: null, pointerEventsCheck: 1 });
|
||||
expect(user).toBeDefined();
|
||||
});
|
||||
|
||||
it("imports vi via the runner adapter (no direct vitest hard-import)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const path = await import("node:path");
|
||||
// Resolve via cwd (vitest runs from package root) — happy-dom's
|
||||
// `import.meta.url` uses a non-file scheme, which `node:fs`
|
||||
// refuses, so we cannot use `new URL(..., import.meta.url)` here.
|
||||
const source = await readFile(
|
||||
path.resolve(process.cwd(), "src/user/setup-user.ts"),
|
||||
"utf8"
|
||||
);
|
||||
// Adapter routing: the file MUST resolve `vi` through the
|
||||
// runner adapter (`../runner/index.js`) so the bun-test
|
||||
// conditional path doesn't pull a vitest-only import.
|
||||
expect(source).not.toMatch(/from\s+["']vitest["']/);
|
||||
expect(source).toMatch(/from\s+["']\.\.\/runner\/index\.js["']/);
|
||||
});
|
||||
});
|
||||
32
packages/testkit/testing/testing-dom/src/user/setup-user.ts
Normal file
32
packages/testkit/testing/testing-dom/src/user/setup-user.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* `setupUser(options?)` — produce a `@testing-library/user-event` v14
|
||||
* instance with happy-dom-friendly defaults. When vitest fake timers are
|
||||
* active, the instance is wired with `advanceTimers` automatically.
|
||||
*/
|
||||
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
// Route through the runner adapter rather than hard-importing `vitest`
|
||||
// so the bun-test conditional path resolves correctly. The adapter
|
||||
// re-exports `vi` (vitest path) or the bun-test `jest` namespace alias.
|
||||
import { vi } from "../runner/index.js";
|
||||
|
||||
/** Options passed through to user-event's `setup()`. */
|
||||
export interface SetupUserOptions {
|
||||
readonly delay?: number | null;
|
||||
readonly pointerEventsCheck?: number;
|
||||
readonly skipHover?: boolean;
|
||||
}
|
||||
|
||||
export function setupUser(options: SetupUserOptions = {}): UserEvent {
|
||||
const baseOptions = {
|
||||
pointerEventsCheck: 0,
|
||||
...options,
|
||||
};
|
||||
if (vi.isFakeTimers()) {
|
||||
return userEvent.setup({
|
||||
...baseOptions,
|
||||
advanceTimers: vi.advanceTimersByTime.bind(vi),
|
||||
});
|
||||
}
|
||||
return userEvent.setup(baseOptions);
|
||||
}
|
||||
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
60
packages/testkit/testing/testing-dom/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Dep-graph guard — runtime closure of @suaveplan/testing-dom must
|
||||
* contain zero `@suaveplan/*` packages. devDeps may include
|
||||
* @suaveplan/testing for our own tests.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
describe("dep-graph guard", () => {
|
||||
it("no @suaveplan/* in dependencies or peerDependencies", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const sections: Array<
|
||||
[
|
||||
"dependencies" | "peerDependencies",
|
||||
Record<string, string> | undefined,
|
||||
]
|
||||
> = [
|
||||
["dependencies", pkg.dependencies],
|
||||
["peerDependencies", pkg.peerDependencies],
|
||||
];
|
||||
for (const [name, deps] of sections) {
|
||||
if (!deps) continue;
|
||||
const offending = Object.keys(deps).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(
|
||||
offending,
|
||||
`${name} contains @suaveplan/* deps: ${offending.join(", ")}`
|
||||
).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("devDependencies may contain @suaveplan/testing only (utilities for our own tests)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(await readFile(pkgPath, "utf8")) as {
|
||||
devDependencies?: Record<string, string>;
|
||||
};
|
||||
const ours = Object.keys(pkg.devDependencies ?? {}).filter((k) =>
|
||||
k.startsWith("@suaveplan/")
|
||||
);
|
||||
const allowed = new Set(["@suaveplan/testing"]);
|
||||
for (const dep of ours) {
|
||||
expect(
|
||||
allowed.has(dep),
|
||||
`unexpected @suaveplan/* devDep: ${dep}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Spec-conformance: pins the dual-runner conditional-exports shape
|
||||
* declared in `package.json`. A regression that flips the `bun`
|
||||
* condition to a vitest-only barrel (or vice versa) would silently
|
||||
* break consumers running under `bun test`; this test catches it at
|
||||
* the JSON-shape level rather than at integration time.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkgPath = resolve(here, "..", "package.json");
|
||||
|
||||
interface ExportTarget {
|
||||
readonly types?: string;
|
||||
readonly default?: string;
|
||||
}
|
||||
|
||||
interface ExportsMap {
|
||||
readonly "."?: {
|
||||
readonly vitest?: ExportTarget;
|
||||
readonly bun?: ExportTarget;
|
||||
readonly default?: ExportTarget;
|
||||
};
|
||||
readonly "./polyfills"?: string | ExportTarget;
|
||||
readonly "./matchers"?: string | ExportTarget;
|
||||
readonly "./config"?: string | ExportTarget;
|
||||
readonly "./errors"?: string | ExportTarget;
|
||||
readonly "./render"?: string | ExportTarget;
|
||||
readonly "./queries"?: string | ExportTarget;
|
||||
readonly "./user"?: string | ExportTarget;
|
||||
readonly "./package.json"?: string;
|
||||
}
|
||||
|
||||
interface MinimalPkg {
|
||||
exports?: ExportsMap;
|
||||
}
|
||||
|
||||
async function loadPkg(): Promise<MinimalPkg> {
|
||||
return JSON.parse(await readFile(pkgPath, "utf8")) as MinimalPkg;
|
||||
}
|
||||
|
||||
describe("dual-runner conditional exports", () => {
|
||||
it("routes vitest + default to the index barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root, "exports['.'] missing").toBeDefined();
|
||||
expect(root?.vitest?.default).toBe("./dist/index.js");
|
||||
expect(root?.default?.default).toBe("./dist/index.js");
|
||||
});
|
||||
|
||||
it("routes the bun condition to the bun-test barrel", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.default).toBe("./dist/runner/bun-test.js");
|
||||
});
|
||||
|
||||
it("vitest and default conditions share the same types entry", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.vitest?.types).toBe("./dist/index.d.ts");
|
||||
expect(root?.default?.types).toBe("./dist/index.d.ts");
|
||||
});
|
||||
|
||||
it("bun condition has its own types entry", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const root = pkg.exports?.["."];
|
||||
expect(root?.bun?.types).toBe("./dist/runner/bun-test.d.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpath exports presence", () => {
|
||||
const REQUIRED_SUBPATHS = [
|
||||
"./polyfills",
|
||||
"./matchers",
|
||||
"./config",
|
||||
"./errors",
|
||||
"./render",
|
||||
"./queries",
|
||||
"./user",
|
||||
] as const;
|
||||
|
||||
it("declares every required subpath", { timeout: 5000 }, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
for (const sub of REQUIRED_SUBPATHS) {
|
||||
expect(
|
||||
Object.hasOwn(exportsMap, sub),
|
||||
`package.json#exports is missing "${sub}"`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("polyfills + matchers resolve to side-effecting dist files", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = await loadPkg();
|
||||
const exportsMap = pkg.exports ?? {};
|
||||
const polyfills = exportsMap["./polyfills"];
|
||||
const matchers = exportsMap["./matchers"];
|
||||
expect(
|
||||
typeof polyfills === "string" ? polyfills : polyfills?.default
|
||||
).toBe("./dist/setup/polyfills.js");
|
||||
expect(
|
||||
typeof matchers === "string" ? matchers : matchers?.default
|
||||
).toBe("./dist/matchers/matchers.js");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Grep guard — fail if `src/` contains any disallowed construct:
|
||||
* `any`, `ts-ignore`, `ts-expect-error`, `TODO`, `FIXME`, `@deprecated`,
|
||||
* `.skip(`, `.only(`. Test fixtures and benchmarks are excluded.
|
||||
*/
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const srcRoot = resolve(here, "..", "src");
|
||||
|
||||
const PATTERNS: Array<{ name: string; regex: RegExp }> = [
|
||||
{ name: "any", regex: /(?<![a-zA-Z0-9_])any(?![a-zA-Z0-9_])/ },
|
||||
{ name: "ts-ignore", regex: /@ts-ignore/ },
|
||||
{ name: "ts-expect-error", regex: /@ts-expect-error/ },
|
||||
{ name: "TODO", regex: /\bTODO\b/ },
|
||||
{ name: "FIXME", regex: /\bFIXME\b/ },
|
||||
{ name: "@deprecated", regex: /@deprecated/ },
|
||||
{ name: ".skip(", regex: /\.skip\s*\(/ },
|
||||
{ name: ".only(", regex: /\.only\s*\(/ },
|
||||
];
|
||||
|
||||
const EXCLUDED_FILE_SUFFIXES = [".test.ts", ".test.tsx", ".bench.ts"];
|
||||
|
||||
const EXCLUDED_FROM_ANY_CHECK: readonly string[] = [
|
||||
// Mock helpers necessarily reach into untyped runner internals.
|
||||
"runner/mocks.ts",
|
||||
"runner/bun-test.ts",
|
||||
];
|
||||
|
||||
async function walk(dir: string, out: string[]): Promise<void> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(full, out);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(entry.name.endsWith(".ts") || entry.name.endsWith(".tsx"))
|
||||
) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed source guard", () => {
|
||||
it("src/ contains no disallowed constructs", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
const all: string[] = [];
|
||||
await walk(srcRoot, all);
|
||||
const files = all.filter((file) => {
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
return !EXCLUDED_FILE_SUFFIXES.some((s) => rel.endsWith(s));
|
||||
});
|
||||
const violations: string[] = [];
|
||||
for (const file of files) {
|
||||
const text = await readFile(file, "utf8");
|
||||
const lines = text.split("\n");
|
||||
const rel = relative(srcRoot, file).replace(/\\/g, "/");
|
||||
for (const { name, regex } of PATTERNS) {
|
||||
if (name === "any" && EXCLUDED_FROM_ANY_CHECK.includes(rel)) {
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i] ?? "";
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
trimmed.startsWith("//") ||
|
||||
trimmed.startsWith("*") ||
|
||||
trimmed.startsWith("/*")
|
||||
)
|
||||
continue;
|
||||
if (regex.test(line)) {
|
||||
violations.push(
|
||||
`${rel}:${i + 1} contains "${name}": ${trimmed}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Side-effecting polyfills module for happy-dom / jsdom / bare-Node test
|
||||
* environments on WSL2 or older toolchains.
|
||||
*
|
||||
* Import via vitest `setupFiles`:
|
||||
*
|
||||
* ```ts
|
||||
* // vitest.config.ts
|
||||
* export default defineConfig({
|
||||
* test: {
|
||||
* setupFiles: ["@suaveplan/testing/polyfills"],
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Installs:
|
||||
* - `globalThis.crypto` (from `node:crypto`'s `webcrypto` export) if missing
|
||||
* - `globalThis.TextEncoder` / `TextDecoder` (usually present, covered defensively)
|
||||
*
|
||||
* Exports nothing. Safe to import multiple times; operations are idempotent.
|
||||
*/
|
||||
|
||||
import { webcrypto } from "node:crypto";
|
||||
import {
|
||||
TextDecoder as NodeTextDecoder,
|
||||
TextEncoder as NodeTextEncoder,
|
||||
} from "node:util";
|
||||
|
||||
type WritableGlobal = {
|
||||
crypto?: Crypto;
|
||||
TextEncoder?: typeof NodeTextEncoder;
|
||||
TextDecoder?: typeof NodeTextDecoder;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as WritableGlobal;
|
||||
|
||||
if (
|
||||
typeof g.crypto === "undefined" ||
|
||||
typeof g.crypto.getRandomValues !== "function"
|
||||
) {
|
||||
g.crypto = webcrypto as unknown as Crypto;
|
||||
}
|
||||
|
||||
if (typeof g.TextEncoder === "undefined") {
|
||||
g.TextEncoder = NodeTextEncoder;
|
||||
}
|
||||
|
||||
if (typeof g.TextDecoder === "undefined") {
|
||||
g.TextDecoder = NodeTextDecoder;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tree-shake guard — verify subpaths don't drag unrelated modules.
|
||||
*/
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "../src/runner/index.js";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const distRoot = resolve(here, "..", "dist");
|
||||
|
||||
async function distExists(): Promise<boolean> {
|
||||
try {
|
||||
await readFile(resolve(distRoot, "index.js"), "utf8");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readDist(rel: string): Promise<string> {
|
||||
return readFile(resolve(distRoot, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("tree-shake guards", () => {
|
||||
it("matchers subpath does not pull renderComponent", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("matchers/matchers.js");
|
||||
expect(text).not.toMatch(/function\s+renderComponent\b/);
|
||||
expect(text).not.toMatch(/class\s+RenderError\b/);
|
||||
});
|
||||
|
||||
it("user/setup-user does not pull jest-dom matchers", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("user/setup-user.js");
|
||||
expect(text).not.toMatch(/toBeInTheDocument/);
|
||||
expect(text).not.toMatch(/jest-dom/);
|
||||
});
|
||||
|
||||
it("polyfills subpath has no functional surface", {
|
||||
timeout: 10000,
|
||||
}, async () => {
|
||||
if (!(await distExists())) return;
|
||||
const text = await readDist("setup/polyfills.js");
|
||||
expect(text).not.toMatch(/export\s+(?:const|function|class)/);
|
||||
});
|
||||
});
|
||||
19
packages/testkit/testing/testing-dom/tsconfig.lib.json
Normal file
19
packages/testkit/testing/testing-dom/tsconfig.lib.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node", "bun"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
71
packages/testkit/testing/testing-dom/vite.config.ts
Normal file
71
packages/testkit/testing/testing-dom/vite.config.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/testing-dom",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Multi-entry so every subpath export in package.json#exports
|
||||
// maps to a real emitted file. Modules not imported from the
|
||||
// root barrel (polyfills, matchers, config, runner variants)
|
||||
// are listed explicitly so Rollup keeps them.
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"setup/polyfills": "src/setup/polyfills.ts",
|
||||
"matchers/matchers": "src/matchers/matchers.ts",
|
||||
"config/index": "src/config/index.ts",
|
||||
"runner/vitest": "src/runner/vitest.ts",
|
||||
"runner/bun-test": "src/runner/bun-test.ts",
|
||||
"errors/index": "src/errors/index.ts",
|
||||
"render/index": "src/render/index.ts",
|
||||
"queries/queries": "src/queries/queries.ts",
|
||||
"user/setup-user": "src/user/setup-user.ts",
|
||||
},
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"@opentelemetry/api",
|
||||
"@testing-library/dom",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
"@testing-library/user-event",
|
||||
"@vitejs/plugin-react",
|
||||
"bun:test",
|
||||
"dom-accessibility-api",
|
||||
"happy-dom",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"node:fs",
|
||||
"node:path",
|
||||
"node:url",
|
||||
"node:util",
|
||||
"node:crypto",
|
||||
"tslib",
|
||||
"vitest",
|
||||
"vitest/config",
|
||||
"zod",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
59
packages/testkit/testing/testing-dom/vitest.config.ts
Normal file
59
packages/testkit/testing/testing-dom/vitest.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Inline config — testing-dom defines createTestConfig but cannot
|
||||
// self-reference it. Polyfills file is internal to this package's own
|
||||
// test run; the production polyfills export is from /dist/setup/polyfills.
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "happy-dom",
|
||||
pool: "forks",
|
||||
fileParallelism: false,
|
||||
setupFiles: [
|
||||
"./tests/setup/polyfills.ts",
|
||||
"./src/matchers/matchers.ts",
|
||||
],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"tests/**/*.test.ts",
|
||||
"tests/**/*.test.tsx",
|
||||
],
|
||||
exclude: [
|
||||
"src/__benchmarks__/**",
|
||||
"benchmarks/**",
|
||||
"e2e/**",
|
||||
"dist/**",
|
||||
"node_modules/**",
|
||||
],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts", "src/**/*.tsx"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"src/runner/bun-test.ts",
|
||||
"src/runner/vitest.ts",
|
||||
"src/runner/mocks.ts",
|
||||
"src/setup/polyfills.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user