ReviewOS

stacks/very-happy-dom

public
Clone

Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.

A very performant virtual DOM implementation.

main
· 3 branches · 185 commits

Mirrored from stacksjs/very-happy-dom · synced 48 minutes ago

README.md

Social Card of this repo

npm version GitHub Actions Commitizen friendly

very-happy-dom

A blazingly fast, lightweight virtual DOM implementation powered by Bun. Drop-in replacement for happy-dom and jsdom in testing environments.

Features

  • Comprehensive DOM - Full DOM manipulation, CSS selectors, XPath, events with bubbling/capturing
  • Network APIs - Fetch, XMLHttpRequest, WebSocket, Server-Sent Events, BroadcastChannel, MessageChannel, request interception
  • Browser APIs - Storage, Timers, Canvas 2D, Observers (Mutation/Intersection/Resize/Performance), Clipboard, History, Cookies, File API, IndexedDB, Web Storage
  • Web Components - Custom Elements and Shadow DOM
  • Framework Agnostic - Works with Bun, Vitest, or any testing framework
  • jsdom-compatible - Real JSDOM class with .serialize(), .reconfigure(), .fromURL(), .fromFile(), .fragment(), VirtualConsole, CookieJar, ResourceLoader
  • happy-dom-compatible - Drop-in for GlobalRegistrator, window.happyDOM API, virtual consoles
  • Screenshot - Pure-JS PNG/JPEG/WebP rendering + optional Bun.WebView real-browser screenshots

Installation

bun add -d very-happy-dom

Or with npm/pnpm:

npm install --save-dev very-happy-dom
pnpm add -D very-happy-dom

Quick Start

import { Window } from 'very-happy-dom'

const window = new Window()
const document = window.document

document.body.innerHTML = '<h1>Hello World</h1>'
const heading = document.querySelector('h1')
console.log(heading?.textContent) // "Hello World"

Testing with Bun

The simplest way — create a Window per test:

import { describe, expect, test } from 'bun:test'
import { Window } from 'very-happy-dom'

describe('MyComponent', () => {
  test('renders correctly', () => {
    const window = new Window()
    const document = window.document

    document.body.innerHTML = '<div class="container">Test</div>'
    const element = document.querySelector('.container')

    expect(element?.textContent).toBe('Test')
  })
})

Global DOM Environment

For Testing Library, React, and other frameworks that expect browser globals (document, window, etc.), either use the one-line preload subpath or call GlobalRegistrator manually.

Easiest — the /register subpath:

# bunfig.toml
[test]
preload = ["very-happy-dom/register"]

You can override the URL with VERY_HAPPY_DOM_URL or HAPPY_DOM_URL env vars.

Manual — drop-in for @happy-dom/global-registrator:

// happy-dom.ts (preload script)
import { GlobalRegistrator } from 'very-happy-dom'

GlobalRegistrator.register()
# bunfig.toml
[test]
preload = ["./happy-dom.ts"]

That's it. All browser globals are now available in your tests:

import { test, expect } from 'bun:test'
import { screen, render } from '@testing-library/react'
import { MyComponent } from './MyComponent'

test('renders correctly', () => {
  render(<MyComponent />)
  expect(screen.getByTestId('my-component')).toBeInTheDocument()
})

Migrating from happy-dom

One-line change — the GlobalRegistrator API is the same:

-import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { GlobalRegistrator } from 'very-happy-dom'

GlobalRegistrator.register()

Advanced Usage

Web-First Assertions

Importing very-happy-dom/matchers registers Playwright-style matchers into bun:test. They retry until the expectation holds or the timeout expires, which is the difference that matters:

import { expect, test } from 'bun:test'
import { Browser } from 'very-happy-dom'
import 'very-happy-dom/matchers'

test('the alert reports the save', async () => {
  const page = new Browser().newPage()

  await expect(page.getByRole('alert')).toContainText('Saved')
})

The one-shot rewrite — expect(await page.getByRole('alert').textContent()).toContain('Saved') — samples once, so anything that appears after a fetch settles or a microtask drains is a race. .not waits for the opposite to become true rather than inverting a single sample.

