Skip to content

Commit 3803906

Browse files
committed
feat: replace tsnode with tsx for parsing user configuration in all cases
1 parent 8cee6dc commit 3803906

File tree

24 files changed

+298
-377
lines changed

24 files changed

+298
-377
lines changed

Diff for: .circleci/workflows.yml

+5-5
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ mainBuildFilters: &mainBuildFilters
3838
- /^release\/\d+\.\d+\.\d+$/
3939
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
4040
- 'update-v8-snapshot-cache-on-develop'
41-
- 'chore/update_mobx_decoratorless'
41+
- 'feat/replace_tsnode_for_tsx_config_process'
4242

4343
# usually we don't build Mac app - it takes a long time
4444
# but sometimes we want to really confirm we are doing the right thing
@@ -49,7 +49,7 @@ macWorkflowFilters: &darwin-workflow-filters
4949
- equal: [ develop, << pipeline.git.branch >> ]
5050
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
5151
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
52-
- equal: [ 'chore/update_mobx_decoratorless', << pipeline.git.branch >> ]
52+
- equal: [ 'feat/replace_tsnode_for_tsx_config_process', << pipeline.git.branch >> ]
5353
- matches:
5454
pattern: /^release\/\d+\.\d+\.\d+$/
5555
value: << pipeline.git.branch >>
@@ -60,7 +60,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
6060
- equal: [ develop, << pipeline.git.branch >> ]
6161
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
6262
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
63-
- equal: [ 'chore/update_mobx_decoratorless', << pipeline.git.branch >> ]
63+
- equal: [ 'feat/replace_tsnode_for_tsx_config_process', << pipeline.git.branch >> ]
6464
- matches:
6565
pattern: /^release\/\d+\.\d+\.\d+$/
6666
value: << pipeline.git.branch >>
@@ -83,7 +83,7 @@ windowsWorkflowFilters: &windows-workflow-filters
8383
- equal: [ develop, << pipeline.git.branch >> ]
8484
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
8585
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
86-
- equal: [ 'chore/update_mobx_decoratorless', << pipeline.git.branch >> ]
86+
- equal: [ 'feat/replace_tsnode_for_tsx_config_process', << pipeline.git.branch >> ]
8787
- matches:
8888
pattern: /^release\/\d+\.\d+\.\d+$/
8989
value: << pipeline.git.branch >>
@@ -157,7 +157,7 @@ commands:
157157
name: Set environment variable to determine whether or not to persist artifacts
158158
command: |
159159
echo "Setting SHOULD_PERSIST_ARTIFACTS variable"
160-
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "chore/update_mobx_decoratorless" ]]; then
160+
echo 'if ! [[ "$CIRCLE_BRANCH" != "develop" && "$CIRCLE_BRANCH" != "release/"* && "$CIRCLE_BRANCH" != "feat/replace_tsnode_for_tsx_config_process" ]]; then
161161
export SHOULD_PERSIST_ARTIFACTS=true
162162
fi' >> "$BASH_ENV"
163163
# You must run `setup_should_persist_artifacts` command and be using bash before running this command

