warpdom

A fast DOM for unit and component tests: jsdom's test results, faster. A drop-in Vitest and Jest environment.
4
Install
npm install warpdom
Latest Version:0.1.10
License:MIT
Last Updated:Oct 26, 2026
Links: GitHub  ·  npm
Maintainer: sonukapoor

warpdom

warpdom

DOI Sponsor OpenSSF Scorecard

A DOM for unit and component tests: your tests get the same results as on jsdom, faster. A drop-in environment for Vitest and Jest, written from the WHATWG specifications.

  • Same results. On 23 open-source suites (14 we tuned on, such as MUI, React Spectrum, handsontable, Radix, Vuetify, ariakit and svelte, about 43,600 tests; and 9 held out, about 13,500 tests), every test has the status it has on jsdom, apart from flaky tests and a short list of explained differences (below). In 12 CI jobs, 17 of the 25 suites (with two web applications) had no test that differed from jsdom in any round.
  • Faster on every suite we tuned on. Drop-in, warpdom was faster than jsdom on all 13 tuning suites in each of three independent runs: a median of about 1.27x, from 1.03x (solid) to 1.89x (MUI) per suite. handsontable, whose core runs jsdom 16.7, ran about 8–9x faster at the measured commit and about 3.2x on its current branch, which has since added its own jsdom speed-ups; it is reported separately.
  • Check it on your own suite in one command: npx warpdom compare.

Status: 0.x, before 1.0; what 1.0 will promise and what is still missing is in docs/road-to-1.0.md, how releases, jsdom, Node and test runner versions are supported in docs/stability.md. Numbers, method and raw data: docs/research/README.md, measured with dom-api-tracer.

Requirements

Node 22.12 or later. Vitest 2 or later (tested with 2, 3, 4 and 5) or Jest 27 or later (tested with 27 and 30). Fast mode needs Vitest 3.2 or later.

Setup

npm install --save-dev warpdom

Vitest. Vitest takes a path (or a vitest-environment-<name> package) as test.environment; warpdom exports the path:

// vitest.config.js
import { defineConfig } from 'vitest/config';
import { environment } from 'warpdom/vitest/environment';

export default defineConfig({
  test: { environment },
});

Or install the small companion package vitest-environment-warpdom too, and use the name: test: { environment: 'warpdom' }.

With test.projects, set it on the projects that use jsdom today.

Jest.

// jest.config.js
module.exports = {
  testEnvironment: 'warpdom/jest',
};

Or install the small companion package jest-environment-warpdom too, and use the name, as with jsdom: testEnvironment: 'warpdom', and /** @jest-environment warpdom */ in test files (Jest 27 to 30).

