@nx/cypress 23.3.0-beta.7 → 23.3.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  [![Semantic Release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](<>)
13
13
  [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
14
14
  [![Join us on the Official Nx Discord Server](https://img.shields.io/discord/1143497901675401286?label=discord)](https://go.nx.dev/community)
15
- [![Nx Sandboxing](https://staging.nx.app/workspaces/62d013ea0852fe0a2df74438/sandbox-badge.svg)](https://nx.dev/docs/features/ci-features/sandboxing)
15
+ [![Hours saved](https://img.shields.io/endpoint?url=https%3A%2F%2Fstaging.nx.app%2Fnx-cloud%2Fbadge%2F62d013ea0852fe0a2df74438%2Fhours-saved.json)](https://nx.dev/nx-cloud)
16
16
 
17
17
  </div>
18
18
 
@@ -46,7 +46,8 @@ function isWebpackComponentTesting(component) {
46
46
  if (usesNxComponentTestingPreset(component)) {
47
47
  // @nx/remix is the only vite-based Nx CT preset; resolve the import bound
48
48
  // to the call so an unrelated remix reference elsewhere can't misclassify.
49
- return getComponentTestingPresetImport(component) !== NX_VITE_CT_PRESET;
49
+ return ((0, migrations_1.getComponentTestingPresetImport)(component.getSourceFile()) !==
50
+ NX_VITE_CT_PRESET);
50
51
  }
51
52
  // Hand-written config: migrate only when it has an inline devServer framework.
52
53
  return hasInlineDevServerFramework(component);
@@ -71,43 +72,6 @@ function getComponentProperty(component, name) {
71
72
  ts ??= (0, internal_1.ensureTypescript)();
72
73
  return (0, tsquery_1.query)(component, 'PropertyAssignment').filter((property) => ts.isIdentifier(property.name) && property.name.text === name);
73
74
  }
74
- // Resolves the module specifier that binds the `nxComponentTestingPreset`
75
- // identifier used inside `component`, covering both ESM `import` and CJS
76
- // `require` cypress configs. Returns null when no such binding is found.
77
- function getComponentTestingPresetImport(component) {
78
- ts ??= (0, internal_1.ensureTypescript)();
79
- for (const statement of component.getSourceFile().statements) {
80
- if (ts.isImportDeclaration(statement) && statement.importClause) {
81
- const namedBindings = statement.importClause.namedBindings;
82
- if (namedBindings &&
83
- ts.isNamedImports(namedBindings) &&
84
- ts.isStringLiteral(statement.moduleSpecifier) &&
85
- namedBindings.elements.some((element) => element.name.text === 'nxComponentTestingPreset')) {
86
- return statement.moduleSpecifier.text;
87
- }
88
- }
89
- if (ts.isVariableStatement(statement)) {
90
- for (const declaration of statement.declarationList.declarations) {
91
- const initializer = declaration.initializer;
92
- if (!initializer ||
93
- !ts.isCallExpression(initializer) ||
94
- !ts.isIdentifier(initializer.expression) ||
95
- initializer.expression.text !== 'require' ||
96
- !ts.isObjectBindingPattern(declaration.name)) {
97
- continue;
98
- }
99
- const moduleSpecifier = initializer.arguments[0];
100
- if (moduleSpecifier &&
101
- ts.isStringLiteral(moduleSpecifier) &&
102
- declaration.name.elements.some((element) => ts.isIdentifier(element.name) &&
103
- element.name.text === 'nxComponentTestingPreset')) {
104
- return moduleSpecifier.text;
105
- }
106
- }
107
- }
108
- }
109
- return null;
110
- }
111
75
  function setsJustInTimeCompile(component) {
112
76
  return getComponentProperty(component, 'justInTimeCompile').length > 0;
113
77
  }
@@ -139,7 +139,50 @@ it('renders on mobile', { viewportWidth: 375 }, () => {
139
139
 
140
140
  `cy.getCookie()`, `cy.getCookies()`, `cy.getAllCookies()`, `cy.getAllLocalStorage()` and `cy.getAllSessionStorage()` are queries: they retry assertions chained with `.should()` and follow `defaultCommandTimeout` (4000ms) instead of `responseTimeout` (30000ms). A `.then()` callback still runs once and does not retry. Change nothing unless a test fails; then move the assertion from `.then()` onto `.should()` so it retries, or pass `{ timeout }` when a cookie read needs longer than 4 seconds.
141
141
 
142
- The deterministic migration renamed `Cypress.Commands.overwrite()` to `overwriteQuery()` for those five names. Adapt each renamed callback: make it a `function` expression, call `originalFn.call(this, ...)`, and return a function that computes the result from the subject, not a chainable. Cypress calls both the callback and the original query with the command as `this`, so an arrow callback fails to typecheck (TS2684) and throws at runtime. The paired `adapt-query-command-overwrites` instructions show the rewrites.
142
+ The deterministic migration renamed `Cypress.Commands.overwrite()` to `overwriteQuery()` for those five names; `<advisory_context>` lists each renamed call. Do not re-apply the rename. Adapt each renamed callback to the query contract: `overwriteQuery` calls the callback with the command as `this`, the original query function as the first argument, and then the arguments the test passed, and the callback must return a function that takes the subject. Cypress calls that returned function, possibly many times while retrying, to compute the result. It must not return a chainable, call `cy.*` commands or use `.then()`. The original query reads `this` as well, so every callback must be a `function` expression that calls `originalFn.call(this, ...)`. An arrow callback, or a plain `originalFn(...)` call, fails to typecheck (TS2684, `QueryFnWithOriginalFn` declares `this: Command`) and throws at runtime. Convert arrow callbacks even when they only forward their arguments:
143
+
144
+ **Before:**
145
+
146
+ ```ts
147
+ Cypress.Commands.overwriteQuery('getCookie', (originalFn, name, options) => {
148
+ return originalFn(name, { ...options, log: false });
149
+ });
150
+ ```
151
+
152
+ **After:**
153
+
154
+ ```ts
155
+ Cypress.Commands.overwriteQuery(
156
+ 'getCookie',
157
+ function (originalFn, name, options) {
158
+ return originalFn.call(this, name, { ...options, log: false });
159
+ }
160
+ );
161
+ ```
162
+
163
+ Rewrite callbacks that post-process the result. Get the inner function from `originalFn`, then return a function that calls it with the subject and transforms the value:
164
+
165
+ **Before:**
166
+
167
+ ```ts
168
+ Cypress.Commands.overwriteQuery('getCookies', (originalFn, options) => {
169
+ return originalFn(options).then((cookies) =>
170
+ cookies.filter((cookie) => cookie.name.startsWith('app.'))
171
+ );
172
+ });
173
+ ```
174
+
175
+ **After:**
176
+
177
+ ```ts
178
+ Cypress.Commands.overwriteQuery('getCookies', function (originalFn, options) {
179
+ const innerFn = originalFn.call(this, options);
180
+ return (subject) =>
181
+ innerFn(subject).filter((cookie) => cookie.name.startsWith('app.'));
182
+ });
183
+ ```
184
+
185
+ Callbacks that call other `cy.*` commands, use `cy.wrap()`, or run asynchronous work cannot become queries. Move that logic into a separate custom command added with `Cypress.Commands.add()` and update the specs that relied on the overwritten behavior.
143
186
 
144
187
  ## Step 7: Review behavior changes that need no code by default
145
188
 
@@ -155,7 +198,7 @@ Flag these, and change code only when a test fails:
155
198
  ## Step 8: Component testing
156
199
 
157
200
  - Angular: `cypress/angular` mounts with zoneless change detection on Cypress 16, for zone-based apps too. A template no longer re-renders after a plain property mutation on the component instance, so state that tests assert on in the DOM must be signal-based. The `autoSpyOutputs` and `autoDetectChanges` mount options no longer exist: remove `autoDetectChanges`, and replace `autoSpyOutputs: true` with explicit spies passed through `componentProperties` (for example `{ saved: createOutputSpy('savedSpy') }`). Cypress officially supports Angular 21 and later; Angular 20 runs with a warning. The harness bootstraps through `@angular/platform-browser/testing`, so `@angular/platform-browser-dynamic` can be uninstalled when nothing else imports it. The deprecated `@cypress/angular-zoneless` npm package was removed and its imports rewritten to `cypress/angular` by the deterministic migration; remove any leftover reference to it.
158
- - Vite: Cypress 16 component testing runs on Vite 8 only, and `@cypress/vite-dev-server` 8 declares it as a peer. Bump `vite` and any `@vitejs/*` plugin that still pins an older major. The package update only sees the Vite installed at the workspace root, so check projects that declare their own `vite` in a project-level `package.json`.
201
+ - Vite: Cypress 16 component testing runs on Vite 8 only, and `@cypress/vite-dev-server` 8 declares it as a peer. The deterministic migration kept the workspace on Cypress 15 only when a Vite component testing config, or a config whose bundler could not be determined statically, resolved an installed Vite below 8. It cannot see a `vite` that is declared but not installed. It also does not check a Cypress config with a custom name that no `@nx/cypress:cypress` target references, such as one run through `nx:run-commands`. Bump any `vite` below 8 in the root or a project `package.json`, and any `@vitejs/*` plugin that still pins an older major. A Vite component testing config the migration did not check must resolve Vite 8 from its directory.
159
202
  - Next.js: Cypress's `next` dev server framework requires Next.js 15.0.4 or later. The `@nx/next/plugins/component-testing` preset runs the `react` framework over a webpack config Nx builds, so a Cypress config on that preset does not use the dropped adapter. Only a hand-written config with `devServer: { framework: 'next' }` needs the Next.js bump.
160
203
  - Electron: the bundled Electron browser is deprecated. Cypress prints a warning; set `defaultBrowser: 'chrome'` (or another installed browser) in the Cypress config, or pass `--browser`, when the warning matters in CI.
161
204
 
@@ -53,7 +53,7 @@ async function updateCypress16ConfigOptions(tree) {
53
53
  if (!isConfigProperty(property)) {
54
54
  continue;
55
55
  }
56
- const name = getPropertyName(property.name);
56
+ const name = (0, migrations_1.getPropertyName)(property.name);
57
57
  if (name === null) {
58
58
  continue;
59
59
  }
@@ -108,7 +108,7 @@ async function updateCypress16ConfigOptions(tree) {
108
108
  // Removed options whose value carried intent the replacement needs. A
109
109
  // shorthand value is unknown, so it gets the follow-up too.
110
110
  function getRemovalFollowUp(property) {
111
- const name = getPropertyName(property.name);
111
+ const name = (0, migrations_1.getPropertyName)(property.name);
112
112
  if (name === 'execTimeout') {
113
113
  return `removed \`${property.getText()}\`; \`cy.exec()\` is gone, set \`taskTimeout\` if the replacement \`cy.task()\` needs more than the 60000ms default`;
114
114
  }
@@ -156,7 +156,7 @@ function getOptionBlocks(config) {
156
156
  for (const block of topLevel) {
157
157
  for (const property of block.properties) {
158
158
  if (!isConfigProperty(property) ||
159
- !TESTING_TYPE_BLOCKS.includes(getPropertyName(property.name))) {
159
+ !TESTING_TYPE_BLOCKS.includes((0, migrations_1.getPropertyName)(property.name))) {
160
160
  continue;
161
161
  }
162
162
  const nested = (0, config_1.resolveObjectLiteral)(ts.isPropertyAssignment(property)
@@ -175,24 +175,11 @@ function isNxPresetCall(expression) {
175
175
  NX_PRESET_CALLS.includes(expression.expression.text));
176
176
  }
177
177
  function hasProperty(block, name) {
178
- return block.properties.some((property) => isConfigProperty(property) && getPropertyName(property.name) === name);
178
+ return block.properties.some((property) => isConfigProperty(property) && (0, migrations_1.getPropertyName)(property.name) === name);
179
179
  }
180
180
  function isConfigProperty(node) {
181
181
  return (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node));
182
182
  }
183
- // A computed identifier (`[key]: value`) resolves at runtime, so it is skipped.
184
- function getPropertyName(name) {
185
- if (ts.isIdentifier(name) || isStringLiteralName(name)) {
186
- return name.text;
187
- }
188
- if (ts.isComputedPropertyName(name) && isStringLiteralName(name.expression)) {
189
- return name.expression.text;
190
- }
191
- return null;
192
- }
193
- function isStringLiteralName(node) {
194
- return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
195
- }
196
183
  function getValueText(property) {
197
184
  return ts.isPropertyAssignment(property)
198
185
  ? property.initializer.getText()
@@ -0,0 +1,8 @@
1
+ import { type Tree } from '@nx/devkit';
2
+ type MigrationResult = {
3
+ nextSteps: string[];
4
+ agentContext: string[];
5
+ skipAgentic?: boolean;
6
+ };
7
+ export default function upgradeToCypress16(tree: Tree): Promise<MigrationResult>;
8
+ export {};
@@ -0,0 +1,231 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.default = upgradeToCypress16;
4
+ const tslib_1 = require("tslib");
5
+ const devkit_1 = require("@nx/devkit");
6
+ const internal_1 = require("@nx/devkit/internal");
7
+ const internal_2 = require("@nx/js/internal");
8
+ const path_1 = require("path");
9
+ const semver_1 = require("semver");
10
+ const config_1 = require("../../utils/config");
11
+ const migrations_1 = require("../../utils/migrations");
12
+ const versions_1 = require("../../utils/versions");
13
+ const update_angular_zoneless_mount_import_1 = tslib_1.__importDefault(require("./update-angular-zoneless-mount-import"));
14
+ const update_cypress_16_config_options_1 = tslib_1.__importDefault(require("./update-cypress-16-config-options"));
15
+ const update_cypress_16_query_command_overwrites_1 = tslib_1.__importDefault(require("./update-cypress-16-query-command-overwrites"));
16
+ // Keep these versions pinned; versions.ts advances to later majors.
17
+ const CYPRESS_16_VERSIONS = {
18
+ cypress: '^16.0.0',
19
+ '@cypress/vite-dev-server': '^8.0.0',
20
+ '@cypress/webpack-dev-server': '^6.0.0',
21
+ };
22
+ // Cypress 16 component testing throws on a Vite below 8 (getVite.js in its
23
+ // bundled vite-dev-server); the peer `vite ^8.0.0` also excludes 8 prereleases.
24
+ const VITE_FLOOR = '8.0.0';
25
+ const NX_VITE_CT_PRESET = '@nx/remix/plugins/component-testing';
26
+ const NX_WEBPACK_CT_PRESETS = [
27
+ '@nx/angular/plugins/component-testing',
28
+ '@nx/next/plugins/component-testing',
29
+ '@nx/react/plugins/component-testing',
30
+ ];
31
+ // Re-collects the Cypress 16 migrations: below their version, above every
32
+ // 23.2.x. `--from` is exclusive.
33
+ const REPLAN_COMMAND = `npx nx migrate @nx/cypress@${versions_1.nxVersion} --from=@nx/cypress@23.3.0-beta.0`;
34
+ const RERUN_COMMAND = `${REPLAN_COMMAND} && npx nx migrate --run-migration=@nx/cypress:upgrade-to-cypress-16`;
35
+ let ts;
36
+ async function upgradeToCypress16(tree) {
37
+ const declared = (0, devkit_1.readJson)(tree, 'package.json');
38
+ const declaredCypress = declared.dependencies?.cypress ?? declared.devDependencies?.cypress;
39
+ // Resolves a `catalog:` reference to its range; `addDependenciesToPackageJson`
40
+ // then writes the bump into the catalog.
41
+ const cypressRange = (0, devkit_1.getDependencyVersionFromPackageJson)(tree, 'cypress');
42
+ if (!cypressRange ||
43
+ !((0, semver_1.validRange)(cypressRange) || ['latest', 'next'].includes(cypressRange))) {
44
+ const reason = declaredCypress
45
+ ? `\`cypress\` is declared as \`${declaredCypress}\` in package.json, which this migration cannot bump`
46
+ : '`cypress` is not declared in the root package.json';
47
+ return skipped(`${reason}. Move it to \`${CYPRESS_16_VERSIONS.cypress}\` by hand, install, and run \`${REPLAN_COMMAND} && npx nx migrate --run-migrations\` to apply the Cypress 16 migrations.`);
48
+ }
49
+ const blockers = await findViteBelowFloor(tree);
50
+ if (blockers.length > 0) {
51
+ return skipped(`Kept Cypress 15: Cypress 16 component testing requires Vite 8 and ${blockers.join(', ')}. Update \`vite\` to 8 where each listed config resolves it from, and any \`@vitejs/*\` plugin whose peer range excludes Vite 8. Install, then run \`${RERUN_COMMAND}\` to move to Cypress 16.`);
52
+ }
53
+ const bumped = Object.fromEntries(Object.entries(CYPRESS_16_VERSIONS).filter(([name]) => declared.devDependencies?.[name] || declared.dependencies?.[name]));
54
+ (0, devkit_1.addDependenciesToPackageJson)(tree, {}, bumped);
55
+ const results = [
56
+ await (0, update_cypress_16_config_options_1.default)(tree),
57
+ await (0, update_cypress_16_query_command_overwrites_1.default)(tree),
58
+ await (0, update_angular_zoneless_mount_import_1.default)(tree),
59
+ ];
60
+ await (0, devkit_1.formatFiles)(tree);
61
+ return {
62
+ nextSteps: results.flatMap((result) => result?.nextSteps ?? []),
63
+ agentContext: [
64
+ `Bumped ${Object.entries(bumped)
65
+ .map(([name, version]) => `\`${name}\` to \`${version}\``)
66
+ .join(', ')} in package.json`,
67
+ ...results.flatMap((result) => result?.agentContext ?? []),
68
+ ],
69
+ };
70
+ }
71
+ function skipped(message) {
72
+ return { nextSteps: [message], agentContext: [], skipAgentic: true };
73
+ }
74
+ // The Cypress config files whose component testing would run on a Vite below
75
+ // 8. Vite is resolved from each config's directory, as Cypress does. A config
76
+ // whose bundler cannot be read statically counts when such a Vite resolves.
77
+ async function findViteBelowFloor(tree) {
78
+ const cypressConfigPaths = new Set(await (0, devkit_1.globAsync)(tree, [`**/${config_1.CYPRESS_CONFIG_FILE_NAME_PATTERN}`]));
79
+ for await (const { cypressConfigPath } of (0, migrations_1.cypressProjectConfigs)(tree)) {
80
+ cypressConfigPaths.add(cypressConfigPath);
81
+ }
82
+ const blockers = [];
83
+ for (const cypressConfigPath of cypressConfigPaths) {
84
+ const bundler = tree.exists(cypressConfigPath)
85
+ ? getComponentTestingBundler(tree.read(cypressConfigPath, 'utf-8'))
86
+ : 'unknown';
87
+ if (bundler === 'other') {
88
+ continue;
89
+ }
90
+ const viteVersion = (0, internal_1.getInstalledPackageVersion)('vite', [
91
+ (0, path_1.resolve)(tree.root, (0, path_1.dirname)(cypressConfigPath)),
92
+ ]);
93
+ if (!viteVersion || !(0, semver_1.lt)(viteVersion, VITE_FLOOR)) {
94
+ continue;
95
+ }
96
+ blockers.push(bundler === 'vite'
97
+ ? `${cypressConfigPath} resolves Vite ${viteVersion}`
98
+ : `${cypressConfigPath} resolves Vite ${viteVersion} and its bundler could not be determined statically`);
99
+ }
100
+ return blockers;
101
+ }
102
+ // Reads the `component` block of a config the way Cypress evaluates it:
103
+ // through same-file spreads and variables, last assignment wins. Anything that
104
+ // cannot be resolved statically (an imported spread, a preset options variable
105
+ // from another file, a computed bundler, a preset call not bound to an Nx
106
+ // preset module) is `unknown`.
107
+ function getComponentTestingBundler(contents) {
108
+ ts ??= (0, internal_2.ensureTypescript)();
109
+ const config = (0, config_1.resolveCypressConfigObject)(contents);
110
+ if (!config) {
111
+ return 'unknown';
112
+ }
113
+ const component = findProperty(config, 'component');
114
+ if (component === 'unresolved') {
115
+ return 'unknown';
116
+ }
117
+ if (!component) {
118
+ return 'other';
119
+ }
120
+ return classifyComponent(component, config.getSourceFile());
121
+ }
122
+ // The value assigned to `name` in `block`, following spreads of same-file
123
+ // objects, last member wins. `null` when the block has no such property,
124
+ // `'unresolved'` when the last member that could set it cannot be read: a
125
+ // spread of something unresolvable or a computed key.
126
+ function findProperty(block, name) {
127
+ const sourceFile = block.getSourceFile();
128
+ let value = null;
129
+ for (const property of block.properties) {
130
+ if (ts.isSpreadAssignment(property)) {
131
+ const spread = (0, config_1.resolveObjectLiteral)(property.expression, sourceFile);
132
+ value = spread ? (findProperty(spread, name) ?? value) : 'unresolved';
133
+ continue;
134
+ }
135
+ const memberName = getMemberName(property);
136
+ if (memberName === 'unresolved') {
137
+ value = 'unresolved';
138
+ }
139
+ else if (memberName === name) {
140
+ value = ts.isPropertyAssignment(property)
141
+ ? property.initializer
142
+ : ts.isShorthandPropertyAssignment(property)
143
+ ? property.name
144
+ : 'unresolved';
145
+ }
146
+ }
147
+ return value;
148
+ }
149
+ // The static name of an object member, `'unresolved'` for a computed key that
150
+ // is not a string literal.
151
+ function getMemberName(property) {
152
+ const name = (0, migrations_1.getPropertyName)(property.name);
153
+ return name === null && ts.isComputedPropertyName(property.name)
154
+ ? 'unresolved'
155
+ : name;
156
+ }
157
+ function classifyComponent(value, sourceFile) {
158
+ if (ts.isCallExpression(value)) {
159
+ if (!ts.isIdentifier(value.expression) ||
160
+ value.expression.text !== 'nxComponentTestingPreset') {
161
+ return 'unknown';
162
+ }
163
+ const presetImport = (0, migrations_1.getComponentTestingPresetImport)(sourceFile);
164
+ if (presetImport === NX_VITE_CT_PRESET) {
165
+ return 'vite';
166
+ }
167
+ if (!NX_WEBPACK_CT_PRESETS.includes(presetImport) ||
168
+ value.arguments.some((argument) => ts.isSpreadElement(argument))) {
169
+ return 'unknown';
170
+ }
171
+ // The other Nx presets bundle with webpack unless told otherwise.
172
+ if (!value.arguments[1]) {
173
+ return 'other';
174
+ }
175
+ const options = (0, config_1.resolveObjectLiteral)(value.arguments[1], sourceFile);
176
+ return options ? getBundler(options, 'other') : 'unknown';
177
+ }
178
+ const component = (0, config_1.resolveObjectLiteral)(value, sourceFile);
179
+ if (!component) {
180
+ return 'unknown';
181
+ }
182
+ return classifyComponentObject(component, sourceFile) ?? 'other';
183
+ }
184
+ // The bundler set by the last member of a component object that can set one:
185
+ // a `devServer` property or a spread carrying one. `null` when none does.
186
+ function classifyComponentObject(component, sourceFile) {
187
+ let bundler = null;
188
+ for (const property of component.properties) {
189
+ if (ts.isSpreadAssignment(property)) {
190
+ if (ts.isCallExpression(property.expression)) {
191
+ bundler = classifyComponent(property.expression, sourceFile);
192
+ continue;
193
+ }
194
+ const spread = (0, config_1.resolveObjectLiteral)(property.expression, sourceFile);
195
+ bundler = spread
196
+ ? (classifyComponentObject(spread, sourceFile) ?? bundler)
197
+ : 'unknown';
198
+ continue;
199
+ }
200
+ const memberName = getMemberName(property);
201
+ if (memberName === 'unresolved') {
202
+ bundler = 'unknown';
203
+ }
204
+ else if (memberName === 'devServer') {
205
+ const devServer = ts.isPropertyAssignment(property)
206
+ ? (0, config_1.resolveObjectLiteral)(property.initializer, sourceFile)
207
+ : ts.isShorthandPropertyAssignment(property)
208
+ ? (0, config_1.resolveObjectLiteral)(property.name, sourceFile)
209
+ : null;
210
+ // Cypress requires `bundler` in `devServer`, so a block without a
211
+ // readable one holds it somewhere this migration cannot see.
212
+ bundler = devServer ? getBundler(devServer, 'unknown') : 'unknown';
213
+ }
214
+ }
215
+ return bundler;
216
+ }
217
+ // The `bundler` string in an options object, `missing` when it has none.
218
+ function getBundler(options, missing) {
219
+ const bundler = findProperty(options, 'bundler');
220
+ if (bundler === 'unresolved') {
221
+ return 'unknown';
222
+ }
223
+ if (!bundler) {
224
+ return missing;
225
+ }
226
+ if (ts.isStringLiteral(bundler) ||
227
+ ts.isNoSubstitutionTemplateLiteral(bundler)) {
228
+ return bundler.text === 'vite' ? 'vite' : 'other';
229
+ }
230
+ return 'unknown';
231
+ }
@@ -1,8 +1,8 @@
1
1
  #### Upgrade Cypress 15 to 16
2
2
 
3
- Bumps `cypress` to 16 together with `@cypress/vite-dev-server` 8 and `@cypress/webpack-dev-server` 6. A workspace with a Vite below 8 installed at the workspace root that does not land on Vite 8 in the same run stays on Cypress 15, since Cypress 16 component testing rejects an older Vite. A Vite declared only in a project's `package.json` is not detected, so check those projects before running their component tests. Cypress 16 removes `Cypress.env()` (replaced by the asynchronous `cy.env()` for `env` values and `Cypress.expose()` for the new `expose` values), `cy.exec()` (use `cy.task()`) and `.end()`, drops `env` from test configuration overrides, turns the cookie and storage getters into queries, drops CoffeeScript support, and routes Chrome, Chromium and Edge through the native browser network. It requires Node 22, 24 or 26+, Vite 8 for Vite component testing, Angular 21 for Angular component testing and Next.js 15.0.4 for component testing on Cypress's `next` dev server framework, which the Nx preset does not use. Read more in the [Cypress 16 migration guide](https://docs.cypress.io/app/references/migration-guide#Migrating-to-Cypress-160).
3
+ Bumps `cypress` to 16 together with the declared `@cypress/vite-dev-server` (to 8) and `@cypress/webpack-dev-server` (to 6); a package manager catalog entry is bumped in the catalog. Cypress 16 component testing rejects a Vite below 8, so the migration first reads the bundler of every Cypress config's `component` block, following same-file spreads and variables, and resolves `vite` from that config's directory, as Cypress does. When a Vite component testing project, or a config whose bundler cannot be read statically, resolves a Vite below 8, the workspace stays on Cypress 15 and the migration reports the command to run once Vite is on 8. It does not check a Cypress config with a custom name that no `@nx/cypress:cypress` target references, such as one run through `nx:run-commands`. A Vite that only e2e or webpack component testing projects resolve, such as a transitive Vite hoisted to the workspace root, does not hold the bump. Cypress 16 removes `Cypress.env()` (replaced by the asynchronous `cy.env()` for `env` values and `Cypress.expose()` for the new `expose` values), `cy.exec()` (use `cy.task()`) and `.end()`, drops `env` from test configuration overrides, turns the cookie and storage getters into queries, drops CoffeeScript support, and routes Chrome, Chromium and Edge through the native browser network. It requires Node 22, 24 or 26+, Vite 8 for Vite component testing, Angular 21 for Angular component testing and Next.js 15.0.4 for component testing on Cypress's `next` dev server framework, which the Nx preset does not use. Read more in the [Cypress 16 migration guide](https://docs.cypress.io/app/references/migration-guide#Migrating-to-Cypress-160).
4
4
 
5
- The generator-based migrations in this release update the renamed and removed config options, the `cypress/angular-zoneless` import and the `Cypress.Commands.overwrite()` calls for the new queries. The paired AI instructions migration walks an agent through the source changes that need judgment. The most common one is shown below.
5
+ After the bump, the migration updates the renamed and removed config options, the `cypress/angular-zoneless` import and the `Cypress.Commands.overwrite()` calls for the new queries; a workspace already on Cypress 16 gets the same rewrites from their standalone migrations. The paired AI instructions walk an agent through the source changes that need judgment. The most common one is shown below.
6
6
 
7
7
  #### Examples
8
8
 
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createNodesV2 = exports.createNodes = void 0;
4
+ const tslib_1 = require("tslib");
4
5
  const internal_1 = require("@nx/devkit/internal");
5
6
  const devkit_1 = require("@nx/devkit");
6
7
  const js_1 = require("@nx/js");
7
8
  const fs_1 = require("fs");
8
9
  const path_1 = require("path");
10
+ const posix = tslib_1.__importStar(require("node:path/posix"));
9
11
  const constants_1 = require("../utils/constants");
10
12
  const cypressConfigGlob = '**/cypress.config.{js,ts,mjs,cjs}';
11
13
  const defaultPatterns = {
@@ -76,7 +78,7 @@ async function createNodesInternal(configFilePath, options, context, pluginCache
76
78
  };
77
79
  }
78
80
  function getTargetOutputs(outputs, subfolder) {
79
- return outputs.map((output) => subfolder ? (0, path_1.join)(output, subfolder) : output);
81
+ return outputs.map((output) => subfolder ? posix.join(output, subfolder) : output);
80
82
  }
81
83
  // Normalize a Cypress config folder (e.g. videosFolder, screenshotsFolder) to a
82
84
  // project-root-relative path, then append the per-spec subfolder. Cypress
@@ -479,13 +481,13 @@ async function getSpecFilesAndPatternsForTestType(cypressConfig, testType, works
479
481
  const specPattern = cypressConfig[testType].specPattern ??
480
482
  defaultPatterns[testType].specPattern;
481
483
  const specPatterns = Array.isArray(specPattern)
482
- ? specPattern.map((p) => (0, path_1.join)(projectRoot, p))
483
- : [(0, path_1.join)(projectRoot, specPattern)];
484
+ ? specPattern.map((p) => posix.join(projectRoot, p))
485
+ : [posix.join(projectRoot, specPattern)];
484
486
  const excludeSpecPattern = cypressConfig[testType].excludeSpecPattern ??
485
487
  defaultPatterns[testType].excludeSpecPattern;
486
488
  const excludeSpecPatterns = Array.isArray(excludeSpecPattern)
487
- ? excludeSpecPattern.map((p) => (0, path_1.join)(projectRoot, p))
488
- : [(0, path_1.join)(projectRoot, excludeSpecPattern)];
489
+ ? excludeSpecPattern.map((p) => posix.join(projectRoot, p))
490
+ : [posix.join(projectRoot, excludeSpecPattern)];
489
491
  const specFiles = await (0, internal_1.globWithWorkspaceContext)(workspaceRoot, specPatterns, excludeSpecPatterns);
490
492
  return { specFiles, specPatterns, excludeSpecPatterns };
491
493
  }
@@ -1,5 +1,5 @@
1
1
  import { type ProjectConfiguration, type Tree } from '@nx/devkit';
2
- import type { Expression, ObjectLiteralExpression, PropertyAssignment, SourceFile } from 'typescript';
2
+ import type { Expression, ObjectLiteralExpression, PropertyAssignment, PropertyName, SourceFile } from 'typescript';
3
3
  export declare function cypressProjectConfigs(tree: Tree): AsyncGenerator<{
4
4
  projectName: string;
5
5
  projectConfig: ProjectConfiguration;
@@ -11,6 +11,8 @@ export declare function updateObjectProperty(config: ObjectLiteralExpression, pr
11
11
  newName?: string;
12
12
  newValue?: Expression;
13
13
  }): ObjectLiteralExpression;
14
+ export declare function getComponentTestingPresetImport(sourceFile: SourceFile): string | null;
15
+ export declare function getPropertyName(name: PropertyName): string | null;
14
16
  export declare function parseSourceFile(filePath: string, content: string): SourceFile;
15
17
  /**
16
18
  * Whether the file binds `name` as a runtime value anywhere: a variable,
@@ -4,55 +4,78 @@ exports.cypressProjectConfigs = cypressProjectConfigs;
4
4
  exports.getObjectProperty = getObjectProperty;
5
5
  exports.removeObjectProperty = removeObjectProperty;
6
6
  exports.updateObjectProperty = updateObjectProperty;
7
+ exports.getComponentTestingPresetImport = getComponentTestingPresetImport;
8
+ exports.getPropertyName = getPropertyName;
7
9
  exports.parseSourceFile = parseSourceFile;
8
10
  exports.hasLocalValueBinding = hasLocalValueBinding;
9
11
  const devkit_1 = require("@nx/devkit");
10
- const internal_1 = require("@nx/js/internal");
12
+ const internal_1 = require("@nx/devkit/internal");
13
+ const internal_2 = require("@nx/js/internal");
11
14
  const path_1 = require("path");
12
15
  const config_1 = require("./config");
13
16
  let ts;
14
17
  async function* cypressProjectConfigs(tree) {
15
18
  const projects = (0, devkit_1.getProjects)(tree);
19
+ const targetDefaults = (0, devkit_1.readNxJson)(tree)?.targetDefaults;
16
20
  for (const [projectName, projectConfig] of projects) {
17
- const targetsWithExecutor = Object.values(projectConfig.targets ?? {}).filter((target) => target.executor === '@nx/cypress:cypress');
18
- if (targetsWithExecutor.length > 0) {
19
- const cypressConfigPaths = new Set();
20
- for (const target of targetsWithExecutor) {
21
- for (const [, options] of allTargetOptions(target)) {
22
- if (options.cypressConfig) {
23
- cypressConfigPaths.add(options.cypressConfig);
24
- }
25
- }
21
+ const cypressConfigPaths = new Set();
22
+ for (const [targetName, target] of Object.entries(projectConfig.targets ?? {})) {
23
+ if (target.command) {
24
+ continue;
25
+ }
26
+ const merged = (0, internal_1.mergeTargetConfigurations)(target, (0, internal_1.readTargetDefaultsForTarget)(targetName, targetDefaults, target.executor, {
27
+ projectName,
28
+ projectNode: {
29
+ name: projectName,
30
+ type: projectConfig.projectType === 'application' ? 'app' : 'lib',
31
+ data: projectConfig,
32
+ },
33
+ }) ?? undefined);
34
+ if (merged.executor !== '@nx/cypress:cypress') {
35
+ continue;
26
36
  }
27
- for (const cypressConfigPath of cypressConfigPaths) {
28
- yield { projectName, projectConfig, cypressConfigPath };
37
+ for (const [, options] of allTargetOptions(merged)) {
38
+ if (options.cypressConfig) {
39
+ cypressConfigPaths.add(toTreePath(tree, (0, internal_1.interpolate)(options.cypressConfig, {
40
+ workspaceRoot: '.',
41
+ projectRoot: projectConfig.root,
42
+ projectName,
43
+ })));
44
+ }
29
45
  }
30
46
  }
31
- else {
32
- // might be using the crystal plugin
47
+ if (cypressConfigPaths.size === 0) {
33
48
  const result = await (0, devkit_1.globAsync)(tree, [
34
49
  path_1.posix.join(projectConfig.root, config_1.CYPRESS_CONFIG_FILE_NAME_PATTERN),
35
50
  ]);
36
51
  if (result.length > 0) {
37
- yield {
38
- projectName,
39
- projectConfig,
40
- cypressConfigPath: result[0],
41
- };
52
+ cypressConfigPaths.add(result[0]);
42
53
  }
43
54
  }
55
+ for (const cypressConfigPath of cypressConfigPaths) {
56
+ yield { projectName, projectConfig, cypressConfigPath };
57
+ }
44
58
  }
45
59
  }
60
+ function toTreePath(tree, path) {
61
+ if (!(0, path_1.isAbsolute)(path)) {
62
+ return path_1.posix.normalize(path);
63
+ }
64
+ const relativePath = (0, path_1.relative)(tree.root, path);
65
+ return relativePath.startsWith('..') || (0, path_1.isAbsolute)(relativePath)
66
+ ? path
67
+ : (0, devkit_1.normalizePath)(relativePath);
68
+ }
46
69
  function getObjectProperty(config, name) {
47
- ts ??= (0, internal_1.ensureTypescript)();
70
+ ts ??= (0, internal_2.ensureTypescript)();
48
71
  return config.properties.find((p) => ts.isPropertyAssignment(p) && p.name.getText() === name);
49
72
  }
50
73
  function removeObjectProperty(config, property) {
51
- ts ??= (0, internal_1.ensureTypescript)();
74
+ ts ??= (0, internal_2.ensureTypescript)();
52
75
  return ts.factory.updateObjectLiteralExpression(config, config.properties.filter((p) => p !== property));
53
76
  }
54
77
  function updateObjectProperty(config, property, { newName, newValue }) {
55
- ts ??= (0, internal_1.ensureTypescript)();
78
+ ts ??= (0, internal_2.ensureTypescript)();
56
79
  if (!newName && !newValue) {
57
80
  throw new Error('newName or newValue must be provided');
58
81
  }
@@ -60,6 +83,57 @@ function updateObjectProperty(config, property, { newName, newValue }) {
60
83
  ? ts.factory.updatePropertyAssignment(p, newName ? ts.factory.createIdentifier(newName) : p.name, newValue ? newValue : p.initializer)
61
84
  : p));
62
85
  }
86
+ // Resolves the module specifier that binds the `nxComponentTestingPreset`
87
+ // identifier in a cypress config, covering both ESM `import` and CJS
88
+ // `require` forms. Returns null when no such binding is found.
89
+ function getComponentTestingPresetImport(sourceFile) {
90
+ ts ??= (0, internal_2.ensureTypescript)();
91
+ for (const statement of sourceFile.statements) {
92
+ if (ts.isImportDeclaration(statement) && statement.importClause) {
93
+ const namedBindings = statement.importClause.namedBindings;
94
+ if (namedBindings &&
95
+ ts.isNamedImports(namedBindings) &&
96
+ ts.isStringLiteral(statement.moduleSpecifier) &&
97
+ namedBindings.elements.some((element) => element.name.text === 'nxComponentTestingPreset')) {
98
+ return statement.moduleSpecifier.text;
99
+ }
100
+ }
101
+ if (ts.isVariableStatement(statement)) {
102
+ for (const declaration of statement.declarationList.declarations) {
103
+ const initializer = declaration.initializer;
104
+ if (!initializer ||
105
+ !ts.isCallExpression(initializer) ||
106
+ !ts.isIdentifier(initializer.expression) ||
107
+ initializer.expression.text !== 'require' ||
108
+ !ts.isObjectBindingPattern(declaration.name)) {
109
+ continue;
110
+ }
111
+ const moduleSpecifier = initializer.arguments[0];
112
+ if (moduleSpecifier &&
113
+ ts.isStringLiteral(moduleSpecifier) &&
114
+ declaration.name.elements.some((element) => ts.isIdentifier(element.name) &&
115
+ element.name.text === 'nxComponentTestingPreset')) {
116
+ return moduleSpecifier.text;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+ // Returns null for a computed name that is not a string literal.
124
+ function getPropertyName(name) {
125
+ ts ??= (0, internal_2.ensureTypescript)();
126
+ if (ts.isIdentifier(name) || isStringLiteralName(name)) {
127
+ return name.text;
128
+ }
129
+ if (ts.isComputedPropertyName(name) && isStringLiteralName(name.expression)) {
130
+ return name.expression.text;
131
+ }
132
+ return null;
133
+ }
134
+ function isStringLiteralName(node) {
135
+ return ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node);
136
+ }
63
137
  function* allTargetOptions(target) {
64
138
  if (target.options) {
65
139
  yield [undefined, target.options];
@@ -76,7 +150,7 @@ function* allTargetOptions(target) {
76
150
  // The extension picks the script kind, so `<T>x` in a .ts file and JSX in a
77
151
  // .tsx file both parse; tsquery's `ast()` parses everything as TSX.
78
152
  function parseSourceFile(filePath, content) {
79
- ts ??= (0, internal_1.ensureTypescript)();
153
+ ts ??= (0, internal_2.ensureTypescript)();
80
154
  return ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest,
81
155
  /* setParentNodes */ true);
82
156
  }
@@ -91,7 +165,7 @@ function parseSourceFile(filePath, content) {
91
165
  * `Cypress` global.
92
166
  */
93
167
  function hasLocalValueBinding(sourceFile, name) {
94
- ts ??= (0, internal_1.ensureTypescript)();
168
+ ts ??= (0, internal_2.ensureTypescript)();
95
169
  if (sourceFile.isDeclarationFile) {
96
170
  return false;
97
171
  }
package/migrations.json CHANGED
@@ -63,7 +63,7 @@
63
63
  "documentation": "./dist/src/migrations/update-23-1-0/disable-webpack-ct-just-in-time-compile.md"
64
64
  },
65
65
  "update-cypress-16-config-options": {
66
- "version": "23.3.0-beta.1",
66
+ "version": "23.3.0-beta.8",
67
67
  "requires": {
68
68
  "cypress": ">=16.0.0"
69
69
  },
@@ -72,7 +72,7 @@
72
72
  "documentation": "./dist/src/migrations/update-23-3-0/update-cypress-16-config-options.md"
73
73
  },
74
74
  "update-cypress-16-query-command-overwrites": {
75
- "version": "23.3.0-beta.1",
75
+ "version": "23.3.0-beta.8",
76
76
  "requires": {
77
77
  "cypress": ">=16.0.0"
78
78
  },
@@ -82,7 +82,7 @@
82
82
  "documentation": "./dist/src/migrations/update-23-3-0/update-cypress-16-query-command-overwrites.md"
83
83
  },
84
84
  "update-angular-zoneless-mount-import": {
85
- "version": "23.3.0-beta.1",
85
+ "version": "23.3.0-beta.8",
86
86
  "requires": {
87
87
  "cypress": ">=16.0.0"
88
88
  },
@@ -91,13 +91,23 @@
91
91
  "documentation": "./dist/src/migrations/update-23-3-0/update-angular-zoneless-mount-import.md"
92
92
  },
93
93
  "create-ai-instructions-for-cypress-16": {
94
- "version": "23.3.0-beta.1",
94
+ "version": "23.3.0-beta.8",
95
95
  "requires": {
96
96
  "cypress": ">=16.0.0"
97
97
  },
98
98
  "description": "AI-assisted migration: replaces `Cypress.env()`, `cy.exec()`, `.end()`, `env` test-configuration overrides and runtime `Cypress.config()` writes removed in Cypress 16, whose replacements depend on where each value comes from, so it is driven by an AI prompt rather than a deterministic generator.",
99
99
  "prompt": "./dist/src/migrations/update-23-3-0/ai-instructions-for-cypress-16.md",
100
100
  "documentation": "./dist/src/migrations/update-23-3-0/upgrade-to-cypress-16.md"
101
+ },
102
+ "upgrade-to-cypress-16": {
103
+ "version": "23.3.0-beta.8",
104
+ "requires": {
105
+ "cypress": ">=15.0.0 <16.0.0"
106
+ },
107
+ "description": "Moves a Cypress 15 workspace to Cypress 16: bumps `cypress` and the declared `@cypress/*-dev-server` packages unless a Vite component testing config, or a config whose bundler cannot be determined statically, resolves a Vite below 8, applies the Cypress 16 config option, `overwriteQuery` and `cypress/angular` rewrites, and hands the remaining source changes to an AI prompt.",
108
+ "implementation": "./dist/src/migrations/update-23-3-0/upgrade-to-cypress-16",
109
+ "prompt": "./dist/src/migrations/update-23-3-0/ai-instructions-for-cypress-16.md",
110
+ "documentation": "./dist/src/migrations/update-23-3-0/upgrade-to-cypress-16.md"
101
111
  }
102
112
  },
103
113
  "packageJsonUpdates": {
@@ -197,29 +207,6 @@
197
207
  "alwaysAddToPackageJson": false
198
208
  }
199
209
  }
200
- },
201
- "23.3.0-cypress-16": {
202
- "version": "23.3.0-beta.1",
203
- "requires": {
204
- "cypress": ">=15.0.0 <16.0.0"
205
- },
206
- "incompatibleWith": {
207
- "vite": "<8.0.0"
208
- },
209
- "packages": {
210
- "cypress": {
211
- "version": "^16.0.0",
212
- "alwaysAddToPackageJson": false
213
- },
214
- "@cypress/vite-dev-server": {
215
- "version": "^8.0.0",
216
- "alwaysAddToPackageJson": false
217
- },
218
- "@cypress/webpack-dev-server": {
219
- "version": "^6.0.0",
220
- "alwaysAddToPackageJson": false
221
- }
222
- }
223
210
  }
224
211
  }
225
212
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nx/cypress",
3
- "version": "23.3.0-beta.7",
3
+ "version": "23.3.0-beta.8",
4
4
  "private": false,
5
5
  "type": "commonjs",
6
6
  "files": [
@@ -84,9 +84,9 @@
84
84
  "supportsOptionalMigrations": true
85
85
  },
86
86
  "dependencies": {
87
- "@nx/devkit": "23.3.0-beta.7",
88
- "@nx/eslint": "23.3.0-beta.7",
89
- "@nx/js": "23.3.0-beta.7",
87
+ "@nx/devkit": "23.3.0-beta.8",
88
+ "@nx/eslint": "23.3.0-beta.8",
89
+ "@nx/js": "23.3.0-beta.8",
90
90
  "@phenomnomnominal/tsquery": "~6.2.0",
91
91
  "detect-port": "^2.1.0",
92
92
  "semver": "^7.6.3",
@@ -94,7 +94,7 @@
94
94
  "tslib": "^2.3.0"
95
95
  },
96
96
  "devDependencies": {
97
- "nx": "23.3.0-beta.7"
97
+ "nx": "23.3.0-beta.8"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "cypress": ">= 13 < 17"