Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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. The component testing setup wizard now accepts TypeScript 7 as well. 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. `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', () => {
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,31 @@
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])

function Field () {
return (_target: any, _propertyKey: string) => {}
}

class Model {
@Field() name: string = ''
}

// A decorated class field (property-decorator + class-properties) must emit its design:type.
const fieldType = Reflect.getMetadata('design:type', Model.prototype, 'name')

expect(fieldType).to.equal(String)
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.1",
"typescript": "^5.6.3",
"typescript": "^7.0.0",
"vite": "^8.0.7"
}
}
Loading
Loading