Skip to content

@hapi/lab crashes when TypeScript tests depend on ESM packages #1086

@setthase

Description

@setthase

Runtime

node.js

Runtime version

24.13.1 (any version >= 22.12.0 with unflagged require(esm))

Module version

26.0.0

Last module version without issue

N/A — the bug has existed since addTsEsmHook was introduced, but only manifests on Node 22.12+ where require(esm) is unflagged.

Used with

standalone (reproducible with minimal setup)

Any other relevant information

Affects projects using lab --typescript whose package.json has "type": "module" and that depend on an ESM package which itself has CJS sub-dependencies. The bug triggers when Node resolves a CJS sub-dependency of an ESM module during loadESMFromCJS, calling Module._resolveFilename with parent set to undefined.


What are you trying to achieve or the steps to reproduce?

Run TypeScript tests with lab --typescript in an ESM project ("type": "module") that depends on an ESM package which has its own CJS dependencies.

Trigger conditions

All four must be true:

  1. The project has "type": "module" in its package.json (so addTsEsmHook is activated).
  2. Lab is invoked with --typescript (or typescript: true in .labrc).
  3. Test code (transitively) imports from an ESM package (a dependency with "type": "module").
  4. That ESM dependency itself imports a CJS sub-dependency whose specifier ends in .js.

Condition 4 is the non-obvious one: a leaf ESM package with zero dependencies does not trigger the bug. The crash only occurs when Node resolves a .js request during the ESM→CJS boundary transition inside loadESMFromCJS, at which point Module._resolveFilename is called with parent === undefined.

Minimal reproduction

Create a directory with the following structure:

lab-esm-repro/
├── package.json
├── tsconfig.json
├── src/
│   └── math.ts
├── test/
│   └── math.ts
└── node_modules/
    ├── esm-dep/
    │   ├── package.json
    │   └── index.js
    └── cjs-helper/
        ├── package.json
        └── lib/
            └── utils.js

package.json

{
  "name": "lab-esm-repro",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "lab test --typescript"
  },
  "devDependencies": {
    "@hapi/lab": "^26.0.0",
    "@hapi/code": "^9.0.3",
    "typescript": "^5.6.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": ".",
    "strict": true
  }
}

src/math.ts

import { add } from 'esm-dep';

export function addOne(n: number): number {
    return add(n, 1);
}

test/math.ts

import * as Lab from '@hapi/lab';
import { expect } from '@hapi/code';
import { addOne } from '../src/math.js';

export const lab = Lab.script();
const { describe, it } = lab;

describe('addOne', () => {
    it('adds 1 to a number', () => {
        expect(addOne(4)).to.equal(5);
    });
});

node_modules/esm-dep/package.json

{
  "name": "esm-dep",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": "./index.js"
  },
  "dependencies": {
    "cjs-helper": "1.0.0"
  }
}

node_modules/esm-dep/index.js — ESM module that imports a CJS sub-dependency

import { double } from 'cjs-helper/lib/utils.js';

export function add(a, b) {
    return double(a) - a + b;
}

node_modules/cjs-helper/package.json — plain CJS package

{
  "name": "cjs-helper",
  "version": "1.0.0"
}

node_modules/cjs-helper/lib/utils.js

'use strict';

exports.double = function (n) {
    return n * 2;
};

Then run:

npm install
npx lab test --typescript

Why the CJS sub-dependency matters

Lab's addTsEsmHook patches Module._resolveFilename globally. When lab loads a .ts test file via require.extensions['.ts']Module._compile, the compiled code require()s esm-dep. Because esm-dep is ESM, Node delegates to loadESMFromCJS → ESM translators. Inside the ESM module graph, when esm-dep/index.js imports cjs-helper/lib/utils.js, Node calls Module._resolveFilename to resolve the CJS module — but in this ESM-to-CJS transition context, parent is undefined. The patched hook does not guard against this and crashes.

A leaf ESM package with no imports does not trigger this path because Module._resolveFilename is never called within the ESM graph for its own dependencies.


What was the result you got?

TypeError: Cannot read properties of undefined (reading 'filename')
    at Module._resolveFilename (/path/to/node_modules/@hapi/lab/lib/cli.js:92:48)
    at defaultResolveImpl (node:internal/modules/cjs/loader:1059:19)
    at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1064:22)
    at Module._load (node:internal/modules/cjs/loader:1234:25)
    at loadCJSModuleWithModuleLoad (node:internal/modules/esm/translators:339:3)
    at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:242:7)
    at ModuleJobSync.runSync (node:internal/modules/esm/module_job:534:37)
    at ModuleLoader.importSyncForRequire (node:internal/modules/esm/loader:388:47)
    at loadESMFromCJS (node:internal/modules/cjs/loader:1621:24)
    at Module._compile (node:internal/modules/cjs/loader:1786:5)
    at Object.require.extensions.<computed> [as .js] (.../lab/lib/modules/transform.js:41:28)

Lab crashes when Node resolves the CJS sub-dependency of the ESM package.


What result did you expect?

The test should run and pass:

  addOne
    ✔ 1) adds 1 to a number (1 ms)

1 tests complete

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugBug or defect

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions