@ctrl/dom

中文文档
0.2.0发布于 2026/11/10MITTypeScriptESM + CJS构建来源已验证近 30 天下载 3209

A fast DOM for testing React components: a drop-in replacement for jsdom and happy-dom, with Testing Library, user-event and jest-dom built in.

原始使用文档
文档目录16 节
  1. Vitest
  2. Jest
  3. Other test runners
  4. node:test
  5. Bun
  6. Configuration
  7. Storybook
  8. Time skipping
  9. Direct imports
  10. Options
  11. Shared workers
  12. Compat profiles
  13. Debugging with coding agents
  14. Sentry
  15. Differences from jsdom
  16. License

@ctrl/dom

A DOM for testing React components. It replaces jsdom or happy-dom, and ships its own Testing Library, user-event and jest-dom built on it. A 100-file React suite runs in 3.6 s with the whole stack against 14.4 s on jsdom and 9.3 s on happy-dom, in less memory than either.

  • Drop-in. Same globals as jsdom, and Testing Library implementations with the same exports, behavior, error messages and prettyDOM output as @testing-library/dom, /react, /user-event and /jest-dom. Swap only the DOM, or the whole stack, without touching tests.
  • Fast. Queries like getByRole take microseconds instead of milliseconds, user-event doesn't wait on timers between keystrokes, and idle time in findBy*, waitFor and app debounces is skipped on a virtual clock instead of waited out (turning it off).
  • Built for component tests. No layout, no script execution, no navigation, no network; everything else a React component touches behaves like a browser, or like jsdom where test suites depend on it.
Supported
Node26+
Test runnerVitest 5+, Jest 30+, node:test, Bun
React19 (@ctrl/dom/react uses React.act)

node:test and Bun: Other test runners.

Vitest

Whole stack: the environment plus your existing @testing-library/* imports routed to @ctrl/dom.

pnpm add -D @ctrl/dom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import { ctrlDom } from "@ctrl/dom/vitest";

export default defineConfig({
  plugins: [ctrlDom()],
  // Optional and faster: each test file gets a fresh context in a reused worker instead of a
  // new worker, so per-file setup drops from about 23 ms to about 2 ms.
  test: { pool: "vmThreads" },
});

DOM only (stock Testing Library packages keep working):

export default defineConfig({ plugins: [ctrlDom({ testingLibrary: false })] });

Options go to the plugin: ctrlDom({ url: "http://localhost:3000/", width: 1280 }). With the plugin installed, a file can also pick the environment itself: // @vitest-environment ctrl-dom. An environment you set in the config other than "ctrl-dom" stays the project's default.

The plugin also adds a Node flag to Vitest's workers, since the default pool starts one per test file: with its Testing Library it turns the compile cache on as each worker starts, so Vitest's own worker modules load from it too, not only the DOM.

Jest

pnpm add -D @ctrl/dom @ctrl/dom-jest-environment
// jest.config.js
module.exports = {
  preset: "@ctrl/dom", // the environment + @testing-library/* routed to @ctrl/dom
  setupFilesAfterEnv: ["@ctrl/dom/jest-dom"], // instead of "@testing-library/jest-dom"
};

DOM only: testEnvironment: "@ctrl/dom-jest-environment" (stock Testing Library keeps working). Options go in testEnvironmentOptions. Per file: /** @jest-environment @ctrl/dom-jest-environment */.

Other test runners

@ctrl/dom/register installs a window as the global for runners without a DOM environment, like happy-dom's @happy-dom/global-registrator, with the Vitest environment's defaults: the jsdom-30 profile, http://localhost:3000/, idle time skipped on a virtual clock, and exceptions thrown by event listeners failing the running test. Testing Library's automatic cleanup() after each test works under both runners below, stock @testing-library/react included.

node:test

node --import @ctrl/dom/register --test

or import "@ctrl/dom/register"; as the first import of a test file. node:test's hooks are not globals, so it exposes them as beforeAll, afterAll, beforeEach and afterEach (only names not already defined), the way Jest and Bun do: that is what Testing Library looks for. After the last test it closes the window and drops its pending timers, so a forgotten setInterval does not keep the file's process running. Files get a fresh window each since node:test runs every file in its own process; with --test-isolation=none they share one.

The jest-dom matchers need an expect. Jest's standalone expect takes them as is; with node:assert, assert on DOM properties:

// signup.test.ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { expect } from "expect";
import * as matchers from "@ctrl/dom/jest-dom/matchers";
import { createElement } from "react";
import { render, screen } from "@ctrl/dom/react";
import userEvent from "@ctrl/dom/user-event";
import { Signup } from "./signup.ts";

expect.extend(matchers);