Checked state is read through ARIA, so a design system's <div role="checkbox" aria-checked> answers truthfully — isChecked(), getByRole({ checked }), check()/uncheck() and toBeChecked() all go through one predicate. locator.checkedState() returns 'mixed' for a tri-state control, and toBeChecked({ indeterminate: true }) asserts it. toBeChecked() refuses an element with no checked state rather than answering false, so .not.toBeChecked() cannot pass against a plain <div>.

Locators: toBeAttached, toBeVisible, toBeHidden, toHaveCount, toHaveText, toContainText, toHaveValue, toHaveValues, toHaveAttribute, toHaveClass, toHaveId, toHaveCSS, toHaveJSProperty, toHaveRole, toHaveAccessibleName, toHaveAccessibleDescription, toBeEnabled, toBeDisabled, toBeChecked, toBeEditable, toBeFocused, toBeEmpty. Pages: toHaveURL, toHaveTitle. Each takes an optional { timeout }, defaulting to page.setDefaultTimeout().

toHaveRole and toHaveAccessibleName assert what getByRole queries on, which is how a failing role query becomes diagnosable. toHaveCSS compares values as the engine reports them, so 'red' will not match a computed 'rgb(255, 0, 0)' — use a RegExp. toHaveJSProperty takes a path, so validity.valueMissing works.

toBeInViewport takes an optional ratio, and can answer "no" — the layout pass gives a box a real position, which is what it was waiting on.

An element is hidden by display: none, visibility: hidden, the hidden attribute, or a declared zero size — height: 0, max-height: 0 and the like, which is how a closed accordion or drawer is built when the author wants a transition. Declared is the operative word: an empty box can also mean the layout pass had nothing to give the element a size, and a text-driven height is an estimate, so a zero box means unknown rather than collapsed and only a declaration is trusted. That is narrower than Playwright's non-empty-bounding-box rule, deliberately.

Locator actions auto-wait on the same terms — present, rendered and not disabled — and locator.waitFor({ state }) covers attached, detached, visible and hidden. Two conditions Playwright checks are not checked here: stable (not mid-animation), which needs an animation clock, and receives-events (not covered by another element), which the layout pass now has the geometry for but which nothing asserts yet.

Rendered Text

innerText returns text as rendered: hidden subtrees left out, whitespace collapsed, block elements separated by newlines, <br> honoured. Visibility is resolved through the cascade, so a display: none from a stylesheet is excluded — not only an inline one. white-space: pre, pre-wrap and break-spaces keep their text verbatim, pre-line keeps newlines, and a <pre> element counts as pre with no rule needed.

textContent is unchanged: raw, untrimmed, uncollapsed.

document.body.innerHTML = '<p>  Save   changes  </p>'
document.querySelector('p').textContent   // '  Save   changes  '
document.querySelector('p').innerText     // 'Save changes'

Dialogs

page.on('dialog', dialog => dialog.accept())          // confirm() returns true
page.on('dialog', dialog => dialog.accept('Ridge'))   // prompt() returns 'Ridge'
page.on('dialog', dialog => dialog.dismiss())         // false / null

Without a handler every dialog is dismissed, which is Playwright's default and what the old hardcoded false/null did — so nothing changes unless you opt in. dialog carries type, message and defaultValue, so a test can assert which dialog appeared, including an alert().

The decision must be made synchronously: confirm() is a synchronous DOM API, so a handler that awaits before deciding answers after the page has already acted on the default. That throws rather than letting the wrong answer through. dialog.accept() records the decision immediately, so async dialog => await dialog.accept() is fine — it is awaiting before deciding that is not.

Combining Locators

// Pick a row by what is inside it, then act within that row
const row = page.getByRole('row').filter({ has: page.getByText('Ridge Loop') })
await row.getByRole('button', { name: 'Delete' }).click()

// Two possible outcomes, without racing them
await expect(page.getByRole('alert').or(page.getByText('Saved'))).toBeVisible()

// Only the elements both match
await page.getByRole('button').and(page.locator('.primary')).click()

