From ca5e4f9b2b5ec62b62910e197b3ceccb2965b46c Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 22 May 2026 01:59:35 +0300 Subject: [PATCH] fix: read ambigous TS files with type-stripping enabled with require first --- .eslintignore | 1 + .github/workflows/module-loading.yml | 29 +++++ package.json | 3 +- src/test-reader/mocha-reader/index.js | 66 ++++++++++- test/module-loading/index.js | 44 ++++++++ .../test-projects/js-cjs/package.json | 4 + .../test-projects/js-cjs/testplane.config.cjs | 11 ++ .../test-projects/js-cjs/tests/basic.test.js | 8 ++ .../js-cjs/tests/helpers/value.js | 5 + .../test-projects/js-esm/package.json | 5 + .../test-projects/js-esm/testplane.config.cjs | 11 ++ .../test-projects/js-esm/tests/basic.test.js | 6 + .../js-esm/tests/helpers/value.js | 1 + .../test-projects/ts-cjs/package.json | 5 + .../test-projects/ts-cjs/testplane.config.cjs | 11 ++ .../test-projects/ts-cjs/tests/basic.test.ts | 7 ++ .../ts-cjs/tests/helpers/value.js | 5 + .../test-projects/ts-esm/package.json | 5 + .../test-projects/ts-esm/testplane.config.cjs | 11 ++ .../test-projects/ts-esm/tests/basic.test.ts | 6 + .../ts-esm/tests/helpers/value.ts | 1 + test/src/test-reader/mocha-reader/index.js | 103 ++++++++++++++++++ test/tsconfig.json | 2 +- 23 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/module-loading.yml create mode 100644 test/module-loading/index.js create mode 100644 test/module-loading/test-projects/js-cjs/package.json create mode 100644 test/module-loading/test-projects/js-cjs/testplane.config.cjs create mode 100644 test/module-loading/test-projects/js-cjs/tests/basic.test.js create mode 100644 test/module-loading/test-projects/js-cjs/tests/helpers/value.js create mode 100644 test/module-loading/test-projects/js-esm/package.json create mode 100644 test/module-loading/test-projects/js-esm/testplane.config.cjs create mode 100644 test/module-loading/test-projects/js-esm/tests/basic.test.js create mode 100644 test/module-loading/test-projects/js-esm/tests/helpers/value.js create mode 100644 test/module-loading/test-projects/ts-cjs/package.json create mode 100644 test/module-loading/test-projects/ts-cjs/testplane.config.cjs create mode 100644 test/module-loading/test-projects/ts-cjs/tests/basic.test.ts create mode 100644 test/module-loading/test-projects/ts-cjs/tests/helpers/value.js create mode 100644 test/module-loading/test-projects/ts-esm/package.json create mode 100644 test/module-loading/test-projects/ts-esm/testplane.config.cjs create mode 100644 test/module-loading/test-projects/ts-esm/tests/basic.test.ts create mode 100644 test/module-loading/test-projects/ts-esm/tests/helpers/value.ts diff --git a/.eslintignore b/.eslintignore index c7d3ca923..4b97ef8f1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -6,4 +6,5 @@ bundle.compat.js bundle.native.js wt test/e2e/report +test/module-loading/test-projects tmp diff --git a/.github/workflows/module-loading.yml b/.github/workflows/module-loading.yml new file mode 100644 index 000000000..302e2ede7 --- /dev/null +++ b/.github/workflows/module-loading.yml @@ -0,0 +1,29 @@ +name: Module Loading Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + module-loading: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build project + run: npm run build + + - name: Run module loading tests + run: npm run test-module-loading diff --git a/package.json b/package.json index 8ddb76ec9..6be24bc44 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "lint": "eslint --cache . && prettier --check .", "reformat": "eslint --fix . && prettier --write .", "prettier-watch": "onchange '**' --exclude-path .prettierignore -- prettier --write {{changed}}", - "test-unit": "_mocha \"test/!(integration)/**/*.js\"", + "test-unit": "_mocha \"test/!(integration|module-loading)/**/*.js\"", + "test-module-loading": "mocha test/module-loading/index.js", "test": "npm run test-unit && npm run check-types && npm run lint", "test-integration": "mocha -r ts-node/register -r test/integration/*/**", "toc": "doctoc docs --title '### Contents'", diff --git a/src/test-reader/mocha-reader/index.js b/src/test-reader/mocha-reader/index.js index 50cc3cfd6..3a4a9a04c 100644 --- a/src/test-reader/mocha-reader/index.js +++ b/src/test-reader/mocha-reader/index.js @@ -1,7 +1,9 @@ "use strict"; +const fs = require("fs"); const _ = require("lodash"); const Mocha = require("mocha"); +const path = require("path"); const { MochaEventBus } = require("./mocha-event-bus"); const { TreeBuilderDecorator } = require("./tree-builder-decorator"); @@ -11,6 +13,8 @@ const { getMethodsByInterface } = require("./utils"); const logger = require("../../utils/logger"); const { enableSourceMaps } = require("../../utils/typescript"); +const AMBIGUOUS_TYPESCRIPT_EXTENSIONS = new Set([".ts", ".tsx"]); + function getTagParser(original) { return function (title, paramsOrFn, fn) { if (typeof paramsOrFn === "function") { @@ -59,7 +63,7 @@ async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts, files.forEach(f => mocha.addFile(f)); try { - await mocha.loadFilesAsync({ esmDecorator }); + await loadMochaFiles(mocha, files, { esmDecorator }); } catch (err) { const errorMessage = (err.message || "").split("\n")[0].trim(); @@ -77,6 +81,66 @@ async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts, applyOnly(mocha.suite, eventBus); } +async function loadMochaFiles(mocha, files, { esmDecorator }) { + if (["true", "1"].includes(process.env.TESTPLANE_LOAD_FILES_ASYNC) || !files.some(shouldLoadWithRequire)) { + await mocha.loadFilesAsync({ esmDecorator }); + return; + } + + const originalFiles = mocha.files; + mocha.lazyLoadFiles(true); + + try { + for (const file of files) { + mocha.files = [file]; + + if (!shouldLoadWithRequire(file)) { + await mocha.loadFilesAsync({ esmDecorator }); + continue; + } + + try { + mocha.loadFiles(); + } catch (err) { + if (err.code !== "ERR_REQUIRE_ESM" && err.code !== "ERR_REQUIRE_ASYNC_MODULE") { + throw err; + } + + await mocha.loadFilesAsync({ esmDecorator }); + } + } + } finally { + mocha.files = originalFiles; + } +} + +function shouldLoadWithRequire(file) { + if (!process.features?.typescript || !AMBIGUOUS_TYPESCRIPT_EXTENSIONS.has(path.extname(file))) { + return false; + } + + // Node.js 24 can import .ts files directly and warn before our require hook transpiles them. + return !isInsideEsmPackage(file); +} + +function isInsideEsmPackage(file) { + let currentDir = path.dirname(path.resolve(file)); + let parentDir; + + while (currentDir !== parentDir) { + const packageJsonPath = path.join(currentDir, "package.json"); + + if (fs.existsSync(packageJsonPath)) { + return JSON.parse(fs.readFileSync(packageJsonPath, "utf8")).type === "module"; + } + + parentDir = currentDir; + currentDir = path.dirname(currentDir); + } + + return false; +} + function initBuildContext(outBus) { outBus.emit(TestReaderEvents.NEW_BUILD_INSTRUCTION, ctx => { ctx.treeBuilder = TreeBuilderDecorator.create(ctx.treeBuilder); diff --git a/test/module-loading/index.js b/test/module-loading/index.js new file mode 100644 index 000000000..361a56e89 --- /dev/null +++ b/test/module-loading/index.js @@ -0,0 +1,44 @@ +"use strict"; + +const path = require("path"); +const execa = require("execa"); + +const TESTPLANE_BIN = path.resolve(__dirname, "../../bin/testplane"); +const PROJECTS_DIR = path.resolve(__dirname, "test-projects"); +const MODULE_TYPE_WARNING = "MODULE_TYPELESS_PACKAGE_JSON"; + +const projects = [ + ["js-cjs", "js-cjs test"], + ["js-esm", "js-esm test"], + ["ts-cjs", "ts-cjs test"], + ["ts-esm", "ts-esm test"], +]; + +describe("module loading", () => { + projects.forEach(([projectName, testTitle]) => { + it(`reads ${projectName}`, async () => { + const { stdout, stderr } = await readTests(projectName); + const tests = JSON.parse(stdout); + + assert.lengthOf(tests, 1); + assert.deepEqual(tests[0].titlePath, [projectName, testTitle]); + assert.deepEqual(tests[0].browserIds, ["chrome"]); + assert.isFalse(tests[0].pending); + assert.notInclude(stderr, MODULE_TYPE_WARNING); + }); + }); +}); + +function readTests(projectName) { + return execa( + process.execPath, + [TESTPLANE_BIN, "list-tests", "-c", "testplane.config.cjs", "tests", "--formatter", "list"], + { + cwd: path.join(PROJECTS_DIR, projectName), + env: { + ...process.env, + NODE_OPTIONS: [process.env.NODE_OPTIONS, "--trace-warnings"].filter(Boolean).join(" "), + }, + }, + ); +} diff --git a/test/module-loading/test-projects/js-cjs/package.json b/test/module-loading/test-projects/js-cjs/package.json new file mode 100644 index 000000000..717f6c3e2 --- /dev/null +++ b/test/module-loading/test-projects/js-cjs/package.json @@ -0,0 +1,4 @@ +{ + "name": "module-loading-js-cjs", + "private": true +} diff --git a/test/module-loading/test-projects/js-cjs/testplane.config.cjs b/test/module-loading/test-projects/js-cjs/testplane.config.cjs new file mode 100644 index 000000000..c71030b9f --- /dev/null +++ b/test/module-loading/test-projects/js-cjs/testplane.config.cjs @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + }, + }, +}; diff --git a/test/module-loading/test-projects/js-cjs/tests/basic.test.js b/test/module-loading/test-projects/js-cjs/tests/basic.test.js new file mode 100644 index 000000000..ee49ff265 --- /dev/null +++ b/test/module-loading/test-projects/js-cjs/tests/basic.test.js @@ -0,0 +1,8 @@ +"use strict"; + +const assert = require("node:assert"); +const { value } = require("./helpers/value"); + +describe("js-cjs", () => { + it("js-cjs test", () => assert.equal(value, "js-cjs-value")); +}); diff --git a/test/module-loading/test-projects/js-cjs/tests/helpers/value.js b/test/module-loading/test-projects/js-cjs/tests/helpers/value.js new file mode 100644 index 000000000..95a57d99c --- /dev/null +++ b/test/module-loading/test-projects/js-cjs/tests/helpers/value.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + value: "js-cjs-value", +}; diff --git a/test/module-loading/test-projects/js-esm/package.json b/test/module-loading/test-projects/js-esm/package.json new file mode 100644 index 000000000..eb013cda9 --- /dev/null +++ b/test/module-loading/test-projects/js-esm/package.json @@ -0,0 +1,5 @@ +{ + "name": "module-loading-js-esm", + "private": true, + "type": "module" +} diff --git a/test/module-loading/test-projects/js-esm/testplane.config.cjs b/test/module-loading/test-projects/js-esm/testplane.config.cjs new file mode 100644 index 000000000..c71030b9f --- /dev/null +++ b/test/module-loading/test-projects/js-esm/testplane.config.cjs @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + }, + }, +}; diff --git a/test/module-loading/test-projects/js-esm/tests/basic.test.js b/test/module-loading/test-projects/js-esm/tests/basic.test.js new file mode 100644 index 000000000..e6eb82e5b --- /dev/null +++ b/test/module-loading/test-projects/js-esm/tests/basic.test.js @@ -0,0 +1,6 @@ +import assert from "node:assert"; +import { value } from "./helpers/value.js"; + +describe("js-esm", () => { + it("js-esm test", () => assert.equal(value, "js-esm-value")); +}); diff --git a/test/module-loading/test-projects/js-esm/tests/helpers/value.js b/test/module-loading/test-projects/js-esm/tests/helpers/value.js new file mode 100644 index 000000000..9bc5cd9be --- /dev/null +++ b/test/module-loading/test-projects/js-esm/tests/helpers/value.js @@ -0,0 +1 @@ +export const value = "js-esm-value"; diff --git a/test/module-loading/test-projects/ts-cjs/package.json b/test/module-loading/test-projects/ts-cjs/package.json new file mode 100644 index 000000000..6376a4bf1 --- /dev/null +++ b/test/module-loading/test-projects/ts-cjs/package.json @@ -0,0 +1,5 @@ +{ + "name": "module-loading-ts-cjs", + "private": true, + "type": "commonjs" +} diff --git a/test/module-loading/test-projects/ts-cjs/testplane.config.cjs b/test/module-loading/test-projects/ts-cjs/testplane.config.cjs new file mode 100644 index 000000000..c71030b9f --- /dev/null +++ b/test/module-loading/test-projects/ts-cjs/testplane.config.cjs @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + }, + }, +}; diff --git a/test/module-loading/test-projects/ts-cjs/tests/basic.test.ts b/test/module-loading/test-projects/ts-cjs/tests/basic.test.ts new file mode 100644 index 000000000..0815e282d --- /dev/null +++ b/test/module-loading/test-projects/ts-cjs/tests/basic.test.ts @@ -0,0 +1,7 @@ +import assert from "node:assert"; + +const { value } = require("./helpers/value"); + +describe("ts-cjs", () => { + it("ts-cjs test", () => assert.equal(value, "ts-cjs-value")); +}); diff --git a/test/module-loading/test-projects/ts-cjs/tests/helpers/value.js b/test/module-loading/test-projects/ts-cjs/tests/helpers/value.js new file mode 100644 index 000000000..31f714523 --- /dev/null +++ b/test/module-loading/test-projects/ts-cjs/tests/helpers/value.js @@ -0,0 +1,5 @@ +"use strict"; + +module.exports = { + value: "ts-cjs-value", +}; diff --git a/test/module-loading/test-projects/ts-esm/package.json b/test/module-loading/test-projects/ts-esm/package.json new file mode 100644 index 000000000..c69e39fa5 --- /dev/null +++ b/test/module-loading/test-projects/ts-esm/package.json @@ -0,0 +1,5 @@ +{ + "name": "module-loading-ts-esm", + "private": true, + "type": "module" +} diff --git a/test/module-loading/test-projects/ts-esm/testplane.config.cjs b/test/module-loading/test-projects/ts-esm/testplane.config.cjs new file mode 100644 index 000000000..c71030b9f --- /dev/null +++ b/test/module-loading/test-projects/ts-esm/testplane.config.cjs @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + browsers: { + chrome: { + desiredCapabilities: { + browserName: "chrome", + }, + }, + }, +}; diff --git a/test/module-loading/test-projects/ts-esm/tests/basic.test.ts b/test/module-loading/test-projects/ts-esm/tests/basic.test.ts new file mode 100644 index 000000000..69007875f --- /dev/null +++ b/test/module-loading/test-projects/ts-esm/tests/basic.test.ts @@ -0,0 +1,6 @@ +import assert from "node:assert"; +import { value } from "./helpers/value.ts"; + +describe("ts-esm", () => { + it("ts-esm test", () => assert.equal(value, "ts-esm-value")); +}); diff --git a/test/module-loading/test-projects/ts-esm/tests/helpers/value.ts b/test/module-loading/test-projects/ts-esm/tests/helpers/value.ts new file mode 100644 index 000000000..f7bb1431c --- /dev/null +++ b/test/module-loading/test-projects/ts-esm/tests/helpers/value.ts @@ -0,0 +1 @@ +export const value = "ts-esm-value"; diff --git a/test/src/test-reader/mocha-reader/index.js b/test/src/test-reader/mocha-reader/index.js index c53761713..13f5fa531 100644 --- a/test/src/test-reader/mocha-reader/index.js +++ b/test/src/test-reader/mocha-reader/index.js @@ -1,6 +1,9 @@ "use strict"; +const fs = require("fs"); const _ = require("lodash"); +const os = require("os"); +const path = require("path"); const { MochaEventBus } = require("src/test-reader/mocha-reader/mocha-event-bus"); const { TreeBuilderDecorator } = require("src/test-reader/mocha-reader/tree-builder-decorator"); const { TreeBuilder } = require("src/test-reader/tree-builder"); @@ -65,6 +68,7 @@ describe("test-reader/mocha-reader", () => { sandbox.stub(Mocha.prototype, "fullTrace"); sandbox.stub(Mocha.prototype, "addFile"); + sandbox.stub(Mocha.prototype, "loadFiles"); sandbox.stub(Mocha.prototype, "loadFilesAsync").resolves(); sandbox.stub(Mocha.Suite.prototype, "hasOnly").returns(false); @@ -90,6 +94,22 @@ describe("test-reader/mocha-reader", () => { }); }; + const withNativeTypeScript_ = async cb => { + const descriptor = Object.getOwnPropertyDescriptor(process.features, "typescript"); + + Object.defineProperty(process.features, "typescript", { configurable: true, value: "strip" }); + + try { + return await cb(); + } finally { + if (descriptor) { + Object.defineProperty(process.features, "typescript", descriptor); + } else { + delete process.features.typescript; + } + } + }; + describe("loadFiles", () => { describe("mocha initialization", () => { it("should create mocha parser with passed config", async () => { @@ -165,6 +185,89 @@ describe("test-reader/mocha-reader", () => { assert.deepEqual(calls, ["addFile", "addFile", "loadFilesAsync"]); }); + it("should load typeless TypeScript files with require if Node.js supports TypeScript", async () => { + await withNativeTypeScript_(() => readFiles_(["foo/bar.ts"])); + + assert.calledOnce(Mocha.prototype.loadFiles); + assert.notCalled(Mocha.prototype.loadFilesAsync); + }); + + it("should keep original async loading if escape hatch is enabled", async () => { + const originalEnv = process.env.TESTPLANE_LOAD_FILES_ASYNC; + process.env.TESTPLANE_LOAD_FILES_ASYNC = "1"; + + try { + await withNativeTypeScript_(() => readFiles_(["foo/bar.ts"])); + } finally { + if (originalEnv === undefined) { + delete process.env.TESTPLANE_LOAD_FILES_ASYNC; + } else { + process.env.TESTPLANE_LOAD_FILES_ASYNC = originalEnv; + } + } + + assert.notCalled(Mocha.prototype.loadFiles); + assert.calledOnce(Mocha.prototype.loadFilesAsync); + }); + + it("should load TypeScript files in module packages with async ESM loader", async () => { + const packageDir = fs.mkdtempSync(path.join(os.tmpdir(), "testplane-mocha-reader-")); + + fs.writeFileSync(path.join(packageDir, "package.json"), JSON.stringify({ type: "module" })); + + try { + await withNativeTypeScript_(() => readFiles_([path.join(packageDir, "test.ts")])); + } finally { + fs.rmSync(packageDir, { recursive: true, force: true }); + } + + assert.notCalled(Mocha.prototype.loadFiles); + assert.calledOnce(Mocha.prototype.loadFilesAsync); + }); + + it("should preserve ESM support when typeless TypeScript and ESM files are loaded together", async () => { + const calls = []; + + Mocha.prototype.addFile.callsFake(file => calls.push(`addFile:${file}`)); + Mocha.prototype.loadFiles.callsFake(function () { + calls.push(`loadFiles:${this.files[0]}`); + }); + Mocha.prototype.loadFilesAsync.callsFake(function () { + calls.push(`loadFilesAsync:${this.files[0]}`); + }); + + await withNativeTypeScript_(() => readFiles_(["foo/bar.ts", "baz/qux.mjs"])); + + assert.deepEqual(calls, [ + "addFile:foo/bar.ts", + "addFile:baz/qux.mjs", + "loadFiles:foo/bar.ts", + "loadFilesAsync:baz/qux.mjs", + ]); + }); + + ["ERR_REQUIRE_ESM", "ERR_REQUIRE_ASYNC_MODULE"].forEach(code => { + it(`should fallback to async loader on ${code}`, async () => { + const error = new Error("require cannot load ESM"); + error.code = code; + Mocha.prototype.loadFiles.throws(error); + + await withNativeTypeScript_(() => readFiles_(["foo/bar.ts"])); + + assert.calledOnce(Mocha.prototype.loadFiles); + assert.calledOnce(Mocha.prototype.loadFilesAsync); + }); + }); + + it("should not fallback to async loader on regular require errors", async () => { + Mocha.prototype.loadFiles.throws(new Error("Some error")); + + await withNativeTypeScript_(() => assert.isRejected(readFiles_(["foo/bar.ts"]), "Some error")); + + assert.calledOnce(Mocha.prototype.loadFiles); + assert.notCalled(Mocha.prototype.loadFilesAsync); + }); + it("should passthrough esmDecorator to mocha", async () => { const esmDecorator = sinon.spy(); diff --git a/test/tsconfig.json b/test/tsconfig.json index 5288823fb..a845d8279 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -1,7 +1,7 @@ { "extends": "../tsconfig.common.json", "include": ["../typings", "../src/index.ts", "."], - "exclude": ["../src/runner/browser-env/vite/browser-modules"], + "exclude": ["../src/runner/browser-env/vite/browser-modules", "module-loading/test-projects"], "compilerOptions": { "baseUrl": "..", "noEmit": true,