Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cli/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
<!-- See ../guides/writing-the-cypress-changelog.md for details on writing the changelog. -->
## 15.18.2
## 15.19.0

**Performance:**

- Fixed a regression in [14.0.0](#14-0-0) where each message sent from the Cypress server to a Chromium-based browser (including Electron) leaked a small amount of browser memory until the end of the spec. During long, command- or network-heavy specs, this buildup could crash the browser (`We detected that the Chrome Renderer process just crashed`). Fixes [#34226](https://github.com/cypress-io/cypress/issues/34226).

**Features:**

- Added support for TypeScript 7 when preprocessing TypeScript spec and support files, and the component testing setup wizard now accepts TypeScript 7. Addresses [#34258](https://github.com/cypress-io/cypress/issues/34258). Addressed in [#34277](https://github.com/cypress-io/cypress/pull/34277).

**Bugfixes:**

- Fixed an issue where, on Windows, enhancing a test failure stack could throw a secondary `TypeError: Cannot read properties of undefined (reading 'replaceAll')` and mask the original error. Fixed in [#34252](https://github.com/cypress-io/cypress/pull/34252).
Expand Down
134 changes: 82 additions & 52 deletions npm/webpack-batteries-included-preprocessor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,40 @@ class TypeScriptNotFoundError extends Error {

const typescriptExtensionRegex = /\.m?tsx?$/

// Shared by the JS spec rule and the TypeScript 7+ rule (TS 7 has no compiler
// API for ts-loader). `typescript: true` adds preset-typescript.
const getBabelLoaderOptions = ({ typescript = false }: { typescript?: boolean } = {}) => {
return {
plugins: [
// Legacy decorators + metadata, ordered before class-properties, to keep
// emitDecoratorMetadata parity with ts-loader.
...(typescript ? [
require.resolve('babel-plugin-transform-typescript-metadata'),
[require.resolve('@babel/plugin-proposal-decorators'), { version: 'legacy' }],
] : []),
...[
'babel-plugin-add-module-exports',
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-object-rest-spread',
].map((plugin) => require.resolve(plugin)),
[require.resolve('@babel/plugin-transform-runtime'), {
absoluteRuntime: path.dirname(require.resolve('@babel/runtime/package')),
}],
],
presets: [
// the chrome version should be synced with
// packages/web-config/webpack.config.base.ts and
// packages/server/lib/browsers/chrome.ts
[require.resolve('@babel/preset-env'), { modules: 'commonjs', targets: { 'chrome': '64' } }],
require.resolve('@babel/preset-react'),
// Last preset runs first: strip types before preset-env/react.
...(typescript ? [require.resolve('@babel/preset-typescript')] : []),
],
configFile: false,
babelrc: false,
}
}

const hasTsLoader = (rules: any[]) => {
return rules.some((rule) => {
if (!rule.use || !Array.isArray(rule.use)) return false
Expand Down Expand Up @@ -94,51 +128,67 @@ const addTypeScriptConfig = (file: { filePath: string }, options: {
return options
}

// tsx parses the moduleResolution default to node10 as well as moduleResolution="node" to node10
// ts-loader struggles to validate the node10 moduleResolution option depending on the version of typescript used,
// so we set it to node which is the same as node 10. @see https://www.typescriptlang.org/tsconfig/#moduleResolution.
if (configFile?.config?.compilerOptions?.moduleResolution === 'node10') {
configFile.config.compilerOptions.moduleResolution = 'node'
}

const tsVersion = webpackPreprocessor.getResolvedTypescriptVersion(typeof typeScriptPath === 'string' ? typeScriptPath : undefined)

let isLessThanTs6
let isGreaterThanOrEqualToTs6
let isGreaterThanOrEqualToTs7

if (tsVersion && semver.valid(tsVersion)) {
isLessThanTs6 = semver.lt(tsVersion, '6.0.0-0')
isGreaterThanOrEqualToTs6 = !isLessThanTs6
isGreaterThanOrEqualToTs7 = semver.gte(tsVersion, '7.0.0-0')
}

if (isGreaterThanOrEqualToTs7) {
// TypeScript 7 ships no JavaScript compiler API, so ts-loader crashes.
// Transpile with Babel instead, matching the prior transpile-only behavior.
webpackOptions.module.rules.push({
test: typescriptExtensionRegex,
exclude: [/node_modules/],
use: [
{
loader: require.resolve('babel-loader'),
options: getBabelLoaderOptions({ typescript: true }),
},
],
})
} else {
// tsx parses the moduleResolution default to node10 as well as moduleResolution="node" to node10
// ts-loader struggles to validate the node10 moduleResolution option depending on the version of typescript used,
// so we set it to node which is the same as node 10. @see https://www.typescriptlang.org/tsconfig/#moduleResolution.
if (configFile?.config?.compilerOptions?.moduleResolution === 'node10') {
configFile.config.compilerOptions.moduleResolution = 'node'
}

webpackOptions.module.rules.push({
test: typescriptExtensionRegex,
exclude: [/node_modules/],
use: [
{
loader: require.resolve('ts-loader'),
options: {
...(isGreaterThanOrEqualToTs6 ? {
configFile: configFile?.path,
} : {}),
compiler: typeScriptPath,
...(isLessThanTs6 ? {
compilerOptions: configFile?.config?.compilerOptions,
} : {}),
logLevel: 'error',
silent: true,
transpileOnly: true,
},
},
],
})
}

// Use the v3 plugin for TS < 6 to remain passive; TS 6+ uses v4, which
// tolerates the missing-baseUrl shape recommended by TypeScript 6+.
// v3 plugin for TS < 6; TS 6+ uses v4, which tolerates the missing-baseUrl shape.
const TsconfigPathsPlugin = isLessThanTs6
? require('tsconfig-paths-webpack-plugin-v3')
: require('tsconfig-paths-webpack-plugin')

webpackOptions.module.rules.push({
test: typescriptExtensionRegex,
exclude: [/node_modules/],
use: [
{
loader: require.resolve('ts-loader'),
options: {
...(isGreaterThanOrEqualToTs6 ? {
configFile: configFile?.path,
} : {}),
compiler: typeScriptPath,
...(isLessThanTs6 ? {
compilerOptions: configFile?.config?.compilerOptions,
} : {}),
logLevel: 'error',
silent: true,
transpileOnly: true,
},
},
],
})

webpackOptions.resolve.extensions = webpackOptions.resolve.extensions.concat(['.ts', '.tsx'])
webpackOptions.resolve.extensionAlias = webpackOptions.resolve.extensionAlias || { '.js': ['.ts', '.js'], '.mjs': ['.mts', '.mjs'] }

Expand Down Expand Up @@ -179,27 +229,7 @@ const getDefaultWebpackOptions = () => {
type: 'javascript/auto',
use: [{
loader: require.resolve('babel-loader'),
options: {
plugins: [
...[
'babel-plugin-add-module-exports',
'@babel/plugin-transform-class-properties',
'@babel/plugin-transform-object-rest-spread',
].map((plugin) => require.resolve(plugin)),
[require.resolve('@babel/plugin-transform-runtime'), {
absoluteRuntime: path.dirname(require.resolve('@babel/runtime/package')),
}],
],
presets: [
// the chrome version should be synced with
// packages/web-config/webpack.config.base.ts and
// packages/server/lib/browsers/chrome.ts
[require.resolve('@babel/preset-env'), { modules: 'commonjs', targets: { 'chrome': '64' } }],
require.resolve('@babel/preset-react'),
],
configFile: false,
babelrc: false,
},
options: getBabelLoaderOptions(),
}],
}, {
test: /\.coffee$/,
Expand Down
5 changes: 5 additions & 0 deletions npm/webpack-batteries-included-preprocessor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
},
"dependencies": {
"@babel/core": "^7.28.0",
"@babel/plugin-proposal-decorators": "^7.28.0",
"@babel/plugin-transform-class-properties": "^7.27.1",
"@babel/plugin-transform-object-rest-spread": "^7.28.0",
"@babel/plugin-transform-runtime": "^7.28.0",
"@babel/preset-env": "^7.28.0",
"@babel/preset-react": "^7.27.1",
"@babel/preset-typescript": "^7.27.1",
"@babel/runtime": "^7.28.2",
"babel-loader": "^10.0.0",
"babel-plugin-add-module-exports": "^1.0.2",
"babel-plugin-transform-typescript-metadata": "^0.4.0",
"buffer": "^6.0.3",
"coffee-loader": "^4.0.0",
"coffeescript": "2.6.0",
Expand All @@ -48,8 +51,10 @@
"globals": "^15.12.0",
"jiti": "^2.4.2",
"react": "^16.13.1",
"reflect-metadata": "^0.2.2",
"typescript": "^6.0.0",
"typescript-v5": "npm:typescript@~5.4.5",
"typescript-v7": "npm:typescript@^7.0.0",
"vitest": "^3.2.4"
},
"peerDependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,25 @@ describe('webpack-batteries-included-preprocessor features', () => {
}
})
})

describe('with typescript 7 (native compiler)', () => {
const options = { typescript: require.resolve('typescript-v7') }

it('handles typescript (and tsconfig paths) via babel', async () => {
await runAndEval('ts_spec.ts', { ...options })
})

it('handles tsx via babel', async () => {
await runAndEval('tsx_spec.tsx', { ...options })
})

it('handles importing .ts and .tsx via babel', async () => {
await runAndEval('typescript_imports_spec.js', { ...options })
})

// Babel must still emit decorator metadata for TypeScript 7, as ts-loader did.
it('emits decorator metadata (emitDecoratorMetadata parity) via babel', async () => {
await runAndEval('typescript_decorators_spec.ts', { ...options })
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'reflect-metadata'
import { expect } from 'chai'

class Dep {}

function Injectable () {
return (_target: any) => {}
}

@Injectable()
class Service {
constructor (public dep: Dep) {}
}

// The constructor param type must be emitted as runtime metadata.
const paramTypes = Reflect.getMetadata('design:paramtypes', Service) as unknown[]

expect(paramTypes).to.deep.equal([Dep])
Original file line number Diff line number Diff line change
Expand Up @@ -303,5 +303,81 @@ describe('webpack-batteries-included-preprocessor', () => {
expect(tsLoader.options.compilerOptions).toBeUndefined()
})
})

describe('with typescript 7 or newer', () => {
const fixtureTsconfigPath = path.resolve(__dirname, '../../test/fixtures/tsconfig.json')

const entryName = (entry: unknown) => (Array.isArray(entry) ? entry[0] : entry) as string

it.each(['7.0.0', '7.0.2', '7.1.0-beta', '8.0.0'] as const)(
'when resolved version is %s, uses babel-loader with @babel/preset-typescript instead of ts-loader',
(resolvedVersion) => {
vi.mocked(webpackPreprocessor.getResolvedTypescriptVersion).mockReturnValue(resolvedVersion)

vi.mocked(getTsConfig).getTsconfig.mockReturnValue({
config: {
compilerOptions: {
module: 'ESNext',
moduleResolution: 'Bundler',
},
},
path: fixtureTsconfigPath,
})

const preprocessorCB = preprocessor({
typescript: true,
webpackOptions,
})

preprocessorCB({
filePath: 'foo.ts',
outputPath: '.js',
} as any)

const rule = webpackOptions.module.rules[0]
const loader = rule.use[0]
const presets = loader.options.presets.map(entryName)
const plugins = loader.options.plugins.map(entryName)

expect(rule.test.toString()).toEqual(/\.m?tsx?$/.toString())
expect(loader.loader).toContain('babel-loader')
expect(loader.loader).not.toContain('ts-loader')
expect(presets.some((p: string) => p.includes('preset-typescript'))).toBe(true)
// emitDecoratorMetadata parity: metadata transform must precede the decorators transform
const metadataIdx = plugins.findIndex((p: string) => p.includes('babel-plugin-transform-typescript-metadata'))
const decoratorsIdx = plugins.findIndex((p: string) => p.includes('plugin-proposal-decorators'))

expect(metadataIdx).toBeGreaterThanOrEqual(0)
expect(decoratorsIdx).toBeGreaterThan(metadataIdx)
},
)

it('still registers the tsconfig paths plugin so path aliases resolve', () => {
vi.mocked(webpackPreprocessor.getResolvedTypescriptVersion).mockReturnValue('7.0.2')

vi.mocked(getTsConfig).getTsconfig.mockReturnValue({
config: {
compilerOptions: {
module: 'ESNext',
moduleResolution: 'Bundler',
},
},
path: fixtureTsconfigPath,
})

const preprocessorCB = preprocessor({
typescript: true,
webpackOptions,
})

preprocessorCB({
filePath: 'foo.ts',
outputPath: '.js',
} as any)

expect(webpackOptions.resolve.extensions).toEqual(expect.arrayContaining(['.ts', '.tsx']))
expect(webpackOptions.resolve.plugins).toHaveLength(1)
})
})
})
})
2 changes: 1 addition & 1 deletion packages/scaffold-config/src/dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export const WIZARD_DEPENDENCY_TYPESCRIPT = {
package: 'typescript',
installer: 'typescript',
description: 'TypeScript is a language for application-scale JavaScript',
minVersion: '^5.0.0 || ^6.0.0',
minVersion: '^5.0.0 || ^6.0.0 || ^7.0.0',
} as const

export const WIZARD_DEPENDENCY_VITE = {
Expand Down
5 changes: 5 additions & 0 deletions packages/scaffold-config/test/dependencies.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ describe('WIZARD_DEPENDENCY_TYPESCRIPT', () => {
expect(satisfies('6.0.2')).toBe(true)
})

it('accepts TypeScript 7.x per minVersion', () => {
expect(satisfies('7.0.0')).toBe(true)
expect(satisfies('7.0.2')).toBe(true)
})

it('rejects TypeScript below 5', () => {
expect(satisfies('4.9.5')).toBe(false)
})
Expand Down
7 changes: 7 additions & 0 deletions system-tests/projects/ts-proj-7/cypress.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { defineConfig } from 'cypress'

export default defineConfig({
e2e: {
supportFile: false,
},
})
9 changes: 9 additions & 0 deletions system-tests/projects/ts-proj-7/cypress/e2e/app.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { add } from './math'

type Nums = { a: number, b: number }

it('compiles and runs a TypeScript spec under typescript@7', () => {
const nums: Nums = { a: 1, b: 2 }

expect(add(nums.a, nums.b)).to.eq(3)
})
19 changes: 19 additions & 0 deletions system-tests/projects/ts-proj-7/cypress/e2e/decorators.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import 'reflect-metadata'

class Dep {}

function Injectable () {
return (_target: any) => {}
}

@Injectable()
class Service {
constructor (public dep: Dep) {}
}

// Babel must still emit decorator metadata for typescript@7.
it('preserves emitDecoratorMetadata under typescript@7', () => {
const paramTypes = Reflect.getMetadata('design:paramtypes', Service)

expect(paramTypes).to.deep.eq([Dep])
})
Loading
Loading