filter({ has }) and { hasNot } query the inner locator from each candidate, not from the page — so a match elsewhere does not qualify a row. hasText is not a substitute: it compares the candidate's whole text, so a neighbouring column containing the string matches too, and it cannot ask about a role.

filter() throws on an unrecognised key. Ignoring one is how filter({ has }) used to look like it worked while matching every candidate. or() returns document order with duplicates removed.

Media Queries

await page.emulateMedia({ colorScheme: 'dark' })
await page.emulateMedia({ media: 'print' })
await page.emulateMedia({ reducedMotion: 'reduce', forcedColors: 'active' })
await page.emulateMedia({ colorScheme: null })   // back to the browser's setting

@media blocks are parsed into real CSSMediaRules and applied when their condition holds, so a dark-mode or responsive stylesheet takes effect in getComputedStyle, in getBoundingClientRect and in the locator queries that read visibility. matchMedia and the cascade evaluate the query with the same code, so they cannot disagree about the same document.

Understood features: prefers-color-scheme, prefers-reduced-motion, forced-colors, orientation, and the width/height family with px, em and rem. Media types (screen, print, all), and, comma lists, not and only all work. Any other feature — (hover: hover), for instance — makes its query false, as an unrecognised feature does in a browser.

Geometry

const box = await page.locator('.card').boundingBox()   // { x, y, width, height } | null

All four are real. The rect resolves through the cascade, so a size from a stylesheet is reported, and there is a layout pass, so x and y are where the box actually sits — overlap, ordering, hit testing and is-this-above-the-fold can all be asked. null is returned for an element that is not rendered.

Block-level boxes stack down their containing block and fill its width unless they declare one; inline and inline-block boxes flow along a line, shrink to fit and wrap. box-sizing, margins, padding, borders, min/max sizes, percentages, position: relative, and absolute/fixed against the nearest positioned ancestor are all modelled, as are lengths in px, %, em, rem, vh/vw/vmin/vmax and the absolute units — plus calc(), min(), max() and clamp(). font-size and line-height inherit, so an em measures against the size an element actually has.

Flexbox works: both axes and both reverses, wrapping including wrap-reverse, justify-content, align-items, align-content, align-self, flex-grow/shrink/basis with the flex and flex-flow shorthands, gap, and order. Growing and shrinking respect min-* and max-*, including the automatic minimum that keeps an item from shrinking below its content.

Adjoining vertical margins collapse, including a first or last child's escaping its parent and an empty box collapsing through entirely; a border, padding, a definite height, overflow other than visible, a line box, a flex or grid container, and being a flex or grid item each stop it.

Grid works too: track lists with fr, auto, minmax() and repeat(auto-fill, …), named areas, implicit tracks, grid-auto-flow, placement by line number or span including negative lines, dense packing, gap, and the justify-*/align-* family for both the tracks and the items in them. auto margins work in flex containers, so margin-left: auto pushes.

align-items: baseline works, and inline-level boxes sit on the text baseline of their line rather than at the top of it.

Text is measured with real font metrics — per-glyph advance widths and kerning pairs for serif, sans-serif and monospace at both weights, which agree with Chrome to within a tenth of a percent. The same tables carry each font's vertical metrics, so line-height: normal is the font's own spacing and a line box puts its baseline where the font does, both matching Chrome exactly.

Screenshots read this same layout. captureHtml and createRenderer from very-happy-dom/screenshot used to run a separate engine that knew nothing of flexbox, grid or stylesheets; a screenshot and a getBoundingClientRect() of the same markup now agree.

What is not: a font outside those three families is measured as the generic one it falls back to, so a page in Inter is measured as Arial. Chrome quantises a line box to a half pixel and this does not, so a line height can differ by 0.1px at 16px. An item spanning several grid tracks does not contribute to their sizes, and named grid lines are not resolved. There is no user-agent stylesheet, so body starts at (0, 0) rather than a browser's 8px inset. Floats, transform, z-index ordering and intrinsic image sizes are absent. The compatibility guide has the full table.

