Skip to content

Commit f74d0f3

Browse files
committed
feat: Automatic TypeScript helper transpilation and arrow function parameter parsing
**TypeScript Helper Auto-Transpilation:** - Added automatic transpilation of .ts helper files using TypeScript compiler - Detects ERR_UNKNOWN_FILE_EXTENSION and transpiles to temporary .mjs files - Cleans up temporary files after import - No NODE_OPTIONS or ESM loaders needed - just install typescript - Handles CommonJS interop issues with fallback transform **Arrow Function Parameter Parsing:** - Fixed parser to handle arrow functions: ({ I }) => {...} - Added regex to convert arrow functions to regular functions for parsing - Handles both async arrow functions and regular arrow functions - Fixes parameter injection for modern JavaScript/TypeScript syntax **Async Helper Loading Fix:** - Fixed race condition where _init() was called before async helpers resolved - Added await asyncHelperPromise before calling _init() on helpers - Ensures all helpers are fully loaded before container.started() completes **Documentation Updates:** - Updated docs/typescript.md with automatic transpilation info - Added explanation of why test files use tsx/cjs vs auto-transpilation - Added TypeScript Files Support Matrix showing what's auto-transpiled - Added troubleshooting section for common issues - Clarified hybrid approach design decision **Test Coverage:** - Added test/data/typescript-helper-autoload/ with working TypeScript helper - Test passes without NODE_OPTIONS, confirming automatic transpilation works - Helper extends @codeceptjs/helper and methods are properly injected into I object Bump version to 4.0.0-beta.21
1 parent 69edd06 commit f74d0f3

File tree

9 files changed

+319
-3
lines changed

9 files changed

+319
-3
lines changed

