warpdom
| Install | |
|---|---|
npm install warpdom |
|
| Latest Version: | 0.1.10 |
| License: | MIT |
| Last Updated: | Oct 26, 2026 |
| Links: | GitHub · npm |
warpdom
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 |
// 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.
// 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
v1tag 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:
DOMExceptionis anErrorin the test's realm, as the standard says (in Vitest's default pools jsdom's is not): 1 svelte test.CSSStyleSheet.ownerNodeis the owning element in the jsdom 27 and 28 profiles, as in jsdom 29 and later; the jsdom 20 to 26 profiles have noownerNode, as those versions. Measured on each release, jsdom 27.0 has noownerNode, 27.1 and 27.2 givenull, 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
largeSteptests (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
requestAnimationFramewith 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 wheneversuspense.specran before it in the same worker withoutexternal-source.specin 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-environmentcomment 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:
isTrustedis an accessor onEvent.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 disabledfieldsetis not focusable.- Standard behavior where jsdom deviates:
FormDataand 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 anddataset, MutationObserver's transient observers and notification order, directionality in shadow trees,document.writeacross several calls,visibilityStateand cookies of documents without a browsing context, CSSOM rule-list and@pagerules, XML serialization details,getElementsByName(HTML elements only), a removed iframe'scontentWindow(null), an iframe'sgetSVGDocument()(null; jsdom's throws a TypeError from inside jsdom). - Additions jsdom lacks:
innerText/outerText,Selection.direction, RangegetClientRects()/getBoundingClientRect()(empty),ToggleEvent,TouchList,TimeRangeson media elements, element interfaces and reflected attributes from the current HTML standard (but<search>,<selectedcontent>and<menuitem>areHTMLUnknownElement, as in jsdom). - Kept the same in every jsdom profile:
MediaListnormalization (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(),cssTextand thestyleattribute (and itsgetComputedStyle()) also call the property setters, so a spied setter sees them; warpdom's call it for assignments only (style.width = ...). Not modeled either: cssstyle'slengthsetter and underscore members, the accessor functions' names (get widthfrom jsdom 29,getin warpdom), jsdom 29.0.0's internal classes behindgetComputedStyle(), aCSSStyleRule'sstylewithout accessors before 27.1.0, andgetComputedStyle().azimuthwith cssstyle 4.2 and 4.3 (NaNdegfor an angle, jsdom's is empty). - Computed values not modeled: the
fontshorthand composed from its longhands (jsdom'sgetComputedStyle().fontandel.style.font), a shorthand followed by one of its longhands in a declaration block (jsdom stores the longhands:margin: 1px 2px; margin-top: 3pxreads3px 2px 1px),!importantin style sheets before jsdom 29 (jsdom ignores it there), jsdom 30'scalc()mixing percentages and em (calc(100% - 0px)), and the uppercase units andcalc()simplification of declared values in jsdom 29 and 30. jsdom 27.1 and later parse style sheets with the cssstyle that@acemir/cssomfinds, so with an older cssstyle installed beside it (such as Jest 29's 2.3.0) its sheets dropCanvasandfit-content; warpdom follows the cssstyle jsdom itself installs. FileReaderalso reads the platform'sBlob(one from Node'sfetchin Vitest); jsdom's throws a TypeError.TextDecodertrims 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.URLparses with the platform's parser (Ada in Node), asURLdoes 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'sWebAssemblyis its parent's (warpdom's frames share one realm).WebSocketis the platform's (Node's, from Node 22), asURLis 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.MessageEventandsocket instanceof window.EventTargetare false, and its errors are Node's. Without a platformWebSocketthe 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,loadandloadendevents report the whole body when the response's headers arrive, as jsdom's do. - Synchronous requests (
open(method, url, false)) throw aNotSupportedErroratsend(); jsdom runs them in a child process. - Cookies are neither sent nor stored. jsdom shares its cookie jar with
document.cookie: a response'sSet-Cookieshows up indocument.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-Modeheader. ADataViewbody is sent (jsdom 28 and later send none). The methodpatchin lowercase is sent as is, as jsdom does, and makes Node print a warning once. Requests from anabout:blankiframe send noReferer(jsdom:about:blank). - The
Referernever 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
fetchhides 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
fetchhides 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
errorand then goes on to follow the redirect and fireload. - A request stopped (
abort(),open()) in an uploadprogressorloadhandler fires nothing more. jsdom goes on with the upload's remainingloadandloadend, and from 28 firesreadystatechange(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
Testing helpers for Nuxt I18n Micro (translations, locales, formatting mocks).
Simple and complete DOM testing utilities that encourage good testing practices.
Core testing utilities and pipeline orchestration for the TestForge component te...
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 | |
| 0.1.5 | MIT | |
| 0.1.4 | MIT | |
| 0.1.3 | MIT | |
| 0.1.2 | MIT | |
| 0.1.1 | MIT | |
| 0.1.0 | MIT |
Pre-releases (1)
| Version | Released | License |
|---|---|---|
| 0.0.0-stage |