scrollIntoViewIfNeeded() resolves and fires a scroll event; nothing scrolls on its own and no overflow is clipped, so there is no position to scroll to.

Setting Page Content

await page.setContent('<button>Save</button>')
await expect(page.getByRole('button')).toBeVisible()

A bare fragment becomes the body; a full <!DOCTYPE html><html><head>… string lands intact. It replaces the whole document rather than filling the body, fires domcontentloaded and load so an assertion straight afterwards needs no wait, and leaves the URL alone — setting content is not navigating.

Form Controls

await page.locator('#area').selectOption('north')            // by value
await page.locator('#area').selectOption({ label: 'North' }) // by option text
await page.locator('#photos').setInputFiles({ name: 'trail.gpx', buffer: gpx })
await page.locator('#photos').setInputFiles('./fixtures/trail.gpx')   // read from disk
await page.locator('#search').press('Control+Enter')
await page.locator('#search').clear()
await page.locator('#row').dblclick()
await page.locator('#terms').setChecked(true)

selectOption fires input then change, which is the point: assigning select.value by hand fires neither, so a component listening for it never updates and the test passes against a DOM the app never saw. setInputFiles builds the FileList that assigning input.files requires. Passing several values to a non-multiple control is refused rather than silently truncated. press focuses the element first — page.keyboard only reaches whatever already has focus.

Keyboard

await page.keyboard.press('Control+Enter')   // key 'Enter', ctrlKey true
await page.keyboard.press('Shift+a')         // types 'A'
await page.keyboard.down('Shift')
await page.keyboard.press('Tab')             // shiftKey true
await page.keyboard.up('Shift')

Control/Ctrl, Shift, Alt/Option and Meta/Cmd/Command are recognised, several at a time; modifiers go down in order and come up in reverse. event.code is populated (KeyA, Digit1, ArrowDown, ShiftLeft). A key held with Control, Alt or Meta does not insert text — Control+A selects rather than typing an "a" — while Shift produces the capital. Only a known modifier name is consumed, so press('Enter') and even a nonsense press('a+b') are passed through as literal keys.

Running Code Against a Locator

const ids = await page.locator('tbody tr').evaluateAll(els => els.map(el => el.dataset.id))
const label = await page.getByRole('button').evaluate(el => el.dataset.state)

await page.locator('li').allTextContents()   // raw textContent, per match
await page.locator('li').allInnerTexts()     // rendered text: hidden subtrees left out

evaluate() is strict and waits for the element to be attached; evaluateAll() is neither, because many matches is its expected case and none is a legitimate []. The element is the real node, so writing through it changes the document.

Callbacks are recompiled against the frame's window and cannot see their closure, the same as in Playwright — pass anything they need as the second argument. page.$eval / page.$$eval exist for ports, but take the first match rather than refusing an ambiguous selector.

Waiting on Page Events

const [response] = await Promise.all([
  page.waitForResponse('**/api/trails'),
  page.getByRole('button', { name: 'Search' }).click(),
])

expect((await response.json()).trails).toHaveLength(3)

Subscribe before the action, as above. An event that has already fired is not replayed — the same as Playwright, and it matters more here because goto() emits synchronously and returns, so there is no window at all.

  • page.waitForEvent(event, predicateOrOptions) — the primitive, for any of the six page events.
  • page.waitForResponse(urlOrPredicate) / waitForRequest(...) — a glob or RegExp matches the URL through the same matcher route() uses; a function receives the event itself, so it can read status too. Responses are reported for navigations and for fetch calls made by page code. Requests need interception on, which page.route() does as a side effect.
  • page.waitForLoadState('load' | 'domcontentloaded') — returns at once if the state has already been reached. networkidle throws rather than pretending: nothing here tracks in-flight requests.
  • page.waitForURL(urlOrPredicate) — polled, so a pushState counts.

Browser Context

import { Browser } from 'very-happy-dom'

const browser = new Browser()
const page = browser.newPage()

page.goto('https://example.com')

// Or an isolated context with its own storage and cookies
const context = browser.newIncognitoContext()
const isolatedPage = context.newPage()

