1#!/usr/bin/env node2// Scan Cypress specs, support files, and config for flake-prone anti-patterns3// and for APIs removed in Cypress 12 through 16.4//5// Zero dependencies. Requires Node.js 18 or later.6// The scan is heuristic (text based, no AST). Treat findings as leads to7// confirm in the code, not as proof.89import fs from 'node:fs'10import path from 'node:path'11import process from 'node:process'1213const
15Scans Cypress test code for flake-prone anti-patterns and removed APIs.
16With no path, scans the current directory.
17
18Options:
19 --json Print machine-readable JSON instead of text
20 --strict Also report low-confidence rules (CY1xx)
21 --rule <ids> Only report these rule ids, comma separated (CY001,CY005)
22 --max-per-rule <n> Findings listed per rule in text output (default 20, 0 = all)
23 --fail-on <level> Exit 1 if a finding at this level or above exists:
24 error | warn | info | never (default never)
25 --cypress-major <n> Override the Cypress major version detected from package.json
26 --list-rules Print the rule catalog and exit
27 -h, --help Show this help
28
29Suppress a finding with a comment on the same or the previous line:
30 // cy-scan-ignore (all rules)
31 // cy-scan-ignore CY001 (one or more rule ids)
32
33Exit codes: 0 ok, 1 threshold from --fail-on reached, 2 usage or I/O error.`
34
35const RULES = {
36 CY001: ['fixed-wait', 'warn', 'cy.wait(<number>) waits on time. Wait on a cy.intercept() alias or a retrying assertion.'],
37 CY002: ['assigned-return-value', 'error', 'A cy command return value is assigned. Commands are queued and yield later; use .then(), .should(), or an alias.'],
38 CY003: ['async-await', 'error', 'async/await is used with Cypress commands. Commands are not promises; remove async and await.'],
39 CY004: ['force-true', 'warn', '{ force: true } skips actionability checks. Find what covers or disables the element.'],
40 CY005: ['chain-after-action', 'warn', 'A command is chained after an action. Safe only if the element cannot re-render; otherwise start a new cy.get().'],
41 CY006: ['one-shot-assertion', 'warn', 'expect() inside .then() runs once and does not retry. Use .should() or .should(callback).'],
42 CY007: ['removed-api', 'error', 'API removed or changed in a newer Cypress major.'],
43 CY008: ['conditional-on-dom', 'warn', 'Test branches on DOM state. Make the state deterministic, then assert it.'],
44 CY009: ['exclusive-or-debug', 'warn', '.only, cy.pause(), or cy.debug() left in the code.'],
45 CY010: ['cleanup-in-after-hook', 'info', 'State is cleaned in after/afterEach. Reset state in before/beforeEach so interrupted runs do not leak.'],
46 CY011: ['absolute-local-visit', 'info', 'cy.visit() uses an absolute local URL. Set baseUrl and visit a relative path.'],
47 CY012: ['ui-login-without-session', 'info', 'Password is typed through the UI in several files and cy.session() is never used.'],
48 CY013: ['positional-selector', 'info', 'Selector depends on element position. Prefer a data-* attribute.'],
49 CY101: ['brittle-selector', 'info', 'Selector relies on a CSS class or id. Prefer a data-* attribute.'],
50 CY102: ['negative-assertion-as-wait', 'info', 'A negative assertion passes before the element has rendered. Confirm a positive state was asserted first.'],
51 CY103: ['variable-wait', 'info', 'cy.wait() is called with an identifier. Confirm it is an alias, not a number of milliseconds.'],
52}
53
54const LEVELS = { info: 1, warn: 2, error: 3 }
55
56const REMOVED = [
57 [/\bCypress\s*\.\s*env\s*\(/g, 16, 'Cypress.env() was removed in 16.0 (deprecated 15.10). Use cy.env([...]) for secrets or Cypress.expose() for public values.'],
58 [/\bcy\s*\.\s*exec\s*\(/g, 16, 'cy.exec() was removed in 16.0. Register a task in setupNodeEvents and call cy.task().'],
59 [/\)\s*\.\s*end\s*\(\s*\)/g, 16, '.end() was removed in 16.0. Delete the call.'],
60 [/\bexecTimeout\b/g, 16, 'execTimeout was removed in 16.0 with cy.exec(). Use taskTimeout.'],
61 [/\bexperimentalMemoryManagement\b/g, 16, 'experimentalMemoryManagement was removed in 16.0. Delete it, or set manageBrowserMemory: false to opt out.'],
62 [/\bexperimentalFastVisibility\b/g, 16, 'experimentalFastVisibility was removed in 16.0. visibilityStrategy defaults to "modern".'],
63 [/\bexperimentalSourceRewriting\b/g, 16, 'experimentalSourceRewriting was removed in 16.0. Delete it; use removeSRIAttributes for SRI errors.'],
64 [/\ballowCypressEnv\b/g, 16, 'allowCypressEnv was removed in 16.0. Delete it.'],
65 [/\bCypress\s*\.\s*config\s*\(\s*['"`](?:viewportWidth|viewportHeight|blockHosts)['"`]\s*,/g, 16, 'Setting this through Cypress.config() during a test throws in 16.0. Use cy.viewport() or the test config object.'],
66 [/\bCypress\s*\.\s*Commands\s*\.\s*overwrite\s*\(\s*['"`](?:getCookie|getCookies|getAllCookies|getAllLocalStorage|getAllSessionStorage)['"`]/g, 16, 'This command is a query in 16.0. Use Cypress.Commands.overwriteQuery().'],
67 [/\bCypress\s*\.\s*SelectorPlayground\b/g, 15, 'Cypress.SelectorPlayground was renamed Cypress.ElementSelector in 15.0.'],
68 [/\bexperimentalSkipDomainInjection\b/g, 14, 'experimentalSkipDomainInjection was removed in 14.0. Use cy.origin() for subdomains.'],
69 [/\bexperimentalJustInTimeCompile\b/g, 14, 'experimentalJustInTimeCompile became justInTimeCompile in 14.0.'],
70 [/\bvideoUploadOnPasses\b/g, 13, 'videoUploadOnPasses was removed in 13.0.'],
71 [/\bcy\s*\.\s*(?:server|route)\s*\(/g, 12, 'cy.server() and cy.route() were removed in 12.0. Use cy.intercept().'],
72 [/\bCypress\s*\.\s*Cookies\s*\.\s*(?:preserveOnce|defaults)\b/g, 12, 'Cypress.Cookies.preserveOnce() and .defaults() were removed in 12.0. Use cy.session().'],
73 [/\bexperimentalSessionAndOrigin\b/g, 12, 'experimentalSessionAndOrigin was removed in 12.0. cy.session() and cy.origin() are stable.'],
74 [/\bcy\s*\.\s*route2\s*\(/g, 7, 'cy.route2() was renamed cy.intercept() in 7.0.'],
75 [/\bcy\s*\.\s*xpath\s*\(/g, null, 'cy.xpath() comes from the deprecated cypress-xpath plugin. Use cy.get() or cy.contains().'],
369 if (!/^\s*(?:return\s+)?cy\b/.test(chain)) return
370 if (/\bcy\s*\.\s*(?:request|task|wait|env|fixture|readFile|wrap|window|exec|origin|session)\s*\(/.test(chain)) return
371 if (/\bcy\s*\.\s*get\s*\(\s*['"`]@/.test(chain)) return
372 if (!/\bcy\s*\.\s*(?:get|contains|url|title|location|hash|focused|getCookie|getCookies|getAllCookies|getAllLocalStorage|getAllSessionStorage)\s*\(/.test(chain)) return
373 add('CY006', m.index)
374 })
375
376 // CY007 removed APIs
377 for (const [regex, removedIn, text] of REMOVED) {
378 each(regex, (m) => {
379 const broken = removedIn === null ? false : major === null ? null : major >= removedIn
381 const suffix = removedIn === null || major === null ? '' : broken ? ` Installed major is ${major}: this already fails.` : ` Installed major is ${major}: fix before upgrading.`
399 each(/\b(?:it|describe|context|specify)\s*\.\s*only\s*\(/g, (m) => add('CY009', m.index, '.only is committed: the rest of the suite does not run.'))
400 each(/(?:\bcy|\))\s*\.\s*(?:pause|debug)\s*\(\s*\)/g, (m) => add('CY009', m.index, 'cy.pause() or cy.debug() left in the code.'))