Search code examples
node.jsjestjses6-modules

How can I bypass Jest's hooking of Node's module loader?


The following code:

if (!globalThis.test) {
  globalThis.test = function(_, f) {
    f();
  }
}

test("Foo", async () => {
  for (let i = 1; i < 10; ++i) {
    await import(`data:text/javascript,console.log(%22In%20import${i}%22)`);
  }
});

runs directly in node, with the following output:

In import1
In import2
In import3
In import4
In import5
In import6
In import7
In import8
In import9

However, to run this under Jest requires the use of experimental options. (NODE_OPTIONS=--experimental_vm_modules npx jest test.js) Furthermore, it's not even guaranteed to work because of https://github.com/nodejs/node/issues/35889 ; many times it will work fine, but (especially in a more complicated project) it will segfault pretty reliably. In fact, if you edit the loop to run to 100000 instead of 10, it will run fine in Node but crash in Jest around iteration 5000.

The issue is that Jest is hooking Node's module loading, in a way I don't fully understand, in order to allow for code transformation, mocking, etc. However, this also opens the door to that bug, specifically for ES modules (also for reasons I don't fully understand).

Is there any way I can bypass the hooking and use Node's ESMLoader directly? For the purpose of my test I literally want to import data URLs, so there's no mocking or transpiling funny business needed (or wanted).


Solution

  • In order to bypass Jest enough to import data URIs without crashing, I needed a couple of pieces: A custom Jest Environment, and a hook in my production code to let me override the import() call.

    jest.config.js

    module.exports = {
      testEnvironment: "./FixJSDOMEnvironment.ts",
      // Other options unchanged
    }
    

    FixJSDOMEnvironment.ts

    import JSDOMEnvironment from "jest-environment-jsdom";
    
    // https://github.com/facebook/jest/blob/v29.4.3/website/versioned_docs/version-29.4/Configuration.md#testenvironment-string
    export default class FixJSDOMEnvironment extends JSDOMEnvironment {
      constructor(...args: ConstructorParameters<typeof JSDOMEnvironment>) {
        super(...args);
    
        // FIXME https://github.com/nodejs/node/issues/35889
        // Add missing importActual() function to mirror requireActual(), which lets us work around the ESM bug.
        // Wrap the construction of the function in eval, so that transpilers don't touch the import() call.
        this.global.importActual = eval("url => import(url)");
      }
    }
    

    productionCode.ts

    // Webpack likes to turn the import into a require, which sort of
    // but not really behaves like import. So we use a "magic comment"
    // to disable that and leave it as a dynamic import.
    //
    // However, we need to be able to replace this implementation in tests. Ideally
    // it would be fine, but Jest causes segfaults when using dynamic import: see
    // https://github.com/nodejs/node/issues/35889 and
    // https://github.com/facebook/jest/issues/11438
    // import() is not a function, so it can't be replaced. We need this separate
    // config object to provide a hook point.
    export const config = {
      doImport(url: string): Promise<MyModule> {
        return import(/*webpackIgnore:true*/ url);
      },
    };
    
    // Real code follows, including use of doImport
    

    ImportTest.ts

    import { config } from "productionCode";
    
    // Critical: We have to overwrite this, otherwise we get Jest's hooked
    // implementation, which will not work without passing special flags to Node,
    // and tends to crash even if you do.
    config.doImport = importActual;
    
    test("Thing that uses import", () => {
      // Possibly mock other functions here so that productionCode imports a data URI
    });