await browser.close()

Request Interception

Interception is attached per page. Each request is finished with exactly one of continue(), respond() or abort().

import type { InterceptedRequest } from 'very-happy-dom'
import { Browser } from 'very-happy-dom'

const browser = new Browser()
const page = browser.newPage()

page.on('request', (request: InterceptedRequest) => {
  if (request.url.includes('/api/')) {
    request.respond({
      status: 200,
      body: JSON.stringify({ mocked: true }),
    })
    return
  }

  request.continue()
})

await page.setRequestInterception(true)

const response = await fetch('https://example.com/api/thing')
console.log(await response.text()) // {"mocked":true}

await browser.close()

Custom Window Configuration

import { Window } from 'very-happy-dom'

const window = new Window({
  url: 'https://example.com',
  width: 1920,
  height: 1080,
  settings: {
    navigator: {
      userAgent: 'MyCustomUserAgent/1.0'
    },
    device: {
      prefersColorScheme: 'dark'
    }
  }
})

Event Handling

const window = new Window()
const document = window.document

const button = document.createElement('button')
let clicked = false

button.addEventListener('click', () => {
  clicked = true
})

button.click()
console.log(clicked) // true

Storage APIs

const window = new Window()

// localStorage
window.localStorage.setItem('key', 'value')
console.log(window.localStorage.getItem('key')) // "value"

// sessionStorage
window.sessionStorage.setItem('session', 'data')

Observers

const window = new Window()
const document = window.document

// MutationObserver
const observer = new window.MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    console.log('DOM changed:', mutation.type)
  })
})

observer.observe(document.body, {
  childList: true,
  attributes: true,
  subtree: true
})

// IntersectionObserver
const io = new window.IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    console.log('Visibility changed:', entry.isIntersecting)
  })
})

// ResizeObserver
const ro = new window.ResizeObserver((entries) => {
  entries.forEach((entry) => {
    console.log('Size changed:', entry.contentRect)
  })
})

Canvas API

const window = new Window()
const document = window.document

const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

ctx.fillStyle = 'red'
ctx.fillRect(0, 0, 100, 100)
ctx.strokeStyle = 'blue'
ctx.strokeRect(10, 10, 80, 80)

// Export canvas data
const dataUrl = canvas.toDataURL()

// toBlob is callback-based, as in the browser
canvas.toBlob((blob) => {
  console.log(blob?.type) // "image/png"
})

// or use the promise-returning convenience wrapper
const blob = await canvas.toBlobAsync()

Performance

vs happy-dom vs jsdom

Operationvery-happy-domhappy-domjsdomFaster by
Window Creation4.08 µs92.83 µs1.22 ms22.7x
createElement463.02 ns2.62 µs4.67 µs5.7x
createElement + setAttribute748.35 ns15.41 µs6.62 µs8.8x
innerHTML (medium)41.61 µs47.48 µs168.98 µs1.1x
innerHTML (large, 200 nodes)1.92 ms3.72 ms6.27 ms1.9x
querySelector by ID81.03 nsn/a2.76 µs34.1x
querySelector by class242.20 nsn/a3.52 µs14.5x
querySelectorAll (200 matches)66.44 µsn/a66.55 µs~1x
querySelectorAll + iteration76.44 µsn/a170.37 µs2.2x
appendChild (single)1.70 µs4.58 µs6.14 µs2.7x
appendChild (1000 children)852.90 µs1.54 ms4.45 ms1.8x
setAttribute124.66 ns2.64 µs1.43 µs11.5x
getAttribute2.18 ns28.85 ns194.98 ns13.2x
classList.add3.97 µs6.88 µs4.87 µs1.2x
addEventListener + dispatch2.67 µs5.43 µs3.65 µs1.4x
textContent set470.48 ns1.72 µs4.67 µs3.7x
cloneNode (deep)6.16 µs21.59 µs15.55 µs2.5x
style.setProperty490.62 ns4.20 µs4.64 µs8.6x
Build data table (50x5)519.30 µs754.42 µs2.89 ms1.5x
Update list items (100)454.98 µsn/a2.41 ms5.3x