test("signs up", async () => {
  const user = userEvent.setup();
  render(createElement(Signup));
  assert.equal((screen.getByRole("button", { name: "Sign up" }) as HTMLButtonElement).disabled, true);
  await user.type(screen.getByLabelText("Name"), "Ada");
  expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Ada");
});

Node strips TypeScript types but does not compile JSX: write .ts files with createElement, or add a loader such as tsx for .tsx files (node --import tsx --import @ctrl/dom/register --test, with "jsx": "react-jsx" in your tsconfig.json).

Bun

# bunfig.toml
[test]
preload = ["@ctrl/dom/register"]
// signup.test.tsx
import { expect, test } from "bun:test";
import * as matchers from "@ctrl/dom/jest-dom/matchers";
import { render, screen } from "@ctrl/dom/react";
import userEvent from "@ctrl/dom/user-event";
import { Signup } from "./signup.tsx";

expect.extend(matchers);

test("signs up", async () => {
  const user = userEvent.setup();
  render(<Signup />);
  expect(screen.getByRole("button", { name: "Sign up" })).toBeDisabled();
  await user.type(screen.getByLabelText("Name"), "Ada");
  expect(screen.getByRole("textbox", { name: "Name" })).toHaveValue("Ada");
});

bun test runs every file in one global, so files share the window (like Vitest with isolate: false); bun test --isolate (or --parallel) gives every file a fresh global and window. Bun's own test hooks are already globals.

Configuration

A preload takes no arguments, so it reads CTRL_DOM_URL, CTRL_DOM_COMPAT (jsdom-26, jsdom-30, modern), CTRL_DOM_TIME_SKIP (0/1) and CTRL_DOM_TEST_HOOKS=0 (leave the runner alone). Anything else goes through the API, which @ctrl/dom/register/pure exports without registering on import:

import { register, unregister } from "@ctrl/dom/register/pure";

const { window } = register({ url: "http://localhost:5173/", compat: "modern", width: 1280 });
// ...
unregister(); // closes the window and restores every global it replaced

register(options) takes the environment options (except compileCache and nodeFetch) plus testHooks (default false here, true for @ctrl/dom/register), and replaces any previous registration.

Storybook

Portable stories run under the ctrl-dom Vitest environment, and under node:test or Bun with @ctrl/dom/register: composeStories stories render with @ctrl/dom/react's render, and await Story.run() mounts the story and runs its play function (Storybook's storybook/test brings its own Testing Library and user-event, which run on the same DOM).

import { composeStories } from "@storybook/react";
import { render, screen } from "@ctrl/dom/react";
import * as stories from "./Signup.stories.tsx";

const { Empty, Filled } = composeStories(stories);

test("renders", () => {
  render(<Empty />);
  expect(screen.getByRole("button", { name: "Sign up" })).toBeDisabled();
});

test("plays", async () => {
  await Filled.run();
  expect(screen.getByRole("status")).toHaveTextContent("Hi, Ada!");
});

run() leaves its canvas in the document after the test, as on jsdom: Testing Library's cleanup() does not remove it, so a later test in the same file sees both (duplicate ids break label lookups). Where test hooks are global (Jest, Bun, node:test with the register), importing Testing Library's React entry turns React's act environment on, and React then warns about play functions' state updates outside act(...); the tests still pass.

Time skipping

Timers run on a virtual clock. Whenever a test has nothing left to do but wait (no pending promises, setImmediate callbacks or I/O), the clock jumps to the next timer, and Date, performance.now() and event timestamps move with it. A findBy* waiting out an 800 ms debounce returns in microseconds; timers still fire in order, and fake timers (vi.useFakeTimers(), jest.useFakeTimers()) take over as usual.

Turn it off when a test needs real time to pass: a timer from node:timers, or from a library that grabbed setTimeout before the environment started, racing the page's timers; or a test measuring time against a clock outside the process. Real I/O (sockets, files, child processes) already holds the clock, so a real server's latency is never skipped.

ScopeHow
Vitest, every filectrlDom({ timeSkip: false })
Vitest, one file// @vitest-environment-options {"timeSkip": false} as its first line
Jest, every filetestEnvironmentOptions: { timeSkip: false }
Jest, one file/** @jest-environment-options {"timeSkip": false} */ at the top
node:test, Bunregister({ timeSkip: false }) from @ctrl/dom/register/pure
Any runner, one runCTRL_DOM_TIME_SKIP=0, which overrides the option

Direct imports

Instead ofImport
@testing-library/react@ctrl/dom/react (also /react/pure, /react/dont-cleanup-after-each)
@testing-library/dom@ctrl/dom/testing-library
@testing-library/user-event@ctrl/dom/user-event
@testing-library/jest-dom@ctrl/dom/jest-dom (also /jest-dom/vitest, /jest-dom/matchers, /jest-dom/jest-globals)
aria-query + dom-accessibility-api@ctrl/dom/aria
jsdom / happy-dom@ctrl/dom (createWindow(), installGlobals(window))
@happy-dom/global-registrator@ctrl/dom/register (register(), unregister() from /register/pure)

