DOM testing
Use --dom for component and browser-facing unit tests:
esdev test --dom esdev test --dom --file=src/button.test.ts
--dom is deliberately test-runner-only. It creates a fresh in-memory DOM realm for every test file; normal esdev programs do not receive window or document. Fresh realms prevent event listeners, custom-element registrations, storage, and document mutations leaking into another test file.
What it is for
The DOM realm is intended for modern component tests: creating and querying nodes, mutating attributes and text, dispatching events, rendering templates, using forms, custom elements, Shadow DOM, MutationObserver, and focused controls. It is a DOM implementation, not a browser renderer. A test that needs layout or anything else only a browser has can run the same file in one with --browser.
import { test, assertEquals } from "runtime:test"; test("updates a control", () => { const input = document.createElement("input"); input.value = "ready"; document.body.append(input); assertEquals(document.querySelector("input")?.value, "ready"); });
Use the built-in runtime:test assertions and runner. --dom does not add compatibility layers for other test frameworks.
Libraries that drive the DOM through standard APIs run as they do in a browser. Testing Library's userEvent types, selects, tabs, copies and pastes: the realm has the text selection APIs, Selection, FileReader and an extensible navigator it relies on.
Strict modern HTML
innerHTML and fragment parsing accept conforming modern HTML, including the tags HTML allows to be omitted — </li>, </p>, </td>, an implied <tbody> before a <tr> — so a fixture builds the same tree here as in a browser.
Malformed input is refused with a diagnostic instead of being repaired:
| Markup | Result |
|---|---|
<table><tr><td>x</td></tr></table> | <table><tbody><tr><td>x</td></tr></tbody></table> |
<ul><li>a<li>b</ul> | <ul><li>a</li><li>b</li></ul> |
<p><i>unclosed | SyntaxError |
<b><i>x</b></i> | SyntaxError |
<table><td>x</td></table> | SyntaxError — a cell needs a row |
<my-widget><my-widget> | SyntaxError — a custom element closes like any other |
A test that depends on parser recovery does not state the DOM tree it expects. That includes custom elements: an unclosed <my-widget> is refused rather than nested and auto-closed, so a fixture ported from a browser runner may need its tags closed first.
Web components
Custom elements, shadow DOM and templates are covered in full, and measured against Chrome:
| Area | What works |
|---|---|
| Reactions | connectedCallback, disconnectedCallback, adoptedCallback, attributeChangedCallback with observedAttributes, namespaces and old values, in specification order |
| Registry | define, get, getName, whenDefined, upgrade; one constructor per name; new MyElement() from script |
| Shadow DOM | open and closed roots, delegatesFocus, clonable, serializable, named and manual slot assignment, assignedSlot, slotchange, retargeted events and paths |
| Styling | :host, :host(…), :host-context(…), ::slotted(…), adoptedStyleSheets per root |
| Forms | ElementInternals, form association, setFormValue, setValidity, formDisabledCallback, formResetCallback, CustomStateSet and :state() |
| Templates | inert content, declarative shadow roots through setHTMLUnsafe and Document.parseHTMLUnsafe, getHTML serialization |
| Dialogs & popovers | show, showModal, close, requestClose, returnValue, :modal; showPopover, hidePopover, togglePopover, :popover-open, beforetoggle/toggle, CommandEvent |
| Moving | moveBefore(), which keeps a node's state instead of removing and re-inserting it |
Scoped custom element registries — new CustomElementRegistry() used with attachShadow({ customElements }) — are a proposal and are not implemented. A registry that is not the document's keeps its own definitions and upgrades nothing.
Boundaries
The realm has no rendering engine, network, browsing contexts, or navigation. Do not use it to test layout, geometry, media loading, XHR, iframe behavior, script execution, or page navigation.
location, history, and web storage are in-memory test state. Form submit events are dispatched, but no navigation occurs. Animation frames use the test clock. Media, resize, and intersection observers are safe never-firing stubs. Geometry answers zero rather than throwing, so feature detection runs. A dialog and a popover have no top layer and no backdrop: what they have is their state, their selectors and their events.
Styles and the cascade
getComputedStyle() resolves the cascade — stylesheets, <style> elements, constructable sheets, adoptedStyleSheets, specificity, !important, inheritance through the flat tree, custom properties, user-agent defaults, @media, @supports and CSS nesting — and resolves everything that needs no box.
import { test, assertEquals } from "runtime:test"; test("a variant restyles the button", () => { const style = document.createElement("style"); style.textContent = ".danger { color: rgb(220, 38, 38) }"; const button = document.createElement("button"); button.className = "danger"; document.body.append(style, button); assertEquals(getComputedStyle(button).color, "rgb(220, 38, 38)"); });
@media is evaluated against a declared viewport: window.innerWidth and innerHeight start at 1024×768 and are assignable.
| Lengths | pixels: getComputedStyle(h1).fontSize is 32px, line-height: 1.5 at a 12px font is 18px, letter-spacing: 2em is 20px |
| Colours | rgb()/rgba(): color: red reads rgb(255, 0, 0), transparent reads rgba(0, 0, 0, 0), currentcolor the element's own colour |
| Shorthands | expanded to longhands, and serialized back from them: computed.border is 1px solid rgb(255, 0, 0) |
| A declaration | holds longhands too — style.border = "1px solid red" gives style.length === 17, and cssText prints the shorthand back |
What it does not do: resolve anything a box decides — a percentage of the containing block, ex, ch, the viewport units, line-height: normal — answer for an element outside the tree, or style a pseudo-element. See DOM parity.
Conformance
The DOM is checked against the JavaScript tests of the Web Platform Tests, and against headless Chrome, jsdom and happy-dom on the same cases, with Chrome's result taken as correct. The current results are on DOM parity.
Tests that need rendering, layout, navigation or a network are outside its scope. For those, use --browser.