Note: "Faster by" compares very-happy-dom to the next-fastest result. Benchmarks run on Apple M3 Pro with Bun 1.3.11. Run them yourself:

bun run bench

Migration

From happy-dom

One-line change — the API is compatible:

// Before
import { Window } from 'happy-dom'

// After
import { Window } from 'very-happy-dom'

From jsdom

very-happy-dom ships a jsdom-compatible JSDOM class — the exact idiom works unchanged:

// Before (jsdom)
import { JSDOM } from 'jsdom'

// After (very-happy-dom) — one-line change
import { JSDOM } from 'very-happy-dom'
// or: import { JSDOM } from 'very-happy-dom/jsdom'

const dom = new JSDOM('<!DOCTYPE html><html><body><h1>Hi</h1></body></html>', {
  url: 'https://example.com/',
  runScripts: 'dangerously',
})
const { window } = dom
const { document } = window

dom.serialize()                       // full HTML string
dom.reconfigure({ url: '...' })       // change URL mid-test
JSDOM.fragment('<p>x</p>')            // DocumentFragment
await JSDOM.fromFile('./page.html')   // parse a local file
await JSDOM.fromURL('https://x.test') // fetch + parse

Full surface: JSDOM, VirtualConsole, CookieJar, ResourceLoader — each with the same method names and overloads as jsdom.

See the drop-in compatibility guide for the complete migration reference.

API Reference

Core Classes

  • Window - Main window/global object with all browser APIs
  • Document - DOM document with querySelector, createElement, etc.
  • Element - DOM elements with full manipulation API
  • Browser - Browser instance for advanced scenarios
  • BrowserContext - Isolated browser contexts
  • BrowserPage - Individual pages with navigation

Supported APIs

Click to expand full API list

DOM

  • Document, Element, TextNode, CommentNode, DocumentFragment
  • Attributes, ClassList (iterable, toggle(x, force), replace), Style, dataset
  • innerHTML, outerHTML (getter + setter), insertAdjacentHTML
  • document.readyState lifecycle (loading → interactive → complete) + DOMContentLoaded + load
  • document.cookie read/write, document.title live getter/setter, document.parentWindow alias

Selectors

  • querySelector / querySelectorAll, matches, closest
  • getElementById / getElementsByClassName / getElementsByTagName / getElementsByTagNameNS
  • Full CSS selectors — combinators, attribute selectors (quoted + unquoted), :not, :is, :where, :has, :nth-child, etc.
  • XPath (document.evaluate, XPathEvaluator, XPathResult)

Events

  • addEventListener / removeEventListener with { once, passive, capture, signal }
  • Bubbling, capturing, stopPropagation, stopImmediatePropagation
  • Full event classes: Event, CustomEvent, MouseEvent, KeyboardEvent, PointerEvent, TouchEvent, WheelEvent, InputEvent, FocusEvent, SubmitEvent, DragEvent, ClipboardEvent, AnimationEvent, TransitionEvent, CompositionEvent, ProgressEvent, MessageEvent, CloseEvent, StorageEvent, PopStateEvent, HashChangeEvent, ErrorEvent, MediaQueryListEvent
  • Focus model: focus/blur + bubbling focusin/focusout, document.activeElement tracking