The page URL: environmentOptions: { warpdom: { url } } with Vitest (default http://localhost:3000, as with Vitest's jsdom environment, which reads environmentOptions.jsdom.url), testEnvironmentOptions: { url } with Jest (default http://localhost/, as with jest-environment-jsdom).

Test files that choose jsdom with a comment (/** @vitest-environment jsdom */, @jest-environment jsdom) keep using jsdom; change the comment to switch them.

Switch those comments too, even if the files pass on jsdom. Vitest groups test files by environment name and runs each group on its own workers, so files whose comment names another environment than the config run apart from the others. In a suite whose files share module state (isolate: false), that changes which files run before which in a worker, and with it the result of order-dependent tests: one of solid's tests passes or fails by that order alone, on jsdom and on warpdom alike. Use the same name in the config and in the comments (environment: 'warpdom' with vitest-environment-warpdom, and /** @vitest-environment warpdom */), or remove the comments.

Which jsdom warpdom behaves like

Test suites encode the behavior of the jsdom version they were written against (for example, getComputedStyle returned empty strings for most properties before jsdom 29, and initial values since). warpdom detects the jsdom your project installs (and, for jsdom 26 and earlier, the cssstyle version it loads) and follows that version's observable behavior. With jsdom alone, another jsdom version changed test results in 13 of 23 projects we measured (Version dependence). To set it yourself:

// Vitest
test: { environment, environmentOptions: { warpdom: { jsdom: '26.1.0' } } }
// Jest
testEnvironmentOptions: { jsdom: '26.1.0' }

Interfaces and members jsdom added along the way exist from that version on, as measured on jsdom's releases: crypto.randomUUID() from 22.1.0, URL.parse(), URL.canParse(), URLSearchParams.size and the value argument of its has() and delete() from 23.0.0, ElementInternals and attachInternals() from 23.1.0 (its labels and form-associated custom elements from 26.0.0, its role and ARIA accessors throw jsdom's TypeError until 27.0.0), Headers' getSetCookie() (separate Set-Cookie values) from 24.1.0, the SVG element interfaces of g, defs, desc, metadata, switch and symbol, SVGRect, viewBox, preserveAspectRatio and createSVGRect() from 26.1.0, PromiseRejectionEvent, BlobEvent, DeviceMotionEvent, DeviceOrientationEvent and their member interfaces from 27.0.0, CSSNestedDeclarations from 27.2.0, TextEncoder and TextDecoder from 27.4.0, Blob's text(), arrayBuffer() and bytes() from 28.1.0, CSSMediaRule.matches from 29.0.0 (window.crypto, window.performance, Blob, File, URL, URLSearchParams, Headers, console, WebAssembly, WebSocket, XMLHttpRequest, SVGNumber, SVGStringList, SVGAnimatedString, PageTransitionEvent, the empty navigator.plugins and navigator.mimeTypes, and media elements' audioTracks and videoTracks exist in every version). Before 28.1.0 an svg element's window event handlers (onunload, onmessage, ...) are the window's, as body's are. Event handlers follow the version too: oncopy, oncut, onpaste, onbeforeinput, the touch handlers and 11 others from 22.1.0 (before, onauxclick and onloadend keep what is assigned but never run, and their content attributes do nothing), the pointer event handlers from 27.0.0, and the window's onautocomplete, onautocompleteerror and onsort until 26.1.0. The window's viewport members (innerWidth, scrollY, devicePixelRatio, ...) and status are plain data properties until 26.1.0 and accessors from 27.0.0 (whose viewport setters replace them), as in jsdom, also on the global of Jest and Vitest's VM pools; name is a data property and document, window and self are accessors in every version, and before 24.1.2 Window.prototype inherits from EventTarget directly. <search> is display: block from 27.0.0. Before 29.0.0 el.style and getComputedStyle() give CSSStyleDeclaration objects whose prototype has the property accessors (width, ...) and the methods, and there is no CSSStyleProperties, so vi.spyOn(CSSStyleDeclaration.prototype, 'width', 'get') (or jest.spyOn) sees el.style.width and computed reads as with jsdom; from 29.0.0 the accessors are CSSStyleProperties.prototype's. cssstyle's getPropertyCSSValue(), getPropertyShorthand() and isPropertyImplicit() (returning undefined) exist with cssstyle 4.3 and earlier, and azimuth with cssstyle 5.3 and earlier (as an angle until 4.3, CSS-wide keywords only in 5.3), as they do in jsdom. Computed values follow the version too: colors stay as declared before 22.1.0 (red, transparent, GrayText), named and hex colors resolve from 22.1.0 while system colors stay keywords until 26, and from 27.0.0 system colors are jsdom's (ButtonFace is rgb(204, 204, 204)); before cssstyle 4.4 color keywords keep their case and only CSS 2's system colors exist (ThreeDFace, not Canvas). A declared 0 stays 0 before 28.1.0 where cssstyle does not parse the property (text-indent, min-width); margin, padding and border-width keep their declared form before 27.0.0 (2px 6px 3px 6px); from 29.0.2 font-family is depends on user agent when not declared and vertical-align: inherit inherits; from 30.0.0 lengths are px (h1 32px, calc(1em + 2px), 10vw 102.4px) and font size keywords too, and from 30.1.0 bolder and lighter are weights. An SVG a with an href matches :link. As in jsdom, the window's URL has no createObjectURL(); under Vitest the global URL is Vitest's (Node's, with a createObjectURL() that takes the window's Blobs), as with jsdom.

warpdom models jsdom 20.0.3 to 30.1.3. With a newer jsdom installed (a later minor or major release), tests get jsdom 30.1.3's behavior and warpdom prints a warning once; a weekly check (upstream-watch) opens an issue when such a release appears.

The same weekly check reads DOMParity's existence data and files the gaps it finds (scripts/api-gaps.mjs): one issue (labels parity, missing-api) per interface jsdom has that warpdom lacks, or with members jsdom has that warpdom lacks, at most 15 a run; and one tracking issue, "Beyond jsdom: core APIs other test DOMs have", whose checklist lists the "Test DOM core" APIs neither jsdom nor warpdom has but happy-dom does, rewritten each run with ticked boxes kept. What an existing API issue names is skipped, and nothing happens when the data has not changed. Run node scripts/api-gaps.mjs locally for a dry run that prints what it would open.

XMLHttpRequest and request mocking

XMLHttpRequest exists in every jsdom version, so warpdom has it too: written from the XHR standard on top of the host's fetch, with jsdom's observable behavior (events and their order, CORS checks and preflights, timeouts, aborts, errors; jsdom 28 changed several of these when it moved to undici, and the profile follows). HTTP libraries that look for it (axios) take their XHR adapter, as with jsdom. Request mocking works as with jsdom: msw answers the request the XHR makes through fetch (so a cross-origin mocked response needs Access-Control-Allow-Origin, as on jsdom), and nock answers XHR itself. The differences are listed under Differences from jsdom.

Before XMLHttpRequest, axios used Node's HTTP client under warpdom. To keep that (no XMLHttpRequest at all):

// Vitest
test: { environment, environmentOptions: { warpdom: { xhr: false } } }
// Jest
testEnvironmentOptions: { xhr: false }

Check your suite first: warpdom compare

npx warpdom compare                 # one round: results and an indicative speedup
npx warpdom compare --rounds 3      # numbers worth quoting
npx warpdom compare --correctness   # results only (for CI)

Run it where you run vitest or jest. It runs your suite on jsdom (or happy-dom, whichever you use) and on warpdom, and reports every test whose status changes, grouped by error, and the speedup. Nothing in your project is edited. It exits 1 if any test that passes on jsdom fails on warpdom. The report's summary contains no file or test names, so it can be shared. Monorepos with Vitest projects or workspaces and Jest projects are supported. Details: docs/compare.md.

In CI, the warpdom compare action runs the same comparison in a non-blocking job on every pull request and puts the differing tests and the time saved in the job summary (and, optionally, one pull request comment), without switching anything:

- uses: sonukapoor/warpdom/compare-action@main # better: a release tag or commit SHA

One caveat: on the warpdom side, compare switches Vitest test files that pin jsdom with a comment to another environment name than the config's, so they run in a separate group (see Setup). In a suite with isolate: false and order-dependent tests, that alone can change a result; check such a difference with the comments switched.

Switch: warpdom migrate

npx warpdom migrate --dry-run  # the plan, nothing changed
npx warpdom migrate            # make the changes, then install

Run it at the root of your project after warpdom compare (compare needs jsdom still installed). It switches every place that picks jsdom or happy-dom to warpdom: Vitest configs, projects and workspace files, environmentMatchGlobs, Jest configs and package.json "jest", @vitest-environment / @jest-environment comments and --environment flags in scripts, each with an exact text edit. It adds warpdom and vitest-environment-warpdom or jest-environment-warpdom where jsdom was declared, removes jsdom, happy-dom and their environment packages when nothing imports them any more, and prints the install command (it does not install). What it cannot change exactly (a computed environment, one inherited from another package) is listed as manual, and lines that mention jsdom (setup files, polyfills, console filters) are listed for review. It also warns about release-age settings (pnpm minimumReleaseAge, yarn npmMinimalAgeGate) with the date the new packages can be installed. warpdom behaves like the jsdom version it finds installed, so when migrate removes a jsdom version other than the one warpdom assumes by default, it pins that version next to the environment (environmentOptions.warpdom.jsdom, testEnvironmentOptions.jsdom) and your tests keep their current jsdom behavior (--no-keep-jsdom-version leaves it out). Details: docs/migrate.md.

Faster still

Fast mode (Vitest). Vitest isolates every test file, and reloading modules per file is a large part of a component suite's time. Fast mode runs files without isolation and resets warpdom's window between them; files that still leak module state are learned and run isolated. How much it gains depends on how much of a suite's time goes into reloading modules per file; measure it on your suite before relying on it.

Measured on GitHub Actions on 16 open-source Vitest suites (up to 5 rounds; warpdom 2606b3d): where sharing pays it is much faster than both jsdom's default and warpdom's drop-in, e.g. VueUse 9.6x over jsdom (5.2x over the drop-in), MUI 6.7x (3.7x), Vuetify 5.6x (3.8x), Radix 4.7x (2.6x), naive-ui 3.4x (3.0x), tanstack-query 2.4x (1.6x). Where sharing does not pay it runs the suite isolated (or splits for a gain within noise) and equals the drop-in (0.98-1.03x on the other suites). Median over the 16: 1.54x over jsdom's default, 1.01x over the drop-in. Against the fastest jsdom configuration that keeps every result (isolate: false, a cleanup, or the VM pool), the median is 1.27x, and it is slower on two suites (naive-ui 0.67x; melt-ui 0.84x). On Vitest older than 3.2, warpdom vitest run is not available and runs Vitest as configured, that is, the drop-in. Four of the 16 suites (melt-ui among them) run an older Vitest and ariakit passes three --project arguments, so those five were measured with our harness's emulation of the same rules.

Suite Mode chosen vs jsdom default vs best correct jsdom setup vs warpdom drop-in
VueUse split 9.62x 9.62x 5.17x
MUI split 6.67x 1.54x 3.73x
Vuetify split 5.61x 1.11x 3.80x
Radix split 4.73x 1.47x 2.59x
naive-ui split 3.36x 0.67x 3.01x
tanstack-query 7 of 19 projects split 2.41x 2.41x 1.59x
CopilotKit vue split 1.52x 1.52x 1.22x
ark-ui partly split 1.59x 1.59x 1.01x
floating-ui / melt-ui isolated 1.56x / 1.44x 1.01x / 0.84x 1.01x / 0.99x
7 more suites isolated 1.05-1.35x – 0.98-1.03x
Median of 16 1.54x 1.27x 1.01x

docs/fast-mode.md

// vitest.config.js
import { withFastMode } from 'warpdom/vitest/fast';
export default withFastMode({ test: { environment: 'warpdom' } });
npx warpdom vitest run

Fast frames (opt-in). requestAnimationFrame normally waits 16.7 ms of real time per frame, in jsdom and warpdom. With fastFrames, frames run as soon as the event loop gets to them; suites whose test helpers wait for frames get much faster (ariakit 1.6x over warpdom's default). A few tests can change result, so it is off by default: check with npx warpdom compare --fast-frames.

docs/fast-frames.md

// Vitest
test: { environment, environmentOptions: { warpdom: { fastFrames: true } } }
// Jest
testEnvironmentOptions: { fastFrames: true }

How warpdom is verified

Some checks run on every change, others on a schedule; all of them are public:

  • jsdom as a black-box reference. Behavior comes from the WHATWG and W3C specifications. Where jsdom differs from them, or from itself across versions, the difference is measured from outside (scripts that run jsdom 20 to 30 and compare results), never copied from its source. Each measured difference becomes a profile setting (Which jsdom warpdom behaves like) and a test.
  • Tests on every pull request and every push to main (ci.yml: npm test, about 1,400 tests on Node 22.22 and 24): unit tests, parity tests that run the same code on warpdom and on the matching jsdom version side by side (test/parity.js), and an API test that walks the windows of the newest jsdom of every major from 20 to 30 and fails when warpdom lacks a member jsdom has (test/jsdom-api-superset.test.js; known exceptions are listed in the test).
  • Real test suites before merging a behavior change. The maintainer starts a comparison (a manually started workflow, not an automatic check) in which open-source component libraries (React, Vue, Svelte, Solid and Angular) and two applications run on jsdom and on warpdom on GitHub-hosted runners, and every test's result is compared (corpus-timing.yml, dom-api-tracer). These suites also drove many fixes, so they are regression checks, not an independent validation; the method and every number are in docs/research/. The remaining differences are listed in Differences from jsdom.
  • Web Platform Tests on 17 areas, monthly at the newest WPT (wpt.yml, Web Platform Tests).
  • Benchmarks on pull requests (bench.yml): the same scripts on the base and the head, alternating on one runner, posted as a comment (for pull requests from forks and Dependabot, in the run's summary instead).
  • Review and supply chain: every change is a pull request with automated code review and a dependency scan (CVE Lite CLI, in ci.yml); workflow actions are pinned to commits except CVE Lite CLI, which follows its v1 tag on purpose; OpenSSF Scorecard runs on pushes to main and weekly (scorecard.yml); releases are published from GitHub Actions with npm provenance (RELEASING.md).
  • Watching upstream (upstream-watch.yml): a weekly job opens an issue when a jsdom newer than the newest modeled one, a changed inline style surface, or a new Vitest or Jest major appears.

You can run the same comparison on your own suite with warpdom compare.

Measured

All numbers below are from GitHub Actions runs on one frozen commit, warpdom 8630317 (2026-10-13): hosted Linux runners with 4 cores, pinned project commits, each project's own runner, config and jsdom, 2 workers. In each job the DOMs alternate on the same machine: an untimed warm-up, then 5 rounds with the order rotated. A speedup is the median of the per-round ratios, so it never mixes machines. Raw data, scripts and every derived number: docs/research/ (docs/research/data/ci-2026-10-13-208ad49-*).

At a glance

Result
Drop-in speed, 13 suites, 3 independent runs median 1.28x / 1.27x / 1.26x faster than jsdom; every suite faster in every run (1.03x to 1.89x)
handsontable (jsdom 16.7, outside the medians) 8.0-8.9x (593 s to 69 s); 2.5x on the first run after install
Held-out suites (8) median 1.36x (1.01x to 1.96x)
Applications Excalidraw 1.21x, Wire 1.03x, 0 tests changed
Same results 17 of 25 suites: no test changed in any round; the other 45 tests are each explained below
happy-dom, for comparison median 1.25-1.28x faster than jsdom, but changes results in 12 of 13 suites
Fast mode, 16 Vitest suites up to 9.6x over jsdom (VueUse); median 1.54x over jsdom, 1.01x over the drop-in
Version dependence (jsdom alone) another jsdom version changes results in 13 of 23 projects
Web Platform Tests 75.4% of subtests in 17 test-DOM areas (jsdom: 72.2%)

Drop-in speed and results

The first of three identical runs (run 38426985620), sorted by jsdom time. Times are the median of 5 rounds. The range is the suite's speedup in each of the three runs.

Suite jsdom happy-dom warpdom warpdom vs jsdom warpdom, 3 runs Tests that change result: happy-dom / warpdom
ariakit 537.3 s 232.1 s 407.2 s 1.32x 1.32–1.39x 51 / 7 ¹
CopilotKit 456.5 s 48.6 s ² 366.3 s 1.24x 1.23–1.25x 4,681 / 0
React Spectrum 419.9 s 363.1 s 338.4 s 1.24x 1.19–1.26x 1,441 / 0
MUI 264.0 s 185.1 s 141.2 s 1.89x 1.88–1.89x 1,042 / 0
ark-ui 205.7 s 141.5 s 130.8 s 1.59x 1.56–1.61x 116 / 3 ³
svelte 91.4 s 85.5 s 84.6 s 1.08x 1.08–1.09x 288 / 1 ⁴
Vuetify 39.6 s 26.7 s 26.8 s 1.49x 1.49–1.52x 4 / 0
emoji-picker-element 37.2 s 32.3 s 29.1 s 1.28x 1.18–1.28x 1 / 0
Radix 29.6 s 19.7 s 16.4 s 1.79x 1.75–1.86x 18 / 0
floating-ui 23.7 s 18.8 s 15.5 s 1.53x 1.52–1.55x 14 / 0
analog 19.3 s 17.3 s 16.0 s 1.20x 1.19–1.21x 2 / 0
react-hook-form 12.8 s 14.2 s 10.3 s 1.24x 1.21–1.24x 8 / 0
solid 3.4 s 3.1 s 3.3 s 1.03x 1.03x in each 0 / 1 ⁵

¹ Tests that fail on jsdom and pass on warpdom. ² CopilotKit is fast on happy-dom because 4,681 of its tests break there. ³ Tests that fail on jsdom and pass on warpdom, and one that is flaky on jsdom too (2 without it). ⁴ A deliberate difference (DOMException, below). ⁵ An order-dependent test of solid's, not a DOM difference (below).

  • Median: 1.28x, 1.27x and 1.26x in the three runs (95% two-level bootstrap 1.22–1.54x, 1.21–1.54x, 1.19–1.55x), 1.27x over the three runs' rounds pooled. The suites fall into two groups, about 1.0x to 1.3x and 1.5x to 1.9x, so read the table rather than the median. Between runs, a suite's speedup moved by a median of 2.3% and at most 8.6% (emoji-picker-element), although absolute times moved by up to 1.6x with the runner's CPU (Radix on jsdom: 28.9 s to 46.8 s).
  • Where the gain is small: a faster DOM only speeds up the part of a run spent in the DOM; compilers, frameworks, module loading and waiting stay as they are. solid (1.03x; single rounds 0.97x to 1.06x) and svelte (1.08x to 1.09x) gain little, as do the held-out taiga-ui (1.01x, 0.99x to 1.03x) and react-router (1.04x) and the Wire web app (1.03x, 1.00x to 1.03x).
  • happy-dom is about as fast (median 1.25x, 1.27x, 1.28x over jsdom in the three runs) but changes results in 12 of the 13 suites, and is slower than jsdom on react-hook-form (0.88x to 0.94x).

handsontable

Reported apart from the medians because its core, React and Vue packages run jsdom 16.7.0, older than the jsdom 20 the medians require. With a warm Jest cache the whole suite ran 8.62x faster in the run above (593.2 s on jsdom, 69.2 s on warpdom) and 7.97x to 8.87x in the three runs; the core package alone 11.2x to 12.5x, the Angular wrapper (jsdom 20.0.3) 1.86x to 1.98x. On the first run after install, when both DOMs pay for a cold transform cache and the Angular wrapper's compile, it was about 2.5x (2.53x to 2.55x). 3 tests differ, all deliberately (below). happy-dom completed no timed round.

On handsontable's current branch (develop at fa6f901, measured 2026-10-14 with warpdom 0.1.1, 5 rounds) the speedup is smaller, 3.17x (251 s on jsdom, 79 s on warpdom; core 3.48x): handsontable has since added its own workarounds for jsdom's slowness (a stylesheet mock and a patch of jsdom's CSS library), which make its jsdom baseline faster. 7 of its new tests test that patch of jsdom's CSS library, which warpdom does not have, so they fail on warpdom.

Held-out suites and two applications

Held-out. Nine projects warpdom was not tuned on (naive-ui, taiga-ui, melt-ui, dom-testing-library, tanstack-query, solid-primitives, VueUse, react-router, downshift; about 13,500 tests). At first contact 58 tests (0.43%) differed; fixing their causes means the set is no longer unseen. Drop-in, 5 rounds: a median of 1.36x (95% two-level bootstrap 1.05x to 1.69x) over the 8 projects with valid jsdom rounds, from 1.01x (taiga-ui) to 1.96x (VueUse); solid-primitives 1.14x from its raw rounds (its jsdom runs report 901 or 922 tests, so the broken-round rule drops them). In the 17 CI rounds that ran each of them, only these tests changed result: tanstack-query 1 (the ownerNode difference below), melt-ui 1 (speed-sensitive, below) and solid-primitives 4 (an order dependence of the project's own, below), plus flaky tests.

Web applications. Excalidraw ran 1.21x faster (1.20x to 1.23x) and the Wire web app 1.03x (1.00x to 1.03x, about equal), with no test that changed result. happy-dom changed 44 and 63 of their tests and ran Wire at 0.54x of jsdom's speed. Both apps drove fixes when they were first tried (2 and 141 tests differed then), so this is a migration case study, not held-out evidence.

Version dependence

Suites encode the jsdom version they were written against. Measured with jsdom alone, no warpdom: running each project on jsdom 20.0.3, 26.1.0 or 30.1.2 instead of its own version changed the status of tests in 13 of 23 projects (counting tests that ran; files that fail to load under a forced version are left out). It is concentrated: ariakit 258 tests (13.9%) on jsdom 20, React Spectrum 138 on jsdom 30, svelte 38, the others 1 to 16. Even a minor update of cssstyle, the CSS library jsdom 26 and earlier load (4.1.0 to 4.6.0), changed 12 svelte tests. warpdom therefore follows the jsdom (and cssstyle) each project installs. Given the latest jsdom's behavior instead of their own, warpdom would differ from jsdom in more tests in 7 of 23 suites: React Spectrum 0 → 146, svelte 1 → 25, handsontable 3 → 23, downshift 0 → 7, ark-ui 2–3 → 6, CopilotKit 0 → 1, dom-testing-library 0 → 1.

Web Platform Tests

On the 17 WPT areas a test DOM is expected to cover (3,447 files; dom, domparsing, html/dom, html/semantics/forms and interactive-elements, css/cssom, css/cssom-view, url, encoding, encoding/streams, FileAPI, WebCryptoAPI, hr-time, user-timing, the resize and intersection observers and web-animations/interfaces), warpdom passes 75.4% of subtests (jsdom 30.1.2: 72.2%). It is ahead or level in every area but html/semantics (forms: 70.6% vs 77.1%; issues #40-#51) and domparsing (341 vs 342 subtests, both 30.3%). Neither implements WebCrypto's subtle, the observers or Web Animations (opt-in work, #5-#12), which keeps both totals low. warpdom was worked on against the first four areas (87.4% vs 80.5% there on 2026-10-13), so those are not an independent check. The runner is in wpt/; the workflow runs all 17 areas monthly at the newest WPT, and xhr (most of its tests need WPT's server, which the runner does not have).

Full tables, method, profiles and caveats: docs/research/README.md.

Differences from jsdom

API by API, how warpdom, jsdom, happy-dom and linkedom each support the web platform (behavior checks for commonly used APIs, existence of every Web IDL interface and member) is on DOMParity, a separate reference site with the same method for every engine (maintained by warpdom's author).

warpdom matches jsdom's observable behavior where tests depend on it, and otherwise follows the standards. Deliberate differences, each with a test (full list in PLAN.md, "Deliberate deviations from jsdom"). Those that changed a test in the 25 measured suites:

  • DOMException is an Error in the test's realm, as the standard says (in Vitest's default pools jsdom's is not): 1 svelte test.
  • CSSStyleSheet.ownerNode is the owning element in the jsdom 27 and 28 profiles, as in jsdom 29 and later; the jsdom 20 to 26 profiles have no ownerNode, as those versions. Measured on each release, jsdom 27.0 has no ownerNode, 27.1 and 27.2 give null, and 27.3 to 28.1 an internal object that is not a Node: 1 tanstack-query test (jsdom 27.4).
  • warpdom reports no style-sheet parse errors to the console. jsdom 16 cannot parse CSS nesting, drops such a sheet and reports "Could not parse CSS stylesheet": 3 handsontable tests of its filter for that message (jsdomThemeCssFilter.unit.js).

Results that change for other reasons:

  • Tests that fail on jsdom and pass on warpdom: ariakit 7 (in every round), and ark-ui's Toast and NumberInput largeStep tests (some rounds). They count as differences because the goal is jsdom's results.
  • Speed-sensitive (melt-ui): a Context Menu test fails in some rounds (7 of 17) on warpdom. It stubs requestAnimationFrame with a 16 ms timer, and warpdom finishes a Svelte transition sooner. A Dropdown Menu test of the same kind fails on jsdom too now and then; for the Context Menu test the cause is inferred from that one, not shown.
  • Order dependence (solid): solid's suite shares module state between files (isolate: false). Its transition test fails whenever suspense.spec ran before it in the same worker without external-source.spec in between, on jsdom and on warpdom: in all 32 such runs, and it passed in all 98 others. On CI it failed on warpdom in 24 of 27 rounds and never on jsdom, because the harness gave solid's files with an @vitest-environment comment another environment name than the config on warpdom only, which put them in a separate group (see Setup).
  • Order dependence (solid-primitives): one of its files leaves fake timers on; 4 timer tests that run after it in the same worker time out (4 of 17 rounds), on jsdom too when forced into that order. On CI that order occurred only under warpdom.

Other deliberate differences:

  • isTrusted is an accessor on Event.prototype, not an own property of every event (much faster to create events; only interface-shape checks can tell).
  • tabindex="abc" gives the element's default tab index (jsdom: 0); a disabled fieldset is not focusable.
  • Standard behavior where jsdom deviates: FormData and disabled options, formnovalidate, Selection.containsNode, event handler re-registration order, failed custom elements and :defined, XPath functions jsdom's engine gets wrong, namespaced attributes in selectors and dataset, MutationObserver's transient observers and notification order, directionality in shadow trees, document.write across several calls, visibilityState and cookies of documents without a browsing context, CSSOM rule-list and @page rules, XML serialization details, getElementsByName (HTML elements only), a removed iframe's contentWindow (null), an iframe's getSVGDocument() (null; jsdom's throws a TypeError from inside jsdom).
  • Additions jsdom lacks: innerText/outerText, Selection.direction, Range getClientRects()/getBoundingClientRect() (empty), ToggleEvent, TouchList, TimeRanges on media elements, element interfaces and reflected attributes from the current HTML standard (but <search>, <selectedcontent> and <menuitem> are HTMLUnknownElement, as in jsdom).
  • Kept the same in every jsdom profile: MediaList normalization (jsdom 30's) and writable style indexed properties (jsdom 26's).
  • Style declarations: with cssstyle 5.3 and earlier (jsdom 20 to 28.0), jsdom's setProperty(), cssText and the style attribute (and its getComputedStyle()) also call the property setters, so a spied setter sees them; warpdom's call it for assignments only (style.width = ...). Not modeled either: cssstyle's length setter and underscore members, the accessor functions' names (get width from jsdom 29, get in warpdom), jsdom 29.0.0's internal classes behind getComputedStyle(), a CSSStyleRule's style without accessors before 27.1.0, and getComputedStyle().azimuth with cssstyle 4.2 and 4.3 (NaNdeg for an angle, jsdom's is empty).
  • Computed values not modeled: the font shorthand composed from its longhands (jsdom's getComputedStyle().font and el.style.font), a shorthand followed by one of its longhands in a declaration block (jsdom stores the longhands: margin: 1px 2px; margin-top: 3px reads 3px 2px 1px), !important in style sheets before jsdom 29 (jsdom ignores it there), jsdom 30's calc() mixing percentages and em (calc(100% - 0px)), and the uppercase units and calc() simplification of declared values in jsdom 29 and 30. jsdom 27.1 and later parse style sheets with the cssstyle that @acemir/cssom finds, so with an older cssstyle installed beside it (such as Jest 29's 2.3.0) its sheets drop Canvas and fit-content; warpdom follows the cssstyle jsdom itself installs.
  • FileReader also reads the platform's Blob (one from Node's fetch in Vitest); jsdom's throws a TypeError. TextDecoder trims only ASCII whitespace from labels, as the Encoding standard says (jsdom accepts labels with other whitespace or NUL around them), and decodes Big5, EUC-JP, EUC-KR, GBK, gb18030, ISO-2022-JP and Shift_JIS with the platform's decoders, which differ from jsdom's for a few byte sequences.
  • URL parses with the platform's parser (Ada in Node), as URL does in Vitest's jsdom environment: for some internationalized host names (IDNA) and opaque paths with trailing spaces it differs from jsdom's whatwg-url (WPT url/: warpdom 7,136 of 8,784 subtests, jsdom 6,394). An iframe's WebAssembly is its parent's (warpdom's frames share one realm).
  • WebSocket is the platform's (Node's, from Node 22), as URL is Node's under Vitest; jsdom has its own. The window's and the global's are the same object in Vitest and Jest, and tests replace it as on jsdom (window.WebSocket = Mock, vi.stubGlobal). Its sockets and events are Node's, not the window's: event instanceof window.MessageEvent and socket instanceof window.EventTarget are false, and its errors are Node's. Without a platform WebSocket the window has none.

Not implemented (jsdom lacks these too): layout (sizes and positions are 0, as in jsdom), inert, loading external style sheets, scripts or pages, navigation to other documents.

XMLHttpRequest makes its requests with the host's fetch, which decides these differences from jsdom:

  • getAllResponseHeaders() lists the headers sorted by name, with repeated headers combined into one line (jsdom: in the order received, and from jsdom 28 each repeated header on its own line). getResponseHeader() is the same.
  • Upload progress is not measured: the upload's progress, load and loadend events report the whole body when the response's headers arrive, as jsdom's do.
  • Synchronous requests (open(method, url, false)) throw a NotSupportedError at send(); jsdom runs them in a child process.
  • Cookies are neither sent nor stored. jsdom shares its cookie jar with document.cookie: a response's Set-Cookie shows up in document.cookie, and requests send the document's cookies.
  • file: URLs fail with a network error (jsdom reads the file).
  • Requests carry fetch's Sec-Fetch-Mode header. A DataView body is sent (jsdom 28 and later send none). The method patch in lowercase is sent as is, as jsdom does, and makes Node print a warning once. Requests from an about:blank iframe send no Referer (jsdom: about:blank).
  • The Referer never carries the page URL's credentials or fragment, as Referrer Policy says. jsdom sends the document's URL with both, and before 28 sends a redirected request's previous URL as requested.
  • Where the host's fetch hides redirects (in a browser, redirect: 'manual' gives an opaque redirect), the host follows them with its own checks and only the final response gets jsdom's; the first redirected request is sent a second time to find this out. Node, Deno and Bun expose redirects.
  • Browser hosts are not supported for cross-origin requests yet: a browser's fetch hides the CORS headers warpdom checks, so allowed cross-origin responses fail (#91). Requests never carry the host's own cookies (credentials: 'omit').
  • A redirect that fails its CORS check is a network error. jsdom before 28 fires error and then goes on to follow the redirect and fire load.
  • A request stopped (abort(), open()) in an upload progress or load handler fires nothing more. jsdom goes on with the upload's remaining load and loadend, and from 28 fires readystatechange (readyState 2) at the stopped or reopened request.
  • jsdom 20 to 26 report network errors on the virtual console; warpdom's test environments report them where they report "Not implemented" messages, in jsdom's words for CORS failures and with fetch's for others.

API

import { createWindow } from 'warpdom';   // or const { createWindow } = require('warpdom');

const window = createWindow({ url: 'http://localhost/', jsdom: '30.1.2', fastFrames: false });
window.document.body.innerHTML = '<p>hello</p>';

require('warpdom') works in Node and in Jest test files, and TypeScript types ship with the package. docs/api.md has the options and the equivalents of jsdom's API (new JSDOM(html, { url }), dom.window, dom.serialize()) for tests that create windows themselves.

warpdom/compare exports compare(options), the API behind the CLI; warpdom/migrate exports migrate({ cwd, apply }), which returns the plan.

Development

npm test              # unit and parity tests (node:test; parity tests run jsdom side by side)
npm run test:types    # the published types, under tsc --strict (test/types)
npm run build         # dist/warpdom.cjs, the CommonJS build (also run by npm test and npm pack)
npm run bench:load    # cold load and window creation
npm run bench:hot     # hot DOM members against jsdom and happy-dom
npm run format        # Prettier (CI runs npm run format:check)
npm run lint          # ESLint readability rules, such as no nested ternaries (Node 22.13+)
node wpt/run.mjs dom/nodes --engine warpdom,jsdom   # Web Platform Tests

warpdom is written from the specifications; jsdom is used as a black-box reference (its source is not read or copied). Plans and decisions: PLAN.md; architecture: docs/design.md; the road to 1.0: docs/road-to-1.0.md; versions and support: docs/stability.md; reporting a vulnerability: SECURITY.md. Code is formatted with Prettier and linted with ESLint in CI; existing nested ternaries are listed in eslint-suppressions.json until they are rewritten. git config blame.ignoreRevsFile .git-blame-ignore-revs keeps the formatting commit out of git blame. Releases are published from GitHub Actions with npm provenance when a version tag is pushed: RELEASING.md.

CI scans the dependencies with CVE Lite CLI (fails on high or critical advisories in production dependencies; results in GitHub Code Scanning) and runs OpenSSF Scorecard weekly.

Citing

If you use warpdom in research, please cite it: GitHub's "Cite this repository" button reads CITATION.cff, and each release is archived on Zenodo with a DOI.

License

MIT. Generated data tables in src/ are derived from the Unicode Character Database (src/bidi-data.js, Unicode License v3) and from webref (src/css-data.js, src/html-elements.js, src/event-handler-names.js; MIT); the scripts that generate them are in scripts/. The Web Platform Tests are not included: the runner in wpt/ uses a separate checkout (WPT is under the 3-Clause BSD License).

Related Packages

@i18n-micro/test-utils

Testing helpers for Nuxt I18n Micro (translations, locales, formatting mocks).

152,219 251
vue3-snapshot-serializer

Vitest snapshot serializer for Vue 3 components

369,150 16
@vuetify/testing-library-dom

Simple and complete DOM testing utilities that encourage good testing practices.

15,432 0
@testforgejs/vue-test-core

Core testing utilities and pipeline orchestration for the TestForge component te...

622 0
vitest-browser-vue

Render Vue components in Vitest Browser Mode

5,213,984 79

Version History

Version Released License
0.1.10 MIT
0.1.9 MIT
0.1.8 MIT
0.1.7 MIT
0.1.6 MIT
Pre-releases (1)
Version Released License
0.0.0-stage