docs/typescript.md

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,138 @@ export const config = {
213213
4. Change `require: ['ts-node/register']` to `require: ['tsx/cjs']`
214214
5. Run tests: `npx codeceptjs run`
215215

216+
### TypeScript Custom Helpers <Badge text="Automatic Transpilation" type="tip"/>
217+
218+
Custom helpers written in TypeScript are **automatically transpiled** at runtime in CodeceptJS 4.x! You don't need NODE_OPTIONS or ESM loaders.
219+
220+
#### Requirements
221+
222+
**Install TypeScript and @codeceptjs/helper:**
223+
```bash
224+
npm install --save-dev typescript @codeceptjs/helper
225+
```
226+
227+
That's it! CodeceptJS will automatically detect `.ts` helper files and transpile them on-the-fly using the TypeScript compiler.
228+
229+
#### Example TypeScript Helper
230+
231+
```typescript
232+
// helpers/material-component.helper.ts
233+
import { Helper } from '@codeceptjs/helper'
234+
235+
class MaterialComponentHelper extends Helper {
236+
async waitForVisibleMaterialSnackbar(sec?: number): Promise<void> {
237+
await this.helpers.Playwright.waitForVisible("simple-snack-bar", sec)
238+
}
239+
240+
async waitForTextMaterialSnackbarText(
241+
text: string,
242+
timeout: number = 10
243+
): Promise<void> {
244+
await this.helpers.Playwright.waitForText(
245+
text,
246+
timeout,
247+
"simple-snack-bar"
248+
)
249+
}
250+
}
251+
252+
export default MaterialComponentHelper
253+
```
254+
255+
**Configuration:**
256+
```typescript
257+
// codecept.conf.ts
258+
export const config = {
259+
tests: './tests/**/*.spec.ts',
260+
require: ['tsx/cjs'], // For test files
261+
helpers: {
262+
Playwright: {
263+
url: 'http://localhost',
264+
browser: 'chromium'
265+
},
266+
MaterialComponent: {
267+
require: "./helpers/material-component.helper.ts" // TypeScript helper - auto-loaded!
268+
}
269+
}
270+
}
271+
```
272+
273+
**Run tests:**
274+
```bash
275+
npx codeceptjs run # TypeScript helper automatically transpiled!
276+
```
277+
278+
#### How It Works
279+
280+
When CodeceptJS encounters a `.ts` helper file:
281+
1. Detects the `.ts` extension and `ERR_UNKNOWN_FILE_EXTENSION` error
282+
2. Checks if `typescript` package is installed
283+
3. Transpiles the helper to a temporary `.mjs` file
284+
4. Imports the transpiled file
285+
5. Cleans up temporary files after loading
286+
287+
This happens automatically - no configuration needed!
288+
289+
#### TypeScript Files Support Matrix
290+
291+
| File Type | Auto-Transpiled? | Configuration Required |
292+
|-----------|------------------|------------------------|
293+
| Test files (`*_test.ts`) | Via tsx/cjs loader | `require: ['tsx/cjs']` in config |
294+
| Helper files (`helper.ts`) | ✅ Automatic | `npm install typescript` |
295+
| Config files (`codecept.conf.ts`) | ✅ Automatic | None (built-in) |
296+
| Page Objects/Steps (`steps.ts`) | ✅ Automatic | `npm install typescript` |
297+
298+
**Key Points:**
299+
- **Test files**: Need `tsx/cjs` in `require` array (Mocha uses `require()` internally)
300+
- **Helper/Page Object files**: Auto-transpiled when loaded via `import()`
301+
- **Config files**: Always automatically transpiled (built-in feature)
302+
303+
**Why Different Approaches?**
304+
305+
Test files use a **loader** (`tsx/cjs`) while helpers use **auto-transpilation**. This hybrid approach is optimal because:
306+
307+
1. **Test files are loaded by Mocha** via `require()` - CodeceptJS can't intercept this, so we need a loader like `tsx/cjs` that hooks into Node.js's require system
308+
2. **Helpers are loaded by CodeceptJS** via `import()` - we can catch the error and transpile on-demand
309+
3. **Performance** - tsx's optimized loader with caching is faster for many test files
310+
4. **Simplicity** - Auto-transpilation for helpers means no extra configuration needed
311+
312+
This gives you the best of both worlds: efficient test loading and zero-config helper transpilation!
313+
314+
#### Troubleshooting
315+
316+
**Error: "TypeScript helper detected but could not be loaded"**
317+
318+
Install TypeScript in your project:
319+
```bash
320+
npm install --save-dev typescript
321+
```
322+
323+
**Error: "Cannot find module '@codeceptjs/helper'"**
324+
325+
Install the helper base class:
326+
```bash
327+
npm install --save-dev @codeceptjs/helper
328+
```
329+
- Config files are always automatically transpiled by CodeceptJS
330+
331+
#### Alternative: Compile TypeScript Helper to JavaScript
332+
333+
If you can't use `NODE_OPTIONS`, compile your helper to JavaScript:
334+
335+
```bash
336+
npx tsc helpers/material-component.helper.ts --module ES2022 --target ES2022
337+
```
338+
339+
Then reference the `.js` file:
340+
```typescript
341+
helpers: {
342+
MaterialComponent: {
343+
require: "./helpers/material-component.helper.js" // Compiled JS
344+
}
345+
}
346+
```
347+
216348
## Promise-Based Typings
217349

218350
By default, CodeceptJS tests are written in synchronous mode. This is a regular CodeceptJS test:

lib/actor.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import event from './event.js'
77
import store from './store.js'
88
import output from './output.js'
99
import Container from './container.js'
10+
import debugModule from 'debug'
11+
const debug = debugModule('codeceptjs:actor')
1012

1113
/**
1214
* @interface

lib/container.js

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,51 @@ import ai from './ai.js'
1717
import actorFactory from './actor.js'
1818

1919
let asyncHelperPromise
20+
let tsxLoaderRegistered = false
21+
22+
/**
23+
* Automatically register tsx ESM loader for TypeScript imports
24+
* This allows loading .ts files without NODE_OPTIONS
25+
*/
26+
async function ensureTsxLoader() {
27+
if (tsxLoaderRegistered) return true
28+
29+
try {
30+
// Check if tsx is available
31+
const { createRequire } = await import('module')
32+
const { pathToFileURL } = await import('url')
33+
const userRequire = createRequire(pathToFileURL(path.join(global.codecept_dir || process.cwd(), 'package.json')).href)
34+
35+
// Try to resolve tsx from user's project
36+
try {
37+
userRequire.resolve('tsx')
38+
} catch {
39+
debug('tsx not found in user project')
40+
return false
41+
}
42+
43+
// Register tsx/esm loader dynamically
44+
// Use Node.js register() API from node:module (Node 18.19+, 20.6+)
45+
try {
46+
const { register } = await import('node:module')
47+
if (typeof register === 'function') {
48+
debug('Registering tsx ESM loader via node:module register()')
49+
const tsxPath = userRequire.resolve('tsx/esm')
50+
register(tsxPath, import.meta.url)
51+
tsxLoaderRegistered = true
52+
return true
53+
}
54+
} catch (registerErr) {
55+
debug('node:module register() not available:', registerErr.message)
56+
}
57+
58+
debug('module.register not available, tsx loader must be set via NODE_OPTIONS')
59+
return false
60+
} catch (err) {
61+
debug('Failed to register tsx loader:', err.message)
62+
return false
63+
}
64+
}
2065

2166
let container = {
2267
helpers: {},
@@ -349,6 +394,9 @@ async function createHelpers(config) {
349394
}
350395
}
351396

397+
// Wait for all async helpers to be resolved before calling _init
398+
await asyncHelperPromise
399+
352400
for (const name in helpers) {
353401
if (helpers[name]._init) await helpers[name]._init()
354402
}
@@ -392,7 +440,83 @@ async function requireHelperFromModule(helperName, config, HelperClass) {
392440
}
393441
HelperClass = mod.default || mod
394442
} catch (err) {
395-
if (err.code === 'ERR_REQUIRE_ESM' || (err.message && err.message.includes('ES module'))) {
443+
if (err.code === 'ERR_UNKNOWN_FILE_EXTENSION' || (err.message && err.message.includes('Unknown file extension'))) {
444+
// This is likely a TypeScript helper file. Transpile it to a temporary JS file
445+
// and import that as a reliable fallback (no NODE_OPTIONS required).
446+
try {
447+
const pathModule = await import('path')
448+
const absolutePath = pathModule.default.resolve(moduleName)
449+
450+
// Attempt to load local 'typescript' to transpile the helper
451+
let typescript
452+
try {
453+
typescript = await import('typescript')
454+
} catch (tsImportErr) {
455+
throw new Error(`TypeScript helper detected (${moduleName}). Please install 'typescript' in your project: npm install --save-dev typescript`)
456+
}
457+
458+
const { tempFile, allTempFiles } = await transpileTypeScript(absolutePath, typescript)
459+
debug(`Transpiled TypeScript helper: ${moduleName} -> ${tempFile}`)
460+
461+
try {
462+
try {
463+
const mod = await import(tempFile)
464+
HelperClass = mod.default || mod
465+
debug(`helper ${helperName} loaded from transpiled JS: ${tempFile}`)
466+
} catch (importTempErr) {
467+
// If import fails due to CommonJS named export incompatibility,
468+
// try a quick transform: convert named imports from CommonJS packages
469+
// into default import + destructuring. This resolves cases like
470+
// "Named export 'Helper' not found. The requested module '@codeceptjs/helper' is a CommonJS module"
471+
const msg = importTempErr && importTempErr.message || ''
472+
const commonJsMatch = msg.match(/The requested module '(.+?)' is a CommonJS module/)
473+
if (commonJsMatch) {
474+
// Read the transpiled file, perform heuristic replacement, and import again
475+
const fs = await import('fs')
476+
let content = fs.readFileSync(tempFile, 'utf8')
477+
// Heuristic: replace "import { X, Y } from 'mod'" with default import + destructure
478+
content = content.replace(/import\s+\{([^}]+)\}\s+from\s+(['"])([^'"\)]+)\2/gm, (m, names, q, modName) => {
479+
// Only adjust imports for the module reported in the error or for local modules
480+
if (modName === commonJsMatch[1] || modName.startsWith('.') || !modName.includes('/')) {
481+
const cleanedNames = names.trim()
482+
return `import pkg__interop from ${q}${modName}${q};\nconst { ${cleanedNames} } = pkg__interop`
483+
}
484+
return m
485+
})
486+
487+
// Write to a secondary temp file
488+
const os = await import('os')
489+
const path = await import('path')
490+
const fallbackTemp = path.default.join(os.default.tmpdir(), `helper-fallback-${Date.now()}.mjs`)
491+
fs.writeFileSync(fallbackTemp, content, 'utf8')
492+
try {
493+
const mod = await import(fallbackTemp)
494+
HelperClass = mod.default || mod
495+
debug(`helper ${helperName} loaded from transpiled JS after CommonJS interop fix: ${fallbackTemp}`)
496+
} finally {
497+
try { fs.unlinkSync(fallbackTemp) } catch (e) {}
498+
}
499+
} else {
500+
throw importTempErr
501+
}
502+
}
503+
} finally {
504+
// Cleanup transpiled temporary files
505+
const filesToClean = Array.isArray(allTempFiles) ? allTempFiles : [allTempFiles]
506+
cleanupTempFiles(filesToClean)
507+
}
508+
} catch (importErr) {
509+
throw new Error(
510+
`Helper '${helperName}' is a TypeScript file but could not be loaded.\n` +
511+
`Path: ${moduleName}\n` +
512+
`Error: ${importErr.message}\n\n` +
513+
`To load TypeScript helpers, install 'typescript' in your project or use an ESM loader (e.g. tsx):\n` +
514+
` npm install --save-dev typescript\n` +
515+
` OR run CodeceptJS with an ESM loader: NODE_OPTIONS='--import tsx' npx codeceptjs run\n\n` +
516+
`CodeceptJS will transpile TypeScript helpers automatically at runtime if 'typescript' is available.`
517+
)
518+
}
519+
} else if (err.code === 'ERR_REQUIRE_ESM' || (err.message && err.message.includes('ES module'))) {
396520
// This is an ESM module, use dynamic import
397521
try {
398522
const pathModule = await import('path')

lib/parser.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,14 @@ export const getParamsToString = function (fn) {
1717
function getParams(fn) {
1818
if (fn.isSinonProxy) return []
1919
try {
20-
const reflected = parser.parse(fn)
20+
// Convert arrow functions to regular functions for parsing
21+
let fnString = fn.toString()
22+
// Handle async arrow functions: async (...) => { becomes async function(...) {
23+
fnString = fnString.replace(/^async\s*(\([^)]*\))\s*=>/, 'async function$1')
24+
// Handle regular arrow functions: (...) => { becomes function(...) {
25+
fnString = fnString.replace(/^(\([^)]*\))\s*=>/, 'function$1')
26+
27+
const reflected = parser.parse(fnString)
2128
if (reflected.args.length > 1 || reflected.args[0] === 'I') {
2229
output.error('Error: old CodeceptJS v2 format detected. Upgrade your project to the new format -> https://bit.ly/codecept3Up')
2330
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeceptjs",
3-
"version": "4.0.0-beta.20",
3+
"version": "4.0.0-beta.21",
44
"type": "module",
55
"description": "Supercharged End 2 End Testing Framework for NodeJS",
66
"keywords": [
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Feature('TypeScript Helper Auto-load Test');
2+
3+
Scenario('Load TypeScript helper automatically', async ({ I }) => {
4+
const greeting = await I.getGreeting('World');
5+
console.log('Greeting:', greeting);
6+
7+
if (greeting !== 'Hello, World!') {
8+
throw new Error(`Expected "Hello, World!" but got "${greeting}"`);
9+
}
10+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const config: CodeceptJS.MainConfig = {
2+
tests: './*_test.ts',
3+
output: './output',
4+
require: ['tsx/cjs'],
5+
helpers: {
6+
CustomHelper: {
7+
require: './helpers/custom.helper.ts'
8+
}
9+
},
10+
include: {},
11+
name: 'typescript-helper-autoload'
12+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Helper from '@codeceptjs/helper';
2+
3+
interface TestData {
4+
message: string;
5+
count: number;
6+
}
7+
8+
class CustomHelper extends Helper {
9+
async logTestData(data: TestData): Promise<void> {
10+
console.log(`Message: ${data.message}, Count: ${data.count}`);
11+
}
12+
13+
getGreeting(name: string): string {
14+
return `Hello, ${name}!`;
15+
}
16+
}
17+
18+
export default CustomHelper;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "typescript-helper-autoload-test",
3+
"version": "1.0.0",
4+
"type": "module",
5+
"dependencies": {
6+
"tsx": "^4.20.6"
7+
},
8+
"devDependencies": {
9+
"@codeceptjs/helper": "^2.0.4"
10+
}
11+
}

0 commit comments

Comments
 (0)