web dev & more!

Ignoring JSDOM Errors in Vitest

The Problem

I recently found myself dealing with JSDOM outputting errors which cluttered the output of my tests. It was doing this because JSDOM is a “pure-JavaScript implementation of many web standards” and definitely not an implementation of all web standards. Nor should it be; that’s called a web browser. Instead, JSDOM is “good enough” for the job. In this case, the job is rendering a DOM for my Vitest unit tests.

It’s easy to use JSDOM in conjunction with Vitest to test whether elements are visible or not within the DOM. Vitest even recommends it for testing web applications so it’s fairly simple to get started with. Once installed (npm install -D jsdom), one can simply tell Vitest to use jsdom for the environment (by setting test.environment: 'jsdom') and go about testing components.

I was happily rendering Svelte components and testing the logic the applied to the DOM when JSDOM came across some styles that it didn’t understand or know how to process. It proceeded to dump errors like so:

stderr | VirtualConsole.<anonymous> (/home/PROJECT/node_modules/jsdom/lib/jsdom/virtual-console.js:29:45)
Error: Could not parse CSS stylesheet
    at exports.createStylesheet (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/helpers/stylesheets.js:37:21)
    at HTMLStyleElementImpl._updateAStyleBlock (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLStyleElement-impl.js:68:5)
    at HTMLStyleElementImpl._attach (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/HTMLStyleElement-impl.js:19:12)
    at HTMLHeadElementImpl._insert (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/Node-impl.js:837:14)
    at HTMLHeadElementImpl._preInsert (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/Node-impl.js:758:10)
    at HTMLHeadElementImpl._append (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/Node-impl.js:864:17)
    at HTMLHeadElementImpl.appendChild (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/nodes/Node-impl.js:600:17)
    at HTMLHeadElement.appendChild (/home/PROJECT/node_modules/jsdom/lib/jsdom/living/generated/Node.js:411:60)
    at Object.updateStyle (file:///home/PROJECT/node_modules/vitest/dist/vendor/execute.edwByI27.js:460:32)
    at /home/PROJECT/src/app.css:4:1 /*

This output would then be followed by the entire, unminified CSS included in the project. Now I don’t know about you but I like being able to scroll back in my terminal and view output that is actually useful. Fortunately for me, I was practicing Test-Driven Development (TDD) so I could easy isolate the change that caused this error: I had added a Tailwind class that utilized the CSS content property. In this case, the fact that JSDOM didn’t support the CSS content property didn’t really matter as it didn’t affect my tests. Instead, I could simply hide the errors being output from JSDOM. But how would I do that?

Ignoring the Problem

Simply ignoring the errors output from JSDOM proved to be more difficult than I had anticipated. Because I was unfamiliar with exactly how JSDOM worked in conjunction with my test suite, I wasn’t even sure where to put the hacks and recommended fixes I found in relevant GitHub issue threads.

In essence, the recommendations boiled down to this:

  • Overwrite console.error and simply drop any output that contains the string ”_Could not parse CSS stylesheet_
  • Overwrite JSDOM’s VirtualConsole error method and replace it with my own
  • Mock JSDOM’s VirtualConsole error method and filter any output or return nothing
  • Pass a new [VirtualConsole](https://github.com/jsdom/jsdom?tab=readme-ov-file#virtual-consoles) with arguments to suppress errors from JSDOM

While some of these solutions seemed simple enough, simply figuring out where to use them proved challenging. How would I go about overwriting these methods for my entire test environment? Of all of these solutions, I settled on the last one. After all, it’s how the official JSDOM documentation recommends addressing this issue. The tricky part was figuring out how to pass a new VirtualConsole object to JSDOM from Vitest.

A New VirtualConsole

It took me longer than I care to publicly admit but I eventually learned that various options can be configured for JSDOM (in vite.config.js) by passing them through test.environmentOptions. However, it wasn’t until I looked through the source code for Vitest’s JSDOM environment integration that I realized I could likely just pass a new VirtualConsole to it like so:

import { VirtualConsole } from 'jsdom';

const config = {
  test: {
    ....
    environment: 'jsdom',
    environmentOptions: {
      jsdom: {
        console: true,
        virtualConsole: new VirtualConsole.sendTo(console, {omitJSDOMErrors: true}),
      }
    },
  ...
}

Because the JSDOM Vitest integration uses a rest parameter to spread remaining operators through destructuring assignments, I assumed I could overwrite whatever options were being set in the integration itself. But I quickly ran into an issue:

DataCloneError: () => {
      // If "error" event has no listeners,
      // EventEmitter throws an exception
    } could not be cloned.
 ❯ new DOMException node:internal/per_context/domexception:53:5
 ❯ WorkerInfo.postTask node_modules/tinypool/dist/esm/index.js:582:17
 ❯ ThreadPool.runTask node_modules/tinypool/dist/esm/index.js:874:16
 ❯ Tinypool.run node_modules/tinypool/dist/esm/index.js:954:38
 ❯ runFiles node_modules/vitest/dist/vendor/node.p6h5JSuL.js:3417:20
 ❯ node_modules/vitest/dist/vendor/node.p6h5JSuL.js:3459:98
 ❯ Object.runTests node_modules/vitest/dist/vendor/node.p6h5JSuL.js:3459:58
 ❯ Object.runTests node_modules/vitest/dist/vendor/node.p6h5JSuL.js:4576:5

This error seems to stem from the fact that the VirtualConsole object received by JSDOM was empty and lacked the events it required to function properly. But even after creating each of the events and passing this new object, the same error would persist.

My understanding is limited but I think this approach failed because the Vite config object must be serializable. When attempting to serialize an object with JSON.stringify(), any methods that are attached to said object will simply be dropped. Hence, why even setting the methods on the object before passing it fails. You can confirm this right now by copy/pasting the following code into your browser’s developer console:

This code will create a simple object with the properties a, b, c, and d. The only property that will not be shown after the JSON.stringify() is d because that property is actually a function. Knowing this, we can move on to the next logical step for resolving the issue of JSDOM errors cluttering up our testing output.

const x = {
  a: 'abcdefg',
  b: 123456789,
  c: true,
  d: () => true
}

console.log(x.a)  // 'abcdefg'
console.log(x.b)  // 123456789
console.log(x.c)  // true
console.log(x.d)  // true

console.log(JSON.stringify(x));

Rolling a Custom Environment

Instead of using the default JSDOM integration provided by Vitest, it’s possible to create a completely custom one which has all options and configurations necessary but still uses JSDOM. It may seem intimidating, but it’s actually quite simple. Especially since we already have all of the source code we need freely available, thanks to the JSDOM integration provided by the Vitest developers. We can take that code, modify it, and point our Vite config test.environment property to that file’s path instead of at JSDOM. We only need to make a few minor adjustments, which will look like this:

src/vitest/custom-jsdom.ts

import type { Environment } from 'vitest';
import { populateGlobal } from 'vitest/environments';

function catchWindowErrors(window: Window) {
  let userErrorListenerCount = 0;
  function throwUnhandlerError(e: ErrorEvent) {
    if (userErrorListenerCount === 0 && e.error != null) process.emit('uncaughtException', e.error);
    }
  const addEventListener = window.addEventListener.bind(window);
  const removeEventListener = window.removeEventListener.bind(window);
  window.addEventListener('error', throwUnhandlerError);
  window.addEventListener = function (...args: Parameters<typeof addEventListener>) {
    if (args[0] === 'error') userErrorListenerCount++;
      return addEventListener.apply(this, args);
    };
  window.removeEventListener = function (...args: Parameters<typeof removeEventListener>) {
    if (args[0] === 'error' && userErrorListenerCount) userErrorListenerCount--;
      return removeEventListener.apply(this, args);
    };
    return function clearErrorHandlers() {
      window.removeEventListener('error', throwUnhandlerError);
    };
}

export default <Environment>{
  name: 'jsdom',
  transformMode: 'web',
  async setup(global, { jsdom = {} }) {
    const { CookieJar, JSDOM, ResourceLoader, VirtualConsole } = await import('jsdom');
    const {
      html = '<!DOCTYPE html>',
      userAgent,
      url = 'http://localhost:3000',
      contentType = 'text/html',
      pretendToBeVisual = true,
      includeNodeLocations = false,
      runScripts = 'dangerously',
      resources,
      console = false,
      cookieJar = false,
      ...restOptions
    } = jsdom as any;

  const dom = new JSDOM(html, {
    pretendToBeVisual,
    resources: resources ?? (userAgent ? new ResourceLoader({ userAgent }) : undefined),
    runScripts,
    url,
    virtualConsole: 
      console && global.console
      ? new VirtualConsole().sendTo(global.console, { omitJSDOMErrors: true })
      : undefined,
    cookieJar: cookieJar ? new CookieJar() : undefined,
    includeNodeLocations,
    contentType,
    userAgent,
    ...restOptions
});

  const { keys, originals } = populateGlobal(global, dom.window, { bindFunctions: true });

  const clearWindowErrors = catchWindowErrors(global);

  global.jsdom = dom;

  return {
    teardown(global) {
      clearWindowErrors();
      dom.window.close();
      delete global.jsdom;
      keys.forEach((key) => delete global[key]);
      originals.forEach((v, k) => (global[k] = v));
    }
  };
 }
};

This file differs from the original Vitest JSDOM integration very little. The only changes are the imports at the top (which resolve nearly all the TS errors), the removal of the setupVM method (only required if you’ve set test.pool to 'vmThreads' or 'vmForks' within your Vitest configuration), and the addition of the arguments passed to VirtualConsole via the the .sendTo() method ({ omitJSDOMErrors: true }).

Once this file has been created, it can be used immediately by setting test.environment to the path which in this case is ./src/vitest/custom-jsdom. After setting up this custom environment, JSDOM errors should no longer be shown in the test output. Of course, this solution doesn’t actually make the problem go away. While it will hide the errors from JSDOM, JSDOM will continue experiencing those errors, obviously.

Note: If JSDOM doesn't support a feature, hiding the errors produced by it will not make that feature magically work.

What Next?

Ideally, one would be able to pass options to override the VirtualConsole set in the Vitest’s JSDOM integration, likely through another set of parameters (since the object itself cannot be passed) that can then be passed to the VirtualConsole constructor to manipulate JSDOM 🤔.

As of now, there isn’t a way to do that. I suppose someone will have to create an issue in the repository, fork the repo, and submit a pull request to address this. I know, this is how open source works so I want it to be clear that I’m not complaining. I’ll put in my best effort to be the person who makes it happen.