diff --git a/docs/typescript.md b/docs/typescript.md index 7103eeb28..27b63f5d0 100644 --- a/docs/typescript.md +++ b/docs/typescript.md @@ -92,27 +92,33 @@ Scenario('successful login', ({ I }) => { - 🚀 **Works with Mocha:** Uses CommonJS hooks that Mocha understands - ✅ **Complete:** Handles all TypeScript features (enums, decorators, etc.) -### Using ts-node/esm (Alternative) +### Using ts-node/esm (Not Recommended) -If you prefer ts-node: +> ⚠️ **Note:** `ts-node/esm` has significant limitations with module resolution and doesn't work well with modern ESM TypeScript projects. **We strongly recommend using `tsx` instead.** The information below is provided for reference only. + +`ts-node/esm` has several issues: +- Doesn't support `"type": "module"` in package.json +- Doesn't resolve extensionless imports or `.js` imports to `.ts` files +- Requires explicit `.ts` extensions in imports, which isn't standard TypeScript practice +- Less reliable than `tsx` for ESM scenarios + +**If you still want to use ts-node/esm:** -**Installation:** ```bash npm install --save-dev ts-node ``` -**Configuration:** ```typescript // codecept.conf.ts export const config = { tests: './**/*_test.ts', - require: ['ts-node/esm'], // ← Use ts-node ESM loader + require: ['ts-node/esm'], helpers: { /* ... */ } } ``` -**Required tsconfig.json:** ```json +// tsconfig.json { "compilerOptions": { "module": "ESNext", @@ -121,12 +127,18 @@ export const config = { "esModuleInterop": true }, "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node" + "esm": true } } ``` +**Critical Limitations:** +- ❌ Cannot use `"type": "module"` in package.json +- ❌ Import statements must match the actual file (no automatic resolution) +- ❌ Module resolution doesn't work like standard TypeScript/Node.js ESM + +**Recommendation:** Use `tsx/cjs` instead for a better experience. + ### Full TypeScript Features in Tests With tsx or ts-node/esm, you can use complete TypeScript syntax including imports, enums, interfaces, and types: @@ -174,7 +186,19 @@ This means the TypeScript loader isn't configured. Make sure: **Error: Module not found when importing from `.ts` files** -Make sure you're using a proper TypeScript loader (`tsx/cjs` or `ts-node/esm`). +When using `ts-node/esm` with ESM, you need to use `.js` extensions in imports: + +```typescript +// This will cause an error in ESM mode: +import loginPage from "./pages/Login" + +// Use .js extension instead: +import loginPage from "./pages/Login.js" +``` + +TypeScript will resolve the `.js` import to your `.ts` file during compilation. This is the standard behavior for ESM + TypeScript. + +Alternatively, use `tsx/cjs` which doesn't require explicit extensions. **TypeScript config files vs test files** diff --git a/lib/mocha/factory.js b/lib/mocha/factory.js index 11efcd0e5..bf734684e 100644 --- a/lib/mocha/factory.js +++ b/lib/mocha/factory.js @@ -62,34 +62,9 @@ class MochaFactory { const jsFiles = this.files.filter(file => !file.match(/\.feature$/)) this.files = this.files.filter(file => !file.match(/\.feature$/)) - // Load JavaScript test files using ESM imports + // Load JavaScript test files using original loadFiles if (jsFiles.length > 0) { - try { - // Try original loadFiles first for compatibility - originalLoadFiles.call(this, fn) - } catch (e) { - // If original loadFiles fails, load ESM files manually - if (e.message.includes('not in cache') || e.message.includes('ESM') || e.message.includes('getStatus')) { - // Load ESM files by importing them synchronously using top-level await workaround - for (const file of jsFiles) { - try { - // Convert file path to file:// URL for dynamic import - const fileUrl = `file://${file}` - // Use import() but don't await it - let it load in the background - import(fileUrl).catch(importErr => { - // If dynamic import fails, the file may have syntax errors or other issues - console.error(`Failed to load test file ${file}:`, importErr.message) - }) - if (fn) fn() - } catch (fileErr) { - console.error(`Error processing test file ${file}:`, fileErr.message) - if (fn) fn(fileErr) - } - } - } else { - throw e - } - } + originalLoadFiles.call(this, fn) } // add ids for each test and check uniqueness diff --git a/lib/utils/loaderCheck.js b/lib/utils/loaderCheck.js index 317b00bf5..9a8e10349 100644 --- a/lib/utils/loaderCheck.js +++ b/lib/utils/loaderCheck.js @@ -65,9 +65,18 @@ CodeceptJS 4.x uses ES Modules (ESM) and requires a loader to run TypeScript tes ✅ Complete: Handles all TypeScript features ┌─────────────────────────────────────────────────────────────────────────────┐ -│ Option 2: ts-node/esm (Alternative - Established, Requires Config) │ +│ Option 2: ts-node/esm (Not Recommended - Has Module Resolution Issues) │ └─────────────────────────────────────────────────────────────────────────────┘ + ⚠️ ts-node/esm has significant limitations and is not recommended: + - Doesn't work with "type": "module" in package.json + - Module resolution doesn't work like standard TypeScript ESM + - Import statements must use explicit file paths + + We strongly recommend using tsx/cjs instead. + + If you still want to use ts-node/esm: + Installation: npm install --save-dev ts-node @@ -84,11 +93,12 @@ CodeceptJS 4.x uses ES Modules (ESM) and requires a loader to run TypeScript tes "esModuleInterop": true }, "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node" + "esm": true } } + 3. Do NOT use "type": "module" in package.json + 📚 Documentation: https://codecept.io/typescript Note: TypeScript config files (codecept.conf.ts) and helpers are automatically diff --git a/test/data/typescript-tsx-esm/README.md b/test/data/typescript-tsx-esm/README.md new file mode 100644 index 000000000..fb020f5f7 --- /dev/null +++ b/test/data/typescript-tsx-esm/README.md @@ -0,0 +1,43 @@ +# TypeScript tsx ESM Test + +This test demonstrates the recommended way to use TypeScript with CodeceptJS 4.x when you have `"type": "module"` in your package.json. + +## Key Features + +- Uses `tsx/cjs` as the TypeScript loader (recommended over ts-node/esm) +- Has `"type": "module"` in package.json +- Imports page objects without file extensions +- Everything works seamlessly + +## Configuration + +- **Loader**: `tsx/cjs` in `require` array +- **Package type**: `"module"` +- **TypeScript module**: `"esnext"` with `"node"` module resolution +- **Imports**: No file extensions needed (tsx handles resolution) + +## Why tsx? + +tsx is the recommended TypeScript loader for CodeceptJS 4.x because: +- ✅ Works with `"type": "module"` +- ✅ Handles extensionless imports +- ✅ Fast (built on esbuild) +- ✅ Zero config needed +- ✅ Compatible with Mocha's loading system + +## Why Not ts-node/esm? + +ts-node/esm has significant limitations: +- ❌ Doesn't work with `"type": "module"` +- ❌ Doesn't resolve extensionless imports to .ts files +- ❌ Requires complex configuration +- ❌ Module resolution doesn't work like standard TypeScript ESM + +## Running This Test + +```bash +cd test/data/typescript-tsx-esm +../../../bin/codecept.js run --verbose +``` + +You should see both scenarios pass successfully. diff --git a/test/data/typescript-tsx-esm/codecept.conf.ts b/test/data/typescript-tsx-esm/codecept.conf.ts new file mode 100644 index 000000000..b70d533bc --- /dev/null +++ b/test/data/typescript-tsx-esm/codecept.conf.ts @@ -0,0 +1,11 @@ +export const config: CodeceptJS.MainConfig = { + tests: "./*_test.ts", + output: "./output", + helpers: { + CustomHelper: { + require: "../helper.js" + } + }, + name: "typescript-tsx-esm-test", + require: ["tsx/cjs"] +}; diff --git a/test/data/typescript-tsx-esm/package.json b/test/data/typescript-tsx-esm/package.json new file mode 100644 index 000000000..b27b19dc4 --- /dev/null +++ b/test/data/typescript-tsx-esm/package.json @@ -0,0 +1,8 @@ +{ + "name": "typescript-tsx-esm", + "version": "1.0.0", + "type": "module", + "devDependencies": { + "tsx": "^4.20.6" + } +} diff --git a/test/data/typescript-tsx-esm/pages/Login.ts b/test/data/typescript-tsx-esm/pages/Login.ts new file mode 100644 index 000000000..70eeeb33e --- /dev/null +++ b/test/data/typescript-tsx-esm/pages/Login.ts @@ -0,0 +1,11 @@ +const { I } = inject(); + +export default { + login(username: string) { + I.say(`Logging in with user: ${username}`); + }, + + logout() { + I.say('Logging out'); + } +}; diff --git a/test/data/typescript-tsx-esm/tsconfig.json b/test/data/typescript-tsx-esm/tsconfig.json new file mode 100644 index 000000000..7f3d7f511 --- /dev/null +++ b/test/data/typescript-tsx-esm/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022", "DOM"], + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "strictNullChecks": false, + "types": ["codeceptjs", "node"], + "declaration": true, + "skipLibCheck": true + }, + "ts-node": { + "esm": true, + "transpileOnly": true + }, + "exclude": ["node_modules"] +} diff --git a/test/data/typescript-tsx-esm/typescript_esm_test.ts b/test/data/typescript-tsx-esm/typescript_esm_test.ts new file mode 100644 index 000000000..373637619 --- /dev/null +++ b/test/data/typescript-tsx-esm/typescript_esm_test.ts @@ -0,0 +1,13 @@ +// With tsx, you can import without extension - tsx handles resolution +import loginPage from "./pages/Login"; + +Feature("TypeScript tsx ESM with type:module"); + +Scenario("Import page object without extension using tsx", () => { + loginPage.login("testuser"); +}); + +Scenario("Page object methods work correctly", () => { + loginPage.login("admin"); + loginPage.logout(); +});