Network

  • fetch(), Request, Response, Headers, FormData (with new FormData(form) populating from a form element)
  • XMLHttpRequest with full event handling
  • WebSocket (backed by Bun's native)
  • EventSource (Server-Sent Events) — real fetch + stream parsing
  • BroadcastChannel, MessageChannel, MessagePort
  • navigator.sendBeacon()
  • Request Interception via RequestInterceptor

Storage

  • localStorage, sessionStorage (isolated per instance)
  • document.cookie → CookieContainer
  • indexedDB — in-memory IDBFactory/IDBDatabase/IDBObjectStore/IDBTransaction
  • navigator.storage with estimate()/persist()/persisted()

Timers

  • setTimeout / clearTimeout, setInterval / clearInterval
  • requestAnimationFrame / cancelAnimationFrame
  • requestIdleCallback / cancelIdleCallback
  • queueMicrotask

Observers

  • MutationObserver (childList, attributes, characterData, subtree, oldValue, filters)
  • IntersectionObserver, ResizeObserver
  • PerformanceObserver with supportedEntryTypes

Canvas + Screenshots

  • HTMLCanvasElement.getContext('2d'), toDataURL/toBlob
  • CanvasRenderingContext2D with full drawing surface
  • Pure-JS rendering pipeline: ScreenshotCapture, captureHtml, captureUrl, compareImages, WebP/PNG encoders
  • Optional Bun.WebView-backed real-browser screenshots (useWebView: true)

Web Components + CSS

  • customElements.define/.get/.whenDefined, lifecycle callbacks (connected/disconnected/adopted/attributeChanged)
  • Shadow DOM (open + closed), event retargeting, slot support
  • CSSStyleSheet with replaceSync() parsing declarations into cssRules
  • document.adoptedStyleSheets
  • CSS.supports(), CSS.escape()
  • getComputedStyle() with per-tag display defaults + common computed fallbacks

Forms

  • Constraint validation: checkValidity, reportValidity, setCustomValidity, validity, validationMessage, willValidate
  • form.submit(), form.requestSubmit(submitter?), form.reset()
  • new FormData(form) populates from disabled/checkbox/radio/select/file fields

Media

  • HTMLMediaElement.play() → Promise, pause(), load(), canPlayType()
  • currentTime, duration, paused, ended, volume, muted, playbackRate, readyState, networkState
  • Dispatches play, playing, pause, timeupdate, volumechange, ratechange, loadstart, loadedmetadata
  • HTMLImageElement.decode(), Element.animate()

jsdom-compatible surface

  • JSDOM class with .window, .serialize(), .reconfigure(), .nodeLocation(), static fragment/fromURL/fromFile
  • VirtualConsole with on/off/emit/sendTo, jsdomError for uncaught exceptions
  • CookieJar (tough-cookie-style callback + promise API)
  • ResourceLoader (subclassable fetch interceptor)
  • runScripts: 'outside-only' | 'dangerously' — opt-in inline-script execution

happy-dom-compatible surface

  • Window with url, width, height, console, settings options
  • window.happyDOM with close/abort/waitUntilComplete/setURL/setViewport
  • GlobalRegistrator.register/unregister
  • /register subpath for one-line preload

Other

  • Performance API + PerformanceObserver
  • navigator.permissions.query(), navigator.sendBeacon()
  • Clipboard API + ClipboardItem
  • Geolocation API, Notification API
  • History API (pushState/replaceState/back/forward/go/state)
  • Location API (full href/protocol/host/hostname/port/pathname/search/hash/origin + assignment setters)
  • File API (File, FileReader, FileList, Blob)
  • URL, URLSearchParams, AbortController, AbortSignal
  • TextEncoder, TextDecoder, ReadableStream, WritableStream, TransformStream
  • DOMParser, XMLSerializer, Range, Selection, NodeIterator, TreeWalker

Testing

bun test                  # Run all tests
bun test --coverage      # Run with coverage

Contributing

We welcome contributions! Please see CONTRIBUTING for details.

Changelog

Please see our releases page for more information on what has changed recently.

Community

For help, discussion about best practices, or any other conversation that would benefit from being searchable:

Discussions on GitHub

For casual chit-chat with others using this package:

Join the Stacks Discord Server

Postcardware

Very Happy DOM is free and open-source, but we'd love to receive a postcard from you! Send one to:

Stacks.js, 12665 Village Ln #2306, Playa Vista, CA 90094, United States 🌎

We showcase postcards from around the world on our website!

Sponsors

We would like to extend our thanks to the following sponsors for funding Stacks development. If you are interested in becoming a sponsor, please reach out to us.

License

The MIT License (MIT). Please see LICENSE for more information.

Made with 💙

[codecov-href]: https://codecov.io/gh/stacksjs/very-happy-dom -->