Skip to content

Commit 9b3b023

Browse files
authored
feat: replace ts-node with tsx for parsing user configuration (#31520)
* feat: replace tsnode with tsx for parsing user configuration in all cases * bump ubuntu images from node 18 to 20 as node 18 is not supported in cypress 15 and allows us to not include the 18.19.0 workaround to use --import with tsx inside the ProjectConfigIpc * fix: issues finding tsx on windows as it needs the file:// protocol as absolute drive paths are not recognized as a file protocol in the node context * make sure to filter out stack code correctly for windows * fix: fix flake from windows on reporter menu not expanding (unrelated to this PR and should be merged into develop) * chore: update changelog with all issues tsx cutover closes * fix merge conflicts * chore: add regression tests for cypress projects that previously did not work but now do with tsx * build all binaries * chore: address issues from code review * update changelog * remove todo comment on testing legacy migration with tsx * refactor codeFrame calculation into a util function and add a unit test * updated node versions in project config ipc tests to remove 18 and test threshold / latest values. Removed redundant comments on ts-node.
1 parent 1d2a26a commit 9b3b023

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1539
-438
lines changed

.circleci/workflows.yml

Lines changed: 5 additions & 5 deletions
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-
- 'breaking/remove_angular_17_cursor'
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: [ 'breaking/remove_angular_17_cursor', << 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: [ 'breaking/remove_angular_17_cursor', << 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: [ 'breaking/remove_angular_17_cursor', << 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" != "breaking/remove_angular_17_cursor" ]]; 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

cli/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ _Released 07/01/2025 (PENDING)_
1010
- 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).
1111
- The Cypress configuration wizard for Component Testing supports TypeScript 5.0 or greater. Addresses [#31187](https://github.com/cypress-io/cypress/issues/31187).
1212

13+
**Features:**
14+
15+
- [`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 [#8090](https://github.com/cypress-io/cypress/issues/8090), [#15724](https://github.com/cypress-io/cypress/issues/15724), [#21805](https://github.com/cypress-io/cypress/issues/21805), [#22273](https://github.com/cypress-io/cypress/issues/22273), [#22747](https://github.com/cypress-io/cypress/issues/22747), [#23141](https://github.com/cypress-io/cypress/issues/23141), [#25958](https://github.com/cypress-io/cypress/issues/25958), [#25959](https://github.com/cypress-io/cypress/issues/25959), [#26606](https://github.com/cypress-io/cypress/issues/26606), [#27359](https://github.com/cypress-io/cypress/issues/27359), [#27450](https://github.com/cypress-io/cypress/issues/27450), [#28442](https://github.com/cypress-io/cypress/issues/28442), [#30318](https://github.com/cypress-io/cypress/issues/30318), [#30718](https://github.com/cypress-io/cypress/issues/30718), [#30907](https://github.com/cypress-io/cypress/issues/30907), [#30915](https://github.com/cypress-io/cypress/issues/30915), [#30925](https://github.com/cypress-io/cypress/issues/30925), [#30954](https://github.com/cypress-io/cypress/issues/30954) and [#31185](https://github.com/cypress-io/cypress/issues/31185).
16+
1317
## 14.3.3
1418

1519
_Released 5/6/2025 (PENDING)_

packages/app/cypress/e2e/specs.cy.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,9 @@ describe('App: Specs', () => {
651651
.and('have.attr', 'href', 'https://on.cypress.io/styling-components')
652652

653653
cy.log('should not contain the link if you navigate away and back')
654+
// A bit of a hack, but our cy-in-cy test needs to wait for the reporter to fully render before pressing the "f" key to expand the "Search specs" menu.
655+
// Otherwise, the "f" keypress happens before the event is registered, which causes the "Search Specs" menu to not expand.
656+
cy.get('[data-cy="runnable-header"]').should('be.visible')
654657
cy.get('body').type('f')
655658
cy.get('[data-cy=spec-file-item]').first().click()
656659
cy.get('#spec-runner-header').should('not.contain', 'Review the docs')

packages/data-context/package.json

Lines changed: 2 additions & 0 deletions
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
},

packages/data-context/src/actions/MigrationActions.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from 'path'
33
import debugLib from 'debug'
44
import { fork } from 'child_process'
55
import fs from 'fs-extra'
6+
import os from 'os'
67
import semver from 'semver'
78
import type { ForkOptions } from 'child_process'
89
import assert from 'assert'
@@ -38,7 +39,8 @@ import { hasTypeScriptInstalled, toPosix } from '../util'
3839

3940
const debug = debugLib('cypress:data-context:MigrationActions')
4041

41-
const tsNode = toPosix(require.resolve('@packages/server/lib/plugins/child/register_ts_node'))
42+
// NOTE: need the file:// prefix to avoid https://nodejs.org/api/errors.html#err_unsupported_esm_url_scheme on windows
43+
const tsxCjs = os.platform() === 'win32' ? `file://${toPosix(require.resolve('tsx/cjs'))}` : toPosix(require.resolve('tsx/cjs'))
4244

4345
export function getConfigWithDefaults (legacyConfig: any) {
4446
const newConfig = _.cloneDeep(legacyConfig)
@@ -74,7 +76,7 @@ export function getDiff (oldConfig: any, newConfig: any) {
7476
}, result)
7577
}
7678

77-
export async function processConfigViaLegacyPlugins (projectRoot: string, legacyConfig: LegacyCypressConfigJson): Promise<LegacyCypressConfigJson> {
79+
export async function processConfigViaLegacyPlugins (projectRoot: string, legacyConfig: LegacyCypressConfigJson, nodeVersion: string | undefined | null): Promise<LegacyCypressConfigJson> {
7880
const pluginFile = legacyConfig.pluginsFile
7981
? await getLegacyPluginsCustomFilePath(projectRoot, legacyConfig.pluginsFile)
8082
: await tryGetDefaultLegacyPluginsFile(projectRoot)
@@ -99,20 +101,22 @@ export async function processConfigViaLegacyPlugins (projectRoot: string, legacy
99101
const configProcessArgs = ['--projectRoot', projectRoot, '--file', cwd]
100102
const CHILD_PROCESS_FILE_PATH = require.resolve('@packages/server/lib/plugins/child/require_async_child')
101103

102-
// use ts-node if they've got typescript installed
104+
// use tsx if they've got typescript installed
103105
// this matches the 9.x behavior, which is what we want for
104106
// processing legacy pluginsFile (we never supported `"type": "module") in 9.x.
105107
if (hasTypeScriptInstalled(projectRoot)) {
106-
const tsNodeLoader = `--require "${tsNode}"`
108+
let tsxLoader = nodeVersion && semver.lt(nodeVersion, '20.6.0') ? `--loader ${tsxCjs}` : `--import ${tsxCjs}`
109+
110+
debug(`using generic ${tsxLoader} for esm and cjs with TypeScript for legacy migration.`)
107111

108112
if (!childOptions.env) {
109113
childOptions.env = {}
110114
}
111115

112116
if (childOptions.env.NODE_OPTIONS) {
113-
childOptions.env.NODE_OPTIONS += ` ${tsNodeLoader}`
117+
childOptions.env.NODE_OPTIONS += ` ${tsxLoader}`
114118
} else {
115-
childOptions.env.NODE_OPTIONS = tsNodeLoader
119+
childOptions.env.NODE_OPTIONS = tsxLoader
116120
}
117121
}
118122

@@ -319,7 +323,7 @@ export class MigrationActions {
319323

320324
async setLegacyConfigForMigration (config: LegacyCypressConfigJson) {
321325
assert(this.ctx.currentProject)
322-
const legacyConfigForMigration = await processConfigViaLegacyPlugins(this.ctx.currentProject, config)
326+
const legacyConfigForMigration = await processConfigViaLegacyPlugins(this.ctx.currentProject, config, this.ctx.coreData.app.nodeVersion)
323327

324328
this.ctx.update((coreData) => {
325329
coreData.migration.legacyConfigForMigration = legacyConfigForMigration

packages/data-context/src/data/ProjectConfigIpc.ts

Lines changed: 58 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,25 @@ 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+
// NOTE: need the file:// prefix to avoid https://nodejs.org/api/errors.html#err_unsupported_esm_url_scheme on windows
24+
const tsx = os.platform() === 'win32' ? `file://${toPosix(require.resolve('tsx'))}` : toPosix(require.resolve('tsx'))
2525

2626
export type IpcHandler = (ipc: ProjectConfigIpc) => void
2727

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

263263
private forkConfigProcess () {
264264
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')
265+
// we do NOT want telemetry enabled within our cy-in-cy tests as it isn't configured to handled it
266+
const env = _.omit(process.env, 'CYPRESS_INTERNAL_E2E_TESTING_SELF', 'CYPRESS_INTERNAL_ENABLE_TELEMETRY')
269267

270268
env.NODE_OPTIONS = process.env.ORIGINAL_NODE_OPTIONS || ''
271269

@@ -279,86 +277,72 @@ export class ProjectConfigIpc extends EventEmitter {
279277
if (inspector.url()) {
280278
childOptions.execArgv = _.chain(process.execArgv.slice(0))
281279
.remove('--inspect-brk')
280+
// 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
281+
// 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
282+
// as it will NOT execute that process until you attach the debugger to it.
282283
.push(`--inspect=${process.debugPort + 1}`)
283284
.value()
284285
}
285286

286-
debug('fork child process %o', { CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions: _.omit(childOptions, 'env') })
287-
288-
let isProjectUsingESModules = false
287+
/**
288+
* 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.
289+
* 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.
290+
* 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
291+
* via a child process, which lead to clashes and issues (outlined in the comments below).
292+
* 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
293+
* 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)
294+
*
295+
* Attempted workarounds with ts-node and node: @see https://github.com/cypress-io/cypress/pull/28709
296+
* Example continued end user issues: @see https://github.com/cypress-io/cypress/issues/30954 and @see https://github.com/cypress-io/cypress/issues/30925
297+
* Spike into ts-node alternatives (a lot of useful comments on tsx): @see https://github.com/cypress-io/cypress/issues/30426
298+
* feature issue to replace ts-node as our end user TypeScript loader: @see https://github.com/cypress-io/cypress/issues/31185
299+
*
300+
* 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
301+
* 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.
302+
* We can use tsx to load just about anything, including JavaScript files (@see https://github.com/privatenumber/ts-runtime-comparison)!
303+
*/
289304

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-
}
305+
debug('fork child process %o', { CHILD_PROCESS_FILE_PATH, configProcessArgs, childOptions: _.omit(childOptions, 'env') })
300306

301307
if (!childOptions.env) {
302308
childOptions.env = {}
303309
}
304310

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)) {
311+
/**
312+
* use --import for node versions
313+
* 20.6.0 and above for 20.x.x as --import is supported
314+
* use --loader for node under 20.6.0 for 20.x.x
315+
* @see https://tsx.is/dev-api/node-cli#node-js-cli
316+
*/
317+
let tsxLoader = this.nodeVersion && semver.lt(this.nodeVersion, '20.6.0') ? `--loader ${tsx}` : `--import ${tsx}`
318+
319+
// If they've got TypeScript installed, we can use tsx for CommonJS and ESM.
320+
// @see https://tsx.is/dev-api/node-cli#node-js-cli
321+
const userHasTypeScriptInstalled = hasTypeScriptInstalled(this.projectRoot)
322+
323+
if (userHasTypeScriptInstalled) {
309324
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-
}
325+
326+
// TODO: get the tsconfig.json that applies to the users cypress.config.ts file
327+
// right now, we are just using the tsconfig.json we find in the project root
328+
const tsConfig = getTsconfig(this.projectRoot)
329+
330+
if (tsConfig) {
331+
debug(`tsconfig.json found at ${tsConfig.path}`)
332+
childOptions.env.TSX_TSCONFIG_PATH = tsConfig.path
333+
334+
debugVerbose(`tsconfig.json parsed as follows: %o`, tsConfig.config)
341335
} 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-
}
336+
debug(`No tsconfig.json found! Attempting to parse file without tsconfig.json.`)
357337
}
338+
}
339+
340+
debug(`using generic ${tsxLoader} for esm and cjs ${userHasTypeScriptInstalled ? 'with TypeScript' : ''}.`)
341+
342+
if (childOptions.env.NODE_OPTIONS) {
343+
childOptions.env.NODE_OPTIONS += ` ${tsxLoader}`
358344
} 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`)
345+
childOptions.env.NODE_OPTIONS = tsxLoader
362346
}
363347

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

0 commit comments

Comments
 (0)