diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index d7db01834612..2ddf860e64de 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -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:** diff --git a/packages/data-context/src/data/ProjectConfigIpc.ts b/packages/data-context/src/data/ProjectConfigIpc.ts index a1dd911110dc..d95f7c687c8c 100644 --- a/packages/data-context/src/data/ProjectConfigIpc.ts +++ b/packages/data-context/src/data/ProjectConfigIpc.ts @@ -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' @@ -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, @@ -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}` diff --git a/packages/data-context/src/data/ProjectConfigManager.ts b/packages/data-context/src/data/ProjectConfigManager.ts index 3fd333242e4a..5b8fd8d68363 100644 --- a/packages/data-context/src/data/ProjectConfigManager.ts +++ b/packages/data-context/src/data/ProjectConfigManager.ts @@ -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, diff --git a/packages/data-context/src/data/coreDataShape.ts b/packages/data-context/src/data/coreDataShape.ts index 0a470cd132e5..3e37c90a35ee 100644 --- a/packages/data-context/src/data/coreDataShape.ts +++ b/packages/data-context/src/data/coreDataShape.ts @@ -75,6 +75,7 @@ export interface AppDataShape { browsers: ReadonlyArray | null projects: ProjectShape[] nodePath: Maybe + nodeVersion: Maybe browserStatus: BrowserStatus browserUserAgent: string | null relaunchBrowser: boolean @@ -195,6 +196,7 @@ export function makeCoreData (modeOptions: Partial = {}): CoreDa browsers: null, projects: [], nodePath: modeOptions.userNodePath, + nodeVersion: modeOptions.userNodeVersion, browserStatus: 'closed', browserUserAgent: null, relaunchBrowser: false, diff --git a/packages/data-context/src/util/hasTypescript.ts b/packages/data-context/src/util/hasTypescript.ts index f87fb2bf050b..0db290763ba7 100644 --- a/packages/data-context/src/util/hasTypescript.ts +++ b/packages/data-context/src/util/hasTypescript.ts @@ -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') { + return true + } + require.resolve('typescript', { paths: [projectRoot] }) return true diff --git a/packages/data-context/test/unit/data/ProjectConfigIpc.spec.ts b/packages/data-context/test/unit/data/ProjectConfigIpc.spec.ts index 1a8bd720e041..70117aa1b41a 100644 --- a/packages/data-context/test/unit/data/ProjectConfigIpc.spec.ts +++ b/packages/data-context/test/unit/data/ProjectConfigIpc.spec.ts @@ -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'), + }, + })) + }) + }) + }) + }) + }) + }) }) diff --git a/system-tests/test-binary/node_versions_spec.ts b/system-tests/test-binary/node_versions_spec.ts index f19448b2595c..32c0d7574a2b 100644 --- a/system-tests/test-binary/node_versions_spec.ts +++ b/system-tests/test-binary/node_versions_spec.ts @@ -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) }) @@ -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,