Javascript

  • Why I use Node as the default testing environment instead of JSDOM

    A while ago I was updating the Node version from 18 to 20 on Playground.

    This felt like a good time to review our Node polyfills and I found that we were pollyfilling URL.canParse because it wasn’t available in Node 18. But, when I removed the URL.canParse polyfill, unit tests started failing.

    JSDOM removes some Node features without replacing them

    After some research I found out that JSDOM creates an environment that overrides some existing Node.js globals with its polyfills.
    One of the globals it replaces is URL and it replaces it with whatwg-url.

    As a result, when we run tests with JSDOM they might fail because the JSDOM environment isn’t fully compatible with Node.

    Example of JSDOM removing Node features

    If you run this code in Node 20 which supports URL.canParse and JSDOM 22 or older, URL.canParse will be undefined because JSDOM added support for it in version 23.

    This was the point when I decided to avoid JSDOM. Instead of reusing existing, working Node features, JSDOM removes them and polyfills only some features.

    const jsdom = require("jsdom");
    const { JSDOM } = jsdom;
    
    const myDom = new JSDOM(`<body>
      <div id="content"></div>
      <script>document.getElementById("content").innerHTML = typeof URL.canParse;</script>
    </body>`, { runScripts: "dangerously" });
    
    console.log(myDom.window.document.body.innerHTML);

    Node is a better default test environment than JSDOM

    Because most Playground packages are Node compatible, we decided to use Node as the environment for running tests.

    Now looking at it, there wasn’t any need to use JSDOM to test Node compatible packages in the first place, but at least I learned something.

    Some tests still need Web API support

    Using Node worked for all packages, except the web version of Playground because these packages depend on Web APIs, and still need to run in a Web-like environment.

    Vitest lists only two alternative web environments, happy-dom, it’s considered to be faster than JSDOM, but lacks some APIs and Browser Mode which is still experimental.

    We decided to keep using JSDOM for the packages that require Web APIs.

    JSDOM is a emulator and isn’t fully compatible with Web APIs

    These days I still use JSDOM when I need Web API support in test, but I keep a few thing in mind.

    • JSDOM is just a web emulator that sits on top of the Node environment.
    • JSDOM isn’t fully compatible with Web APIs, and polyfill may be necessary.
    • Some functions are different between environments, so it’s possible to get a different response from a function call in Node and JSDOM.
    • JSDOM deliberately removes Node features even if it doesn’t support these features.

  • Don’t use URL to construct paths in Node.js

    I’ve been spending some of my time recently making sure WordPress Playground works in Windows.
    While working on Windows fixes I learned a lot about making sure Javascript code works POSIX and Windows when running it in Node.js.

    One thing that I learned is that you shouldn’t use URL to construct paths.

    If you construct a path using new URL(import.meta.url) in Node.js and use pathname to to obtain the path, it will always have a leading slash in front of the path.
    This is ok for POSIX but in Windows it results in invalid paths
    like /C:/Users/.

    What to use instead of URL

    You can use fileURLToPath from the URL package, it will correctly construct both POSIX and Windows paths from file:/// paths.

    Polyfilling __dirname and __filename in Node 20+

    In Node 20 and above you can use import.meta.filename and import.meta.dirname to polyfill __dirname and __filename.

    Example

    import { fileURLToPath } from 'url';
    import path from 'path';
    
    const windowsImportMetaUrl = "file:///C:/Users/me/Downloads/index.js";
    console.log('windowsImportMetaUrl', windowsImportMetaUrl);
    
    let __dirname = new URL('.', windowsImportMetaUrl).pathname;
    let __filename = new URL(windowsImportMetaUrl).pathname;
    console.log('__filename', __filename);
    console.log('__dirname', __dirname);
    
    
    __filename = fileURLToPath(
        windowsImportMetaUrl,
        {
            windows: true,
        }
    );
    __dirname = path.win32.dirname(__filename);
    console.log('__filename', __filename);
    console.log('__dirname', __dirname);
    
    __filename = import.meta.filename;
    __dirname = import.meta.dirname;
    console.log('__filename', __filename);
    console.log('__dirname', __dirname);

    The above code will result in:

    windowsImportMetaUrl file:///C:/Users/me/Downloads/index.js
    
    # new URL output 
    __filename /C:/Users/me/Downloads/index.js
    __dirname /C:/Users/me/Downloads/
    
    
    # fileURLToPath output
    __filename C:\Users\me\Downloads\index.js
    __dirname C:\Users\me\Downloads
    
    
    # import.meta.filename and import.meta.dirname output
    __filename C:\Users\me\Downloads\index.js
    __dirname C:\Users\me\Downloads