diff --git a/package-lock.json b/package-lock.json index cb7c0c101..cce672c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "testplane": "bin/testplane" }, "devDependencies": { + "@babel/parser": "7.28.5", "@commitlint/cli": "^19.0.3", "@commitlint/config-conventional": "^19.0.3", "@cspotcode/source-map-support": "0.8.0", @@ -158,8 +159,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -178,6 +191,22 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.24.1", "dev": true, @@ -189,6 +218,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@blakeembrey/deque": { "version": "1.0.5", "dev": true, @@ -14172,8 +14215,16 @@ "picocolors": "^1.0.0" } }, + "@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true + }, "@babel/helper-validator-identifier": { - "version": "7.22.20" + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==" }, "@babel/highlight": { "version": "7.24.2", @@ -14184,6 +14235,15 @@ "picocolors": "^1.0.0" } }, + "@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "requires": { + "@babel/types": "^7.28.5" + } + }, "@babel/runtime": { "version": "7.24.1", "dev": true, @@ -14191,6 +14251,16 @@ "regenerator-runtime": "^0.14.0" } }, + "@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "requires": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + } + }, "@blakeembrey/deque": { "version": "1.0.5", "dev": true diff --git a/package.json b/package.json index 4907edfd3..349b7490f 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "yallist": "3.1.1" }, "devDependencies": { + "@babel/parser": "7.28.5", "@commitlint/cli": "^19.0.3", "@commitlint/config-conventional": "^19.0.3", "@cspotcode/source-map-support": "0.8.0", @@ -179,11 +180,15 @@ "uglifyify": "3.0.4" }, "peerDependencies": { + "@babel/parser": ">=7.0.0", "@cspotcode/source-map-support": ">=0.7.0", "@swc/core": ">=1.3.96", "ts-node": ">=10.5.0" }, "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, "ts-node": { "optional": true }, diff --git a/src/base-testplane.ts b/src/base-testplane.ts index 854528be4..47acd741f 100644 --- a/src/base-testplane.ts +++ b/src/base-testplane.ts @@ -11,7 +11,7 @@ import { Interceptor, } from "./events"; import Errors from "./errors"; -import { registerTransformHook } from "./utils/typescript"; +import { registerTransformHook, updateTransformHook } from "./utils/typescript"; import { ConfigInput } from "./config/types"; export abstract class BaseTestplane extends AsyncEmitter { @@ -31,8 +31,9 @@ export abstract class BaseTestplane extends AsyncEmitter { this._interceptors = []; registerTransformHook(this.isWorker()); - this._config = Config.create(config); + updateTransformHook(this._config); + this._setLogLevel(); this._loadPlugins(); } diff --git a/src/test-reader/mocha-reader/index.js b/src/test-reader/mocha-reader/index.js index 6e5f10c31..73886f5b2 100644 --- a/src/test-reader/mocha-reader/index.js +++ b/src/test-reader/mocha-reader/index.js @@ -8,9 +8,10 @@ const { TreeBuilderDecorator } = require("./tree-builder-decorator"); const { TestReaderEvents } = require("../../events"); const { MasterEvents } = require("../../events"); const { getMethodsByInterface } = require("./utils"); +const logger = require("../../utils/logger"); const { enableSourceMaps } = require("../../utils/typescript"); -async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts }) { +async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts, isBrowserEnv = false }) { const mocha = new Mocha(config); mocha.fullTrace(); @@ -18,7 +19,22 @@ async function readFiles(files, { esmDecorator, config, eventBus, runnableOpts } initEventListeners({ rootSuite: mocha.suite, outBus: eventBus, config, runnableOpts }); files.forEach(f => mocha.addFile(f)); - await mocha.loadFilesAsync({ esmDecorator }); + + try { + await mocha.loadFilesAsync({ esmDecorator }); + } catch (err) { + const errorMessage = (err.message || "").split("\n")[0].trim(); + + if (isBrowserEnv && err.code === "MODULE_NOT_FOUND" && errorMessage.includes("?")) { + logger.warn( + `Failed to resolve module with query parameter: ${errorMessage}. ` + + `This is likely a Vite-style import (e.g., './file.svg?react'). ` + + `Please install @babel/parser version 7 or higher to fix this issue.`, + ); + } + + throw err; + } applyOnly(mocha.suite, eventBus); } diff --git a/src/test-reader/test-parser.ts b/src/test-reader/test-parser.ts index 822b452b9..51ad01c93 100644 --- a/src/test-reader/test-parser.ts +++ b/src/test-reader/test-parser.ts @@ -17,6 +17,7 @@ import * as logger from "../utils/logger"; import { getShortMD5 } from "../utils/crypto"; import { Test } from "./test-object"; import { Config } from "../config"; +import { isRunInBrowserEnv } from "../utils/config"; import { BrowserConfig } from "../config/browser-config"; import type { ReadTestsOpts } from "../testplane"; @@ -79,7 +80,9 @@ export class TestParser extends EventEmitter { const rand = Math.random(); const esmDecorator = (f: string): string => f + `?rand=${rand}`; - await readFiles(files, { esmDecorator, config: mochaOpts, eventBus, runnableOpts }); + const isBrowserEnv = isRunInBrowserEnv(config); + + await readFiles(files, { esmDecorator, config: mochaOpts, eventBus, runnableOpts, isBrowserEnv }); if (config.lastFailed.only) { try { diff --git a/src/utils/config.ts b/src/utils/config.ts index a66873698..83811a4dc 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1,6 +1,13 @@ -import { NODEJS_TEST_RUN_ENV } from "../constants/config"; +import { BROWSER_TEST_RUN_ENV, NODEJS_TEST_RUN_ENV } from "../constants/config"; import type { CommonConfig } from "../config/types"; export const isRunInNodeJsEnv = (config: CommonConfig): boolean => { return config.system.testRunEnv === NODEJS_TEST_RUN_ENV; }; + +export const isRunInBrowserEnv = (config: CommonConfig): boolean => { + return ( + (typeof config.system.testRunEnv === "string" && config.system.testRunEnv === BROWSER_TEST_RUN_ENV) || + (Array.isArray(config.system.testRunEnv) && config.system.testRunEnv[0] === BROWSER_TEST_RUN_ENV) + ); +}; diff --git a/src/utils/typescript.ts b/src/utils/typescript.ts index ab0a7ae66..a2f2d4c77 100644 --- a/src/utils/typescript.ts +++ b/src/utils/typescript.ts @@ -1,6 +1,9 @@ import path from "node:path"; import { addHook } from "pirates"; +import * as recast from "recast"; import * as logger from "./logger"; +import { isRunInBrowserEnv } from "./config"; +import type { CommonConfig } from "../config/types"; const TESTPLANE_TRANSFORM_HOOK = Symbol.for("testplane.transform.hook"); @@ -20,11 +23,16 @@ const ASSET_EXTENSIONS = [ ".woff2", ]; +type RecastParser = { + parse(source: string, options?: unknown): unknown; +}; + type ProcessWithTransformHook = typeof process & { [TESTPLANE_TRANSFORM_HOOK]?: { revert: () => void; enableSourceMaps: () => void }; }; let transformFunc: null | ((code: string, sourceFile: string, sourceMaps: boolean) => string) = null; +let shouldRemoveViteQueryImports: boolean = false; export const transformCode = ( code: string, @@ -49,8 +57,10 @@ export const transformCode = ( if (envVar && hasSwcCore()) { // eslint-disable-next-line @typescript-eslint/no-var-requires const { transformSync }: typeof import("@swc/core") = require("@swc/core"); - transformFunc = (code, sourceFile, sourceMaps): string => - transformSync(code, { + transformFunc = (code, sourceFile, sourceMaps): string => { + const preprocessedCode = shouldRemoveViteQueryImports ? removeViteQueryImports(code, sourceFile) : code; + + return transformSync(preprocessedCode, { sourceFileName: sourceFile, sourceMaps: sourceMaps ? "inline" : false, configFile: false, @@ -67,11 +77,14 @@ export const transformCode = ( }, }, }).code; + }; } else { // eslint-disable-next-line @typescript-eslint/no-var-requires const { transformSync }: typeof import("esbuild") = require("esbuild"); - transformFunc = (code, sourceFile, sourceMaps): string => - transformSync(code, { + transformFunc = (code, sourceFile, sourceMaps): string => { + const preprocessedCode = shouldRemoveViteQueryImports ? removeViteQueryImports(code, sourceFile) : code; + + return transformSync(preprocessedCode, { sourcefile: sourceFile, sourcemap: sourceMaps ? "inline" : false, minify: false, @@ -80,6 +93,7 @@ export const transformCode = ( target: "esnext", jsx: "automatic", }).code; + }; } } @@ -137,6 +151,10 @@ export const registerTransformHook = (isSilent: boolean = false): void => { } }; +export const updateTransformHook = (config: CommonConfig): void => { + shouldRemoveViteQueryImports = isRunInBrowserEnv(config); +}; + export const enableSourceMaps = (): void => { const processWithTranspileSymbol = process as ProcessWithTransformHook; @@ -146,3 +164,87 @@ export const enableSourceMaps = (): void => { processWithTranspileSymbol[TESTPLANE_TRANSFORM_HOOK].enableSourceMaps(); }; + +function removeViteQueryImports(code: string, sourceFile: string): string { + const extname = path.extname(sourceFile); + const isJsxOrTsx = extname === ".jsx" || extname === ".tsx"; + + if (!isJsxOrTsx) { + return code; + } + + let parser: RecastParser | null = null; + + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const babelParser: typeof import("@babel/parser") = require("@babel/parser"); + + parser = { + parse(source: string): ReturnType { + return babelParser.parse(source, { + sourceType: "module", + plugins: [ + "typescript", + "jsx", + "decorators-legacy", + "classProperties", + "nullishCoalescingOperator", + "optionalChaining", + ], + tokens: true, + }); + }, + }; + } catch (err) { + // If @babel/parser is not installed, return original code without transformation. + // This is not a critical issue as the user may not be using Vite-style imports with query parameters. + return code; + } + + let ast: recast.types.namedTypes.File; + + try { + ast = recast.parse(code, { + parser, + sourceFileName: sourceFile, + sourceRoot: path.dirname(sourceFile), + }); + } catch (err) { + const errorMessage = (err as Error).message; + logger.warn( + `Failed to parse file ${sourceFile} for removal of Vite query imports: ${errorMessage}. ` + + `The file will be processed without removing query imports.`, + ); + return code; + } + + recast.visit(ast, { + visitImportDeclaration(nodePath) { + const declaration = nodePath.value as recast.types.namedTypes.ImportDeclaration; + const source = declaration.source.value; + + if (typeof source === "string") { + const extname = path.extname(source); + + if (extname && extname.includes("?")) { + nodePath.prune(); + return false; + } + } + + return this.traverse(nodePath); + }, + }); + + try { + const result = recast.print(ast, { sourceMapName: sourceFile }); + return result.code; + } catch (err) { + const errorMessage = (err as Error).message; + logger.warn( + `Failed to transform AST for ${sourceFile} for removal of Vite query imports: ${errorMessage}. ` + + `The file will be processed without removing query imports.`, + ); + return code; + } +} diff --git a/test/src/test-reader/mocha-reader/index.js b/test/src/test-reader/mocha-reader/index.js index b17186047..c53761713 100644 --- a/test/src/test-reader/mocha-reader/index.js +++ b/test/src/test-reader/mocha-reader/index.js @@ -19,6 +19,7 @@ describe("test-reader/mocha-reader", () => { let getMethodsByInterfaceStub; let enableSourceMapsStub; let readFiles; + let loggerWarnStub; const mkMochaSuiteStub_ = () => { const suite = Object.create(Mocha.Suite.prototype); @@ -50,11 +51,14 @@ describe("test-reader/mocha-reader", () => { getMethodsByInterfaceStub = sinon.stub().returns({ suiteMethods: [], testMethods: [] }); enableSourceMapsStub = sinon.stub(); + loggerWarnStub = sinon.stub(); + readFiles = proxyquire("src/test-reader/mocha-reader", { mocha: MochaConstructorStub, "@cspotcode/source-map-support": SourceMapSupportStub, "./utils": { getMethodsByInterface: getMethodsByInterfaceStub }, "../../utils/typescript": { enableSourceMaps: enableSourceMapsStub }, + "../../utils/logger": { warn: loggerWarnStub }, }).readFiles; sandbox.stub(MochaEventBus, "create").returns(Object.create(MochaEventBus.prototype)); @@ -168,6 +172,45 @@ describe("test-reader/mocha-reader", () => { assert.calledWith(Mocha.prototype.loadFilesAsync, { esmDecorator }); }); + + describe("handle errors", () => { + it("should do nothing if error thrown in non-browser environment", async () => { + Mocha.prototype.loadFilesAsync.rejects(new Error("Some error")); + + await assert.isRejected(readFiles_({ isBrowserEnv: false }), "Some error"); + assert.notCalled(loggerWarnStub); + }); + + it("should do nothing if error is not a MODULE_NOT_FOUND error", async () => { + Mocha.prototype.loadFilesAsync.rejects(new Error("Some error")); + + await assert.isRejected(readFiles_({ isBrowserEnv: true }), "Some error"); + assert.notCalled(loggerWarnStub); + }); + + it("should do nothing if error message does not contain '?'", async () => { + const error = new Error("Cannot find module 'file.svg'"); + error.code = "MODULE_NOT_FOUND"; + Mocha.prototype.loadFilesAsync.rejects(error); + + await assert.isRejected(readFiles_({ isBrowserEnv: true }), "Cannot find module 'file.svg'"); + assert.notCalled(loggerWarnStub); + }); + + it("should warn if module not found with query parameter in browser environment", async () => { + const error = new Error("Cannot find module 'file.svg?react'"); + error.code = "MODULE_NOT_FOUND"; + Mocha.prototype.loadFilesAsync.rejects(error); + + await assert.isRejected(readFiles_({ isBrowserEnv: true }), "Cannot find module 'file.svg?react'"); + assert.calledOnceWith( + loggerWarnStub, + sinon.match( + "Failed to resolve module with query parameter: Cannot find module 'file.svg?react'.", + ), + ); + }); + }); }); describe("forbid suite hooks", () => { diff --git a/test/src/test-reader/test-parser.js b/test/src/test-reader/test-parser.js index c08ad9603..90a019be0 100644 --- a/test/src/test-reader/test-parser.js +++ b/test/src/test-reader/test-parser.js @@ -9,6 +9,7 @@ const { ConfigController } = require("src/test-reader/controllers/config-control const { TestParserAPI } = require("src/test-reader/test-parser-api"); const { Test, Suite } = require("src/test-reader/test-object"); const { MasterEvents: RunnerEvents, TestReaderEvents } = require("src/events"); +const { BROWSER_TEST_RUN_ENV } = require("src/constants/config"); const { makeConfigStub } = require("../../utils"); const proxyquire = require("proxyquire").noCallThru(); const path = require("path"); @@ -421,6 +422,18 @@ describe("test-reader/test-parser", () => { assert.calledWithMatch(readFiles, sinon.match.any, { runnableOpts }); }); + it("should pass 'isBrowserEnv' option to reader", async () => { + const config = makeConfigStub({ + system: { + testRunEnv: BROWSER_TEST_RUN_ENV, + }, + }); + + await loadFiles_({ config }); + + assert.calledWithMatch(readFiles, sinon.match.any, { isBrowserEnv: true }); + }); + describe("esm decorator", () => { it("should be passed to mocha reader", async () => { await loadFiles_(); diff --git a/test/src/testplane.js b/test/src/testplane.js index 3ee07fa65..a480798fa 100644 --- a/test/src/testplane.js +++ b/test/src/testplane.js @@ -814,7 +814,7 @@ describe("testplane", () => { }); it("testplane configuration", () => { - const config = { foo: "bar" }; + const config = makeConfigStub(); assert.deepEqual(mkTestplane_(config).config, config); }); diff --git a/test/src/utils/config.ts b/test/src/utils/config.ts index f4c0dde04..e27d519e4 100644 --- a/test/src/utils/config.ts +++ b/test/src/utils/config.ts @@ -1,4 +1,4 @@ -import { isRunInNodeJsEnv } from "../../../src/utils/config"; +import { isRunInBrowserEnv, isRunInNodeJsEnv } from "../../../src/utils/config"; import { NODEJS_TEST_RUN_ENV, BROWSER_TEST_RUN_ENV } from "../../../src/constants/config"; import type { CommonConfig } from "../../../src/config/types"; @@ -24,4 +24,36 @@ describe("config-utils", () => { assert.isFalse(isRunInNodeJsEnv(config)); }); }); + + describe("isRunInBrowserEnv", () => { + it("should return 'true' if running in browser environment", () => { + const config = { + system: { + testRunEnv: BROWSER_TEST_RUN_ENV, + }, + } as CommonConfig; + + assert.isTrue(isRunInBrowserEnv(config)); + }); + + it("should return 'true' if running in browser environment with options", () => { + const config = { + system: { + testRunEnv: [BROWSER_TEST_RUN_ENV, {}], + }, + } as CommonConfig; + + assert.isTrue(isRunInBrowserEnv(config)); + }); + + it("should return 'false' if running in node environment", () => { + const config = { + system: { + testRunEnv: NODEJS_TEST_RUN_ENV, + }, + } as CommonConfig; + + assert.isFalse(isRunInBrowserEnv(config)); + }); + }); }); diff --git a/test/src/worker/testplane.js b/test/src/worker/testplane.js index a981b1597..12f417ade 100644 --- a/test/src/worker/testplane.js +++ b/test/src/worker/testplane.js @@ -46,11 +46,12 @@ describe("worker/testplane", () => { }); it("should create a runner instance", () => { - Config.create.returns({ some: "config" }); + const config = makeConfigStub(); + Config.create.returns(config); Testplane.create(); - assert.calledOnceWith(Runner.create, { some: "config" }); + assert.calledOnceWith(Runner.create, config); }); it("should passthrough all runner events", () => { @@ -113,7 +114,7 @@ describe("worker/testplane", () => { }); it("testplane configuration", () => { - const config = { foo: "bar" }; + const config = makeConfigStub(); Config.create.returns(config);