Under either environment, @ctrl/dom and every subpath share the environment's DOM instance, in Jest and in every Vitest pool (including vmThreads/vmForks), so instanceof checks against @ctrl/dom's classes hold. The same goes for the window @ctrl/dom/register installs.

Options

Environment options (environmentOptions["ctrl-dom"] in Vitest, testEnvironmentOptions in Jest):

OptionDefault
urlhttp://localhost:3000/ (Vitest), http://localhost/ (Jest)document URL
width, height1024 × 768innerWidth / innerHeight, drives matchMedia
userAgent, platform, languages, devicePixelRatio, referrerjsdom-likenavigator / window values
timeSkiptrueskip idle time on a virtual clock; false keeps real-time timers. CTRL_DOM_TIME_SKIP=0/1 overrides
compat"jsdom-30" (Vitest), "jsdom-26" (Jest)which APIs beyond jsdom's are exposed: see below
layoutoff"lite": an approximate box model for geometry (getBoundingClientRect, offset*, client*, scroll*, ResizeObserver), so virtualizers (TanStack Virtual, react-window) and positioning libraries work without hand-written mocks. Off, geometry is zeros as in jsdom. Details: docs/layout.md
compileCache (Vitest)true with the plugin's Testing Library, else falseenable Node's compile cache before importing the DOM (with the plugin: as each worker starts), for every worker after the first. It stays on for everything the worker loads, which only pays off for large files (stock Testing Library's many small ones load slower). CTRL_DOM_COMPILE_CACHE=0/1 overrides
nodeFetch (Jest)falsekeep Node's fetch/Request/Response (absent under jest-environment-jsdom)

Options of the Vitest plugin alone (ctrlDom({ ... })):

OptionDefault
testingLibrarytrueroute stock @testing-library/* imports to @ctrl/dom's implementations; false swaps only the DOM
jsxRuntimetrueresolve react/jsx-runtime and react/jsx-dev-runtime to ES module copies of them. Vitest hands test and application code a Proxy over a CommonJS dependency, so every JSX element otherwise goes through the Proxy's get trap; a hooks-heavy render is 8% faster without it. Left alone when the project resolves the runtime to anything but its React
sharedWorkersfalserun a worker's test files one after another in the same realm instead of starting a worker per file: see below
debugoff"aria" or "compact": debugging with coding agents

Shared workers

export default defineConfig({ plugins: [ctrlDom({ sharedWorkers: true })] });

Most of what a suite of many short test files costs is not the tests: each file starts a worker, which boots Node, loads Vitest, React and react-dom, and runs all of it cold. sharedWorkers sets Vitest's isolate: false, so a worker keeps going from file to file, and puts back most of what a fresh worker gave each file. Base UI's 9,199 tests take 35 s isolated and 7–8 s this way, with the same results.

Before each test file:

  • first-party modules are evaluated again (vi.resetModules()), so module state and vi.mock() start over;
  • the window is reset: trees still mounted are unmounted, pending timers and animation frames dropped, the body emptied, focus, storage, cookies and the URL put back;
  • Testing Library's cleanup is registered for the file (stock Testing Library registers it for a worker's first file only, which is why the mode needs testingLibrary).

What a file can still see from an earlier one, and why the mode is opt-in:

  • the module state of dependencies. Vitest loads node_modules through Node, once per worker: React, a CSS-in-JS cache, a data-fetching client created at module scope in a dependency;
  • <head>, event listeners on window and document, and patched prototypes, which those modules may rely on;
  • memory. Vitest keeps every test it has run reachable for the life of the worker (chai remembers each expect.extend call, and Vitest makes one per test), and with it that file's modules: a worker's heap grows by the size of a file's module graph per file, 2.3 MB on Base UI. More workers means fewer files each.

The VM pools (pool: "vmThreads") already reuse workers with a fresh context per file, and are unaffected.

Compat profiles

Suites written for jsdom feature-detect: a library that finds PointerEvent, matchMedia or ResizeObserver takes its browser path, and a polyfill that installs itself only when an API is missing leaves ours in place instead of its mock. compat picks what the test global exposes:

compatMatchesExposes beyond jsdom 30
"jsdom-26"jest-environment-jsdom 30 (jsdom 26.1); the Jest defaultno PointerEvent or onpointer* handlers
"jsdom-30"Vitest's jsdom environment; the Vitest defaultnothing libraries feature-detect
"modern"everything @ctrl/dom implementsmatchMedia, ResizeObserver, the inert IDL attribute, visualViewport

docs/deviations.md lists every API in each profile, with the reason.

Debugging with coding agents

screen.debug() and failing queries print the whole DOM as HTML, which costs an agent thousands of tokens of classes and SVG paths. Run tests with CTRL_DOM_DEBUG=aria (or ctrlDom({ debug: "aria" })) to print the accessibility tree in Playwright's aria snapshot format instead, plus the closest matches when a *ByRole, *ByText or *ByLabelText query fails:

Unable to find an accessible element with the role "button" and name "Submit"

Closest matches:
- text: "Submit"  # in <div>, which has no role: give it role="button" or use getByText
- button "Pay now"  # same role, other name

Accessibility snapshot of <body> (CTRL_DOM_DEBUG=aria):
- main:
  - heading "Checkout" [level=1]
  - form "Payment":
    - textbox "Email address"
    - button "Pay now"
    - text: Submit

That error is 468 characters; upstream prints 2,476 for the same small form, and around 437,000 for a large page. ariaSnapshot(el?) / screen.ariaSnapshot(), prettyDOM(el, max, { format: "aria" }) and diffAriaSnapshots(before, after) are available directly. CTRL_DOM_DEBUG=compact prints HTML without class/style attributes and SVG internals. Without the variable, output is identical to Testing Library's.

In the same mode, every failing test (a failed toBe, a thrown error, not just queries) ends with a snapshot of the page as it was when it failed, before cleanup, plus what changed since the test's previous snapshot. Jest's environment does this on its own; with Vitest, ctrlDom() adds the setup file, or add "@ctrl/dom/vitest-environment/failure-snapshot" to setupFiles with the bare environment.

npx ctrl-dom debug <test file> [-t name] does all of it in one command: it finds Vitest or Jest, runs just that file in agent mode and prints a compact report (failures with their snapshot and closest matches, one stack frame, console output), exiting with the test's status.

To assert structure the way the snapshot prints it, jest-dom gets expect(el).toMatchAriaSnapshot(template), with Playwright's partial matching:

expect(screen.getByRole("form")).toMatchAriaSnapshot(`
  - textbox "Email"
  - button /Pay \\d+ €/ [disabled]
`);

A line for your CLAUDE.md / AGENTS.md:

When a component test fails, rerun it with npx ctrl-dom debug <file> [-t <name>] and read the accessibility snapshot and closest matches instead of adding screen.debug().

Details: docs/agent-debugging.md.

Sentry

@ctrl/sentry-tests puts test runs in Sentry: failing tests as issues with the failing line, what the test did as breadcrumbs and the page attached, flaky tests as FlakyTest issues, and each test file as a trace of its describe blocks, tests, hooks and test functions. On @ctrl/dom the traces also hold every Testing Library query, waitFor, user-event action and render (findByRole("option", …) (40 checks)), and failures get an accessibility snapshot of the page.

plugins: [ctrlDom(), sentryTests()]                // Vitest, sentryTests from @ctrl/sentry-tests/vitest

Setup for Vitest and Jest: its README.

Differences from jsdom

There is no script execution, no navigation and no network, as in jsdom and happy-dom, and no layout unless you turn on layout: "lite" (otherwise geometry is zeros, as in jsdom). Every intentional difference is listed, with the reason, in docs/deviations.md.

License

MIT. Includes code ported from Testing Library, dom-accessibility-api, aria-query, Playwright, jsdom and others; their copyright notices and licenses are in the package's NOTICE file.

版本与发布记录

@ctrl/dom 共发布 18 个版本,首次发布于 2026/10/26;过去 12 个月发布了 18 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.2.0latest2026/11/101 天前
0.1.152026/11/037 天前
0.1.142026/11/038 天前
0.1.132026/11/038 天前
0.1.122026/10/2913 天前
0.1.112026/10/2913 天前

关于 @ctrl/dom 的常见问题

@ctrl/dom 是什么?

A fast DOM for testing React components: a drop-in replacement for jsdom and happy-dom, with Testing Library, user-event and jest-dom built in.

如何安装 @ctrl/dom?

在项目目录运行 npm install @ctrl/dom,或使用 pnpm add / yarn add / bun add。它还提供命令行工具 ctrl-dom,可以用 npx ctrl-dom 直接运行。

@ctrl/dom 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@ctrl/dom 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@ctrl/dom 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=26。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@ctrl/dom 可以商用吗?

@ctrl/dom 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@ctrl/dom 的下载量有多少?

根据 npm 官方下载统计,@ctrl/dom 在 2026/10/11 – 2026/11/09 这 30 天内被下载 3,209 次,日均约 107 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@ctrl/dom 还在维护吗?

最新版本 0.2.0 发布于 2026/11/10(1 天前);至今共发布 18 个版本,其中过去 12 个月发布了 18 个;首次发布于 2026/10/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。