Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Typescript config invalid with node v22.7.0 with ESM #30099

Merged
merged 8 commits into from
Aug 27, 2024
1 change: 1 addition & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ _Released 8/27/2024 (PENDING)_
**Bugfixes:**

- Fixed an issue where files outside the Cypress project directory were not calculating the bundle output path correctly for the `file:preprocessor`. Addresses [#8599](https://github.com/cypress-io/cypress/issues/8599).
- Fixed an issue where Cypress would not run if Node.js version `22.7.0` was being used with TypeScript and ES Modules. Fixes [#30084](https://github.com/cypress-io/cypress/issues/30084).
- Correctly determines current browser family when choosing between `unload` and `pagehide` options in App Runner. Fixes [#29880](https://github.com/cypress-io/cypress/issues/29880).

**Misc:**
Expand Down
12 changes: 11 additions & 1 deletion packages/data-context/src/data/ProjectConfigIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { autoBindDebug, hasTypeScriptInstalled, toPosix } from '../util'
import _ from 'lodash'
import { pathToFileURL } from 'url'
import os from 'os'
import semver from 'semver'
import type { OTLPTraceExporterCloud } from '@packages/telemetry'
import { telemetry, encodeTelemetryContext } from '@packages/telemetry'

Expand Down Expand Up @@ -58,6 +59,7 @@ export class ProjectConfigIpc extends EventEmitter {

constructor (
readonly nodePath: string | undefined | null,
readonly nodeVersion: string | undefined | null,
readonly projectRoot: string,
readonly configFilePath: string,
readonly configFile: string | false,
Expand Down Expand Up @@ -301,7 +303,15 @@ export class ProjectConfigIpc extends EventEmitter {
// best option that leverages the existing modules we bundle in the binary.
// @see ts-node esm loader https://typestrong.org/ts-node/docs/usage/#node-flags-and-other-tools
// @see Node.js Loader API https://nodejs.org/api/esm.html#customizing-esm-specifier-resolution-algorithm
const tsNodeEsmLoader = `--experimental-specifier-resolution=node --loader ${tsNodeEsm}`
let tsNodeEsmLoader = `--experimental-specifier-resolution=node --loader ${tsNodeEsm}`

// in nodejs 22.7.0, the --experimental-detect-module option is now enabled by default.
// We need to disable it with the --no-experimental-detect-module flag.
// @see https://github.com/cypress-io/cypress/issues/30084
if (this.nodeVersion && semver.gte(this.nodeVersion, '22.7.0')) {
debug(`detected node version ${this.nodeVersion}, adding --no-experimental-detect-module option to child_process NODE_OPTIONS.`)
tsNodeEsmLoader = `${tsNodeEsmLoader} --no-experimental-detect-module`
}

if (childOptions.env.NODE_OPTIONS) {
childOptions.env.NODE_OPTIONS += ` ${tsNodeEsmLoader}`
Expand Down
1 change: 1 addition & 0 deletions packages/data-context/src/data/ProjectConfigManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,7 @@ export class ProjectConfigManager {

this._eventsIpc = new ProjectConfigIpc(
this.options.ctx.coreData.app.nodePath,
this.options.ctx.coreData.app.nodeVersion,
this.options.projectRoot,
this.configFilePath,
this.options.configFile,
Expand Down
2 changes: 2 additions & 0 deletions packages/data-context/src/data/coreDataShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export interface AppDataShape {
browsers: ReadonlyArray<FoundBrowser> | null
projects: ProjectShape[]
nodePath: Maybe<string>
nodeVersion: Maybe<string>
browserStatus: BrowserStatus
browserUserAgent: string | null
relaunchBrowser: boolean
Expand Down Expand Up @@ -195,6 +196,7 @@ export function makeCoreData (modeOptions: Partial<AllModeOptions> = {}): CoreDa
browsers: null,
projects: [],
nodePath: modeOptions.userNodePath,
nodeVersion: modeOptions.userNodeVersion,
browserStatus: 'closed',
browserUserAgent: null,
relaunchBrowser: false,
Expand Down
6 changes: 6 additions & 0 deletions packages/data-context/src/util/hasTypescript.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
export function hasTypeScriptInstalled (projectRoot: string) {
try {
// mocking this module is fairly difficult under unit test. We need to mock this for the ProjectConfigIpc unit tests
// as the scaffolded projects in the data-context package do not install dependencies related to the project.
if (process.env.CYPRESS_INTERNAL_MOCK_TYPESCRIPT_INSTALL === 'true') {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't figure out another way to test this. Not great, but at least gets us what we need

return true
}

require.resolve('typescript', { paths: [projectRoot] })

return true
Expand Down
144 changes: 126 additions & 18 deletions packages/data-context/test/unit/data/ProjectConfigIpc.spec.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,140 @@
import childProcess from 'child_process'
import { expect } from 'chai'
import sinon from 'sinon'
import { scaffoldMigrationProject as scaffoldProject } from '../helper'
import { ProjectConfigIpc } from '../../../src/data/ProjectConfigIpc'

describe('ProjectConfigIpc', () => {
let projectConfigIpc

beforeEach(async () => {
const projectPath = await scaffoldProject('e2e')

projectConfigIpc = new ProjectConfigIpc(
undefined,
projectPath,
'cypress.config.js',
false,
(error) => {},
() => {},
)
})
context('#eventProcessPid', () => {
let projectConfigIpc

afterEach(() => {
projectConfigIpc.cleanupIpc()
})
beforeEach(async () => {
const projectPath = await scaffoldProject('e2e')

projectConfigIpc = new ProjectConfigIpc(
undefined,
undefined,
projectPath,
'cypress.config.js',
false,
(error) => {},
() => {},
)
})

afterEach(() => {
projectConfigIpc.cleanupIpc()
})

context('#eventProcessPid', () => {
it('returns id for child process', () => {
const expectedId = projectConfigIpc._childProcess.pid

expect(projectConfigIpc.childProcessPid).to.eq(expectedId)
})
})

context('forkChildProcess', () => {
const NODE_VERSIONS = ['18.20.4', '20.17.0']
const NODE_VERSIONS_22_7_0_AND_UP = ['22.7.0', '22.11.4']

let projectConfigIpc
let forkSpy

beforeEach(() => {
process.env.CYPRESS_INTERNAL_MOCK_TYPESCRIPT_INSTALL = 'true'
forkSpy = sinon.spy(childProcess, 'fork')
})

afterEach(() => {
delete process.env.CYPRESS_INTERNAL_MOCK_TYPESCRIPT_INSTALL
forkSpy.restore()
projectConfigIpc.cleanupIpc()
})

context('typescript', () => {
[...NODE_VERSIONS, ...NODE_VERSIONS_22_7_0_AND_UP].forEach((nodeVersion) => {
context(`node v${nodeVersion}`, () => {
context('ESM', () => {
it('uses the experimental module loader if ESM is being used with typescript', async () => {
// @ts-expect-error
const projectPath = await scaffoldProject('config-cjs-and-esm/config-with-ts-module')

const MOCK_NODE_PATH = `/Users/foo/.nvm/versions/node/v${nodeVersion}/bin/node`
const MOCK_NODE_VERSION = nodeVersion

projectConfigIpc = new ProjectConfigIpc(
MOCK_NODE_PATH,
MOCK_NODE_VERSION,
projectPath,
'cypress.config.js',
false,
(error) => {},
() => {},
)

expect(forkSpy).to.have.been.calledWith(sinon.match.string, sinon.match.array, sinon.match({
env: {
NODE_OPTIONS: sinon.match('--experimental-specifier-resolution=node --loader'),
},
}))
})

// @see https://github.com/cypress-io/cypress/issues/30084
// at time of writing, 22.11.4 is a node version that does not exist. We are using this version to test the logic for future proofing.
if (NODE_VERSIONS_22_7_0_AND_UP.includes(nodeVersion)) {
it(`additionally adds --no-experimental-detect-module for node versions 22.7.0 and up if ESM is being used with typescript`, async () => {
// @ts-expect-error
const projectPath = await scaffoldProject('config-cjs-and-esm/config-with-ts-module')

const MOCK_NODE_PATH = `/Users/foo/.nvm/versions/node/v${nodeVersion}/bin/node`
const MOCK_NODE_VERSION = nodeVersion

projectConfigIpc = new ProjectConfigIpc(
MOCK_NODE_PATH,
MOCK_NODE_VERSION,
projectPath,
'cypress.config.js',
false,
(error) => {},
() => {},
)

expect(forkSpy).to.have.been.calledWith(sinon.match.string, sinon.match.array, sinon.match({
env: {
NODE_OPTIONS: sinon.match('--no-experimental-detect-module'),
},
}))
})
}
})

context('CommonJS', () => {
it('uses the ts_node commonjs loader if CommonJS is being used with typescript', async () => {
// @ts-expect-error
const projectPath = await scaffoldProject('config-cjs-and-esm/config-with-module-resolution-bundler')

const MOCK_NODE_PATH = `/Users/foo/.nvm/versions/node/v${nodeVersion}/bin/node`
const MOCK_NODE_VERSION = nodeVersion

projectConfigIpc = new ProjectConfigIpc(
MOCK_NODE_PATH,
MOCK_NODE_VERSION,
projectPath,
'cypress.config.js',
false,
(error) => {},
() => {},
)

expect(forkSpy).to.have.been.calledWith(sinon.match.string, sinon.match.array, sinon.match({
env: {
NODE_OPTIONS: sinon.match('--require'),
},
}))
})
})
})
})
})
})
})
2 changes: 2 additions & 0 deletions system-tests/test-binary/node_versions_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe('binary node versions', () => {
'cypress/base:18.16.1',
'cypress/base:20.12.2',
'cypress/base:22.0.0',
'cypress/base:22.7.0',
].forEach(smokeTestDockerImage)
})

Expand All @@ -37,6 +38,7 @@ describe('type: module', () => {
'cypress/base:18.16.1',
'cypress/base:20.12.2',
'cypress/base:22.0.0',
'cypress/base:22.7.0',
].forEach((dockerImage) => {
systemTests.it(`can run in ${dockerImage}`, {
withBinary: true,
Expand Down
Loading