Diff for: cli/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ _Released 07/01/2025 (PENDING)_
88
- Removed support for Node.js 18 and Node.js 23. Addresses [#31302](https://github.com/cypress-io/cypress/issues/31302).
99
- Removed support for [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol) with the [firefox](https://www.mozilla.org/) browser. Addresses [#31189](https://github.com/cypress-io/cypress/issues/31189).
1010

11+
**Features:**
12+
13+
- [`tsx`](https://tsx.is/) is now used in all cases to run the Cypress config, replacing [ts-node](https://github.com/TypeStrong/ts-node) for TypeScript and Node for commonjs/ESM. This should allow for more interoperability for users who are using any variant of ES Modules. Addresses [#30954](https://github.com/cypress-io/cypress/issues/30954), [#30925](https://github.com/cypress-io/cypress/issues/30925), and [#31185](https://github.com/cypress-io/cypress/issues/31185).
14+
1115
## 14.3.1
1216

1317
_Released 4/22/2025 (PENDING)_

Diff for: packages/data-context/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"execa": "1.0.0",
4040
"front-matter": "^4.0.2",
4141
"fs-extra": "8.1.0",
42+
"get-tsconfig": "4.10.0",
4243
"getenv": "1.0.0",
4344
"globby": "^11.0.1",
4445
"graphql": "^15.5.1",
@@ -60,6 +61,7 @@
6061
"server-destroy": "1.0.1",
6162
"simple-git": "^3.27.0",
6263
"stringify-object": "^3.0.0",
64+
"tsx": "4.19.3",
6365
"underscore.string": "^3.3.6",
6466
"wonka": "^4.0.15"
6567
},

Diff for: packages/data-context/src/actions/MigrationActions.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { hasTypeScriptInstalled, toPosix } from '../util'
3838

3939
const debug = debugLib('cypress:data-context:MigrationActions')
4040

41-
const tsNode = toPosix(require.resolve('@packages/server/lib/plugins/child/register_ts_node'))
41+
const tsxCjs = toPosix(require.resolve('tsx/cjs'))
4242

4343
export function getConfigWithDefaults (legacyConfig: any) {
4444
const newConfig = _.cloneDeep(legacyConfig)
@@ -99,20 +99,20 @@ export async function processConfigViaLegacyPlugins (projectRoot: string, legacy
9999
const configProcessArgs = ['--projectRoot', projectRoot, '--file', cwd]
100100
const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
101101

102-
// use ts-node if they've got typescript installed
102+
// use tsx if they've got typescript installed
103103
// this matches the 9.x behavior, which is what we want for
104104
// processing legacy pluginsFile (we never supported `"type": "module") in 9.x.
105105
if (hasTypeScriptInstalled(projectRoot)) {
106-
const tsNodeLoader = `--require "${tsNode}"`
106+
const tsxLoader = `--require "${tsxCjs}"`
107107

108108
if (!childOptions.env) {
109109
childOptions.env = {}
110110
}
111111

112112
if (childOptions.env.NODE_OPTIONS) {
113-
childOptions.env.NODE_OPTIONS += ` ${tsNodeLoader}`
113+
childOptions.env.NODE_OPTIONS += ` ${tsxLoader}`
114114
} else {
115-
childOptions.env.NODE_OPTIONS = tsNodeLoader
115+
childOptions.env.NODE_OPTIONS = tsxLoader
116116
}
117117
}
118118

Diff for: packages/data-context/src/data/ProjectConfigIpc.ts

+56-74
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,24 @@ import { CypressError, getError } from '@packages/errors'
33
import type { FullConfig, TestingType } from '@packages/types'
44
import { ChildProcess, fork, ForkOptions, spawn } from 'child_process'
55
import EventEmitter from 'events'
6-
import fs from 'fs-extra'
76
import path from 'path'
87
import inspector from 'inspector'
98
import debugLib from 'debug'
9+
import { getTsconfig } from 'get-tsconfig'
1010
import { autoBindDebug, hasTypeScriptInstalled, toPosix } from '../util'
1111
import _ from 'lodash'
12-
import { pathToFileURL } from 'url'
1312
import os from 'os'
1413
import semver from 'semver'
1514
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
1615
import { telemetry, encodeTelemetryContext } from '@packages/telemetry'
1716

1817
const pkg = require('@packages/root')
1918
const debug = debugLib(`cypress:lifecycle:ProjectConfigIpc`)
19+
const debugVerbose = debugLib(`cypress-verbose:lifecycle:ProjectConfigIpc`)
2020

2121
const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
2222

23-
const tsNodeEsm = pathToFileURL(require.resolve('ts-node/esm/transpile-only')).href
24-
const tsNode = toPosix(require.resolve('@packages/server/lib/plugins/child/register_ts_node'))
23+
const tsx = toPosix(require.resolve('tsx'))
2524

2625
export type IpcHandler = (ipc: ProjectConfigIpc) => void
2726

@@ -262,10 +261,8 @@ export class ProjectConfigIpc extends EventEmitter {
262261

263262
private forkConfigProcess () {
264263
const configProcessArgs = ['--projectRoot', this.projectRoot, '--file', this.configFilePath]
265-
// allow the use of ts-node in subprocesses tests by removing the env constant from it
266-
// without this line, packages/ts/register.js never registers the ts-node module for config and
267-
// run_plugins can't use the config module.
268-
const env = _.omit(process.env, 'CYPRESS_INTERNAL_E2E_TESTING_SELF')
264+
// we do NOT want telemetry enabled within our cy-in-cy tests as it isn't configured to handled it
265+
const env = _.omit(process.env, 'CYPRESS_INTERNAL_E2E_TESTING_SELF', 'CYPRESS_INTERNAL_ENABLE_TELEMETRY')
269266

270267
env.NODE_OPTIONS = process.env.ORIGINAL_NODE_OPTIONS || ''
271268

@@ -279,86 +276,71 @@ export class ProjectConfigIpc extends EventEmitter {
279276
if (inspector.url()) {
280277
childOptions.execArgv = _.chain(process.execArgv.slice(0))
281278
.remove('--inspect-brk')
279+
// NOTE: The IDE in which you are working likely will not let attach to this process until it is running if using the --inspect option
280+
// If needing to debug the child process (webpack-dev-server/vite-dev-server/webpack-preprocessor(s)/config loading), you may want to use --inspect-brk instead
281+
// as it will NOT execute that process until you attach the debugger to it.
282282
.push(`--inspect=${process.debugPort + 1}`)
283283
.value()
284284
}
285285

286-
debug('fork child process %o', { CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions: _.omit(childOptions, 'env') })
287-
288-
let isProjectUsingESModules = false
286+
/**
287+
* Before the introduction of tsx, Cypress used ts-node (@see https://github.com/TypeStrong/ts-node) with native node to try and load the user's cypress.config.ts file.
288+
* This presented problems because the Cypress node runtime runs in commonjs, which may not be compatible with the user's cypress.config.ts and tsconfig.json.
289+
* To mitigate the aforementioned runtime incompatibility, we used to force TypeScript options for the user in order to load their config inside the our node context
290+
* via a child process, which lead to clashes and issues (outlined in the comments below).
291+
* This is best explained historically in our docs which a screenshot can be see in @see https://github.com/cypress-io/cypress/issues/30426#issuecomment-2805204540 and can be seen
292+
* in an older version of the Cypress codebase (@see https://github.com/cypress-io/cypress/blob/v14.3.0/packages/server/lib/plugins/child/ts_node.js#L24)
293+
*
294+
* Attempted workarounds with ts-node and node: @see https://github.com/cypress-io/cypress/pull/28709
295+
* Example continued end user issues: @see https://github.com/cypress-io/cypress/issues/30954 and @see https://github.com/cypress-io/cypress/issues/30925
296+
* Spike into ts-node alternatives (a lot of useful comments on tsx): @see https://github.com/cypress-io/cypress/issues/30426
297+
* feature issue to replace ts-node as our end user TypeScript loader: @see https://github.com/cypress-io/cypress/issues/31185
298+
*
299+
* tsx (@see https://tsx.is/) is able to work with both CommonJS and ESM at the same time ( @see https://tsx.is/#seamless-cjs-%E2%86%94-esm-imports), which solves the problem of interoperability that
300+
* Cypress faced with ts-node and really just node itself. We no longer need experimental node flags and ts-node permutations to load the user's config file.
301+
* We can use tsx to load just about anything, including JavaScript files (@see https://github.com/privatenumber/ts-runtime-comparison)!
302+
*/
289303

290-
try {
291-
// TODO: convert this to async FS methods
292-
// eslint-disable-next-line no-restricted-syntax
293-
const pkgJson = fs.readJsonSync(path.join(this.projectRoot, 'package.json'))
294-
295-
isProjectUsingESModules = pkgJson.type === 'module'
296-
} catch (e) {
297-
// project does not have `package.json` or it was not found
298-
// reasonable to assume not using es modules
299-
}
304+
debug('fork child process %o', { CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions: _.omit(childOptions, 'env') })
300305

301306
if (!childOptions.env) {
302307
childOptions.env = {}
303308
}
304309

305-
// If they've got TypeScript installed, we can use
306-
// ts-node for CommonJS
307-
// ts-node/esm for ESM
308-
if (hasTypeScriptInstalled(this.projectRoot)) {
310+
/**
311+
* use --import for node 20.6.0 and above as --import is supported
312+
* use --loader for node under 20.6.0
313+
* @see https://tsx.is/dev-api/node-cli#node-js-cli
314+
*/
315+
let tsxLoader = this.nodeVersion && semver.lt(this.nodeVersion, '20.6.0') ? `--loader ${tsx}` : `--import ${tsx}`
316+
317+
// If they've got TypeScript installed, we can use tsx for CommonJS and ESM.
318+
// @see https://tsx.is/dev-api/node-cli#node-js-cli
319+
const userHasTypeScriptInstalled = hasTypeScriptInstalled(this.projectRoot)
320+
321+
if (userHasTypeScriptInstalled) {
309322
debug('found typescript in %s', this.projectRoot)
310-
if (isProjectUsingESModules) {
311-
debug(`using --experimental-specifier-resolution=node with --loader ${tsNodeEsm}`)
312-
// Use the ts-node/esm loader so they can use TypeScript with `"type": "module".
313-
// The loader API is experimental and will change.
314-
// The same can be said for the other alternative, esbuild, so this is the
315-
// best option that leverages the existing modules we bundle in the binary.
316-
// @see ts-node esm loader https://typestrong.org/ts-node/docs/usage/#node-flags-and-other-tools
317-
// @see Node.js Loader API https://nodejs.org/api/esm.html#customizing-esm-specifier-resolution-algorithm
318-
let tsNodeEsmLoader = `--experimental-specifier-resolution=node --loader ${tsNodeEsm}`
319-
320-
// starting in nodejs 20.19.0 and 22.7.0, the --experimental-detect-module option is now enabled by default.
321-
// We need to disable it with the --no-experimental-detect-module flag.
322-
// @see https://github.com/cypress-io/cypress/issues/30084
323-
if (this.nodeVersion && (semver.gte(this.nodeVersion, '22.7.0') || semver.satisfies(this.nodeVersion, '>= 20.19.0 < 21.0.0'))) {
324-
debug(`detected node version ${this.nodeVersion}, adding --no-experimental-detect-module option to child_process NODE_OPTIONS.`)
325-
tsNodeEsmLoader = `${tsNodeEsmLoader} --no-experimental-detect-module`
326-
}
327-
328-
// starting in nodejs 20.19.0 and 22.12.0, the --experimental-require-module option is now enabled by default.
329-
// We need to disable it with the --no-experimental-require-module flag.
330-
// @see https://github.com/cypress-io/cypress/issues/30715
331-
if (this.nodeVersion && (semver.gte(this.nodeVersion, '22.12.0') || semver.satisfies(this.nodeVersion, '>= 20.19.0 < 21.0.0'))) {
332-
debug(`detected node version ${this.nodeVersion}, adding --no-experimental-require-module option to child_process NODE_OPTIONS.`)
333-
tsNodeEsmLoader = `${tsNodeEsmLoader} --no-experimental-require-module`
334-
}
335-
336-
if (childOptions.env.NODE_OPTIONS) {
337-
childOptions.env.NODE_OPTIONS += ` ${tsNodeEsmLoader}`
338-
} else {
339-
childOptions.env.NODE_OPTIONS = tsNodeEsmLoader
340-
}
323+
324+
// TODO: get the tsconfig.json that applies to the users cypress.config.ts file
325+
// right now, we are just using the tsconfig.json we find in the project root
326+
const tsConfigIfExists = getTsconfig(this.projectRoot)
327+
328+
if (tsConfigIfExists) {
329+
debug(`tsconfig.json found at ${tsConfigIfExists.path}`)
330+
childOptions.env.TSX_TSCONFIG_PATH = tsConfigIfExists.path
331+
332+
debugVerbose(`tsconfig.json parsed as follows: %o`, tsConfigIfExists.config)
341333
} else {
342-
// Not using ES Modules (via "type": "module"),
343-
// so we just register the standard ts-node module
344-
// to handle TypeScript that is compiled to CommonJS.
345-
// We do NOT use the `--loader` flag because we have some additional
346-
// custom logic for ts-node when used with CommonJS that needs to be evaluated
347-
// so we need to load and evaluate the hook first using the `--require` module API.
348-
const tsNodeLoader = `--require "${tsNode}"`
349-
350-
debug(`using cjs with --require ${tsNode}`)
351-
352-
if (childOptions.env.NODE_OPTIONS) {
353-
childOptions.env.NODE_OPTIONS += ` ${tsNodeLoader}`
354-
} else {
355-
childOptions.env.NODE_OPTIONS = tsNodeLoader
356-
}
334+
debug(`No tsconfig.json found! Attempting to parse file without tsconfig.json.`)
357335
}
336+
}
337+
338+
debug(`using generic ${tsxLoader} for esm and cjs ${userHasTypeScriptInstalled ? 'with TypeScript' : ''}.`)
339+
340+
if (childOptions.env.NODE_OPTIONS) {
341+
childOptions.env.NODE_OPTIONS += ` ${tsxLoader}`
358342
} else {
359-
// Just use Node's built-in ESM support.
360-
// TODO: Consider using userland `esbuild` with Node's --loader API to handle ESM.
361-
debug(`no typescript found, just use regular Node.js`)
343+
childOptions.env.NODE_OPTIONS = tsxLoader
362344
}
363345

364346
const telemetryCtx = encodeTelemetryContext({ context: telemetry.getActiveContextObject(), version: pkg.version })

Diff for: packages/data-context/src/sources/ErrorDataSource.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export class ErrorDataSource {
2424
return null
2525
}
2626

27-
// If we saw a TSError, or a esbuild error we will extract the error location from the message
27+
// If we saw a TransformError, or a esbuild error we will extract the error location from the message
2828
const compilerErrorLocation = source.cypressError.originalError?.compilerErrorLocation
2929

3030
let line: number | null | undefined
@@ -38,7 +38,8 @@ export class ErrorDataSource {
3838
} else {
3939
// Skip any stack trace lines which come from node:internal code
4040
const stackLines = stackUtils.getStackLines(source.cypressError.stack ?? '')
41-
const filteredStackLines = stackLines.filter((stackLine) => !stackLine.includes('node:electron') && !stackLine.includes('node:internal') && !stackLine.includes('source-map-support'))
41+
// we want to filter out any tsx transformation code in the stack to help identify the error
42+
const filteredStackLines = stackLines.filter((stackLine) => !stackLine.includes('node:electron') && !stackLine.includes('node:internal') && !stackLine.includes('source-map-support') && !stackLine.includes('/node_modules/tsx/dist/register'))
4243
const parsedLine = stackUtils.parseStackLine(filteredStackLines[0] ?? '')
4344

4445
if (parsedLine) {

0 commit comments

Comments
 (0)