diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 6009b776873..d33895f0b9b 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -6,6 +6,7 @@ _Released 12/16/2025 (PENDING)_ **Features:** - `Angular` version 21 is now supported within component testing. Addressed in [#33004](https://github.com/cypress-io/cypress/pull/33004). +- Added intelligent timeout diagnostics that surface contextual suggestions when commands exceed their timeouts and introduced an async Vite dev-server plugin loader to avoid blocking startup. Addressed in [#33022](https://github.com/cypress-io/cypress/pull/33022). **Bugfixes:** @@ -13,7 +14,9 @@ _Released 12/16/2025 (PENDING)_ ## 15.7.1 -_Released 12/2/2025_ +_Released 12/4/2025 (PENDING)_ + +**Features:** **Performance:** diff --git a/npm/vite-dev-server/src/plugins/cypress.ts b/npm/vite-dev-server/src/plugins/cypress.ts index 49e16312ffe..89b72b2091d 100644 --- a/npm/vite-dev-server/src/plugins/cypress.ts +++ b/npm/vite-dev-server/src/plugins/cypress.ts @@ -3,11 +3,14 @@ import type { ModuleNode, PluginOption, ViteDevServer } from 'vite-7' import type { Vite } from '../getVite.js' import { parse, HTMLElement } from 'node-html-parser' import fs from 'fs' +import { promisify } from 'util' import type { ViteDevServerConfig } from '../devServer.js' import path from 'path' import { fileURLToPath } from 'url' +const readFile = promisify(fs.readFile) + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -33,6 +36,7 @@ export const Cypress = ( vite: Vite, ): PluginOption => { let base = '/' + let loaderPromise: Promise | null = null const projectRoot = options.cypressConfig.projectRoot const supportFilePath = options.cypressConfig.supportFile ? path.resolve(projectRoot, options.cypressConfig.supportFile) : false @@ -42,9 +46,35 @@ export const Cypress = ( const indexHtmlFile = options.cypressConfig.indexHtmlFile let specsPathsSet = getSpecsPathsSet(specs) - // TODO: use async fs methods here - // eslint-disable-next-line no-restricted-syntax - let loader = fs.readFileSync(INIT_FILEPATH, 'utf8') + + // Load the init file asynchronously with proper error handling + const loadInitFile = async (): Promise => { + try { + const content = await readFile(INIT_FILEPATH, 'utf8') + + debug(`Successfully loaded init file from ${INIT_FILEPATH}`) + + return content + } catch (error) { + debug(`Failed to load init file from ${INIT_FILEPATH}:`, error) + + throw new Error(`Failed to load Cypress init file: ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // Get or create the loader promise (lazy initialization with proper error handling) + const getLoaderPromise = (): Promise => { + if (!loaderPromise) { + loaderPromise = loadInitFile().catch((error) => { + // Reset the promise so it can be retried later + loaderPromise = null + + throw error + }) + } + + return loaderPromise + } devServerEvents.on('dev-server:specs:changed', ({ specs, options }: { specs: Spec[], options?: { neededForJustInTimeCompile: boolean }}) => { if (options?.neededForJustInTimeCompile) { @@ -79,7 +109,7 @@ export const Cypress = ( debug('resolved the indexHtmlPath as', indexHtmlPath, 'from', indexHtmlFile) - let indexHtmlContent = await fs.promises.readFile(indexHtmlPath, { encoding: 'utf8' }) + let indexHtmlContent = await readFile(indexHtmlPath, 'utf8') // Inject the script tags indexHtmlContent = indexHtmlContent.replace( @@ -92,12 +122,26 @@ export const Cypress = ( // find last index const endOfBody = indexHtmlContent.lastIndexOf('') + // Get the loader content asynchronously + const loader = await getLoaderPromise() + // insert the script in the end of the body - const newHtml = ` - ${indexHtmlContent.substring(0, endOfBody)} + let newHtml: string + + if (endOfBody === -1) { + // No closing body tag found, append at the end + debug('No closing body tag found, appending script at end of HTML') + newHtml = `${indexHtmlContent} + ` + } else { + // Insert before closing body tag without extra leading whitespace + const beforeBody = indexHtmlContent.substring(0, endOfBody) + const afterBody = indexHtmlContent.substring(endOfBody) + + newHtml = `${beforeBody} - ${indexHtmlContent.substring(endOfBody)} - ` +${afterBody}` + } return newHtml }, diff --git a/packages/driver/src/cypress/TIMEOUT_DIAGNOSTICS_README.md b/packages/driver/src/cypress/TIMEOUT_DIAGNOSTICS_README.md new file mode 100644 index 00000000000..41e11f2c5b7 --- /dev/null +++ b/packages/driver/src/cypress/TIMEOUT_DIAGNOSTICS_README.md @@ -0,0 +1,198 @@ +# Timeout Diagnostics - Smart Error Messages + +## 🎯 Objetivo + +Melhorar dramaticamente a experiência do desenvolvedor ao lidar com erros de timeout no Cypress, fornecendo **sugestões contextuais e acionáveis** baseadas na análise do contexto do erro. + +## 🚀 Motivação + +Erros de timeout são extremamente comuns em testes Cypress, mas as mensagens tradicionais são genéricas: + +``` +cy.get() timed out waiting 4000ms +``` + +Com este sistema, os desenvolvedores recebem diagnósticos inteligentes: + +``` +cy.get() timed out waiting 4000ms + +🔍 Diagnostic Suggestions: + +1. The selector appears to target dynamic/loading content + a) Wait for the loading state to complete: cy.get('.loading-spinner').should('not.exist') + b) Consider using data-cy attributes instead of class names that indicate loading states + c) Use cy.intercept() to wait for the API request that populates this content + 📚 Learn more: https://on.cypress.io/best-practices#Selecting-Elements + +2. 8 network requests are still pending + a) Wait for specific API calls to complete using cy.intercept() + b) Consider increasing the timeout if the requests are expected to be slow + c) Check if some requests are failing or hanging in the Network tab + d) Example: cy.intercept("GET", "/api/data").as("getData"); cy.wait("@getData") + 📚 Learn more: https://on.cypress.io/intercept +``` + +## ✨ Funcionalidades + +### 1. **Detecção de Seletores Problemáticos** +- Identifica seletores que apontam para conteúdo dinâmico (loading, spinner, skeleton) +- Detecta seletores complexos e frágeis +- Alerta sobre IDs dinâmicos (ex: `#user-12345`) + +### 2. **Análise de Problemas de Rede** +- Detecta múltiplas requisições pendentes +- Identifica timeouts longos que sugerem operações assíncronas +- Sugere uso de `cy.intercept()` para melhor controle + +### 3. **Diagnóstico de Animações** +- Identifica quando animações estão causando delays +- Sugere configurações para desabilitar animações em testes + +### 4. **Detecção de Mutações Excessivas do DOM** +- Identifica quando o DOM está mudando rapidamente +- Sugere esperar por estabilização antes de interagir + +### 5. **Sugestões Específicas por Comando** +- Sugestões customizadas para cada tipo de comando (`get`, `click`, `type`, etc.) +- Links para documentação relevante + +## 📦 Estrutura + +``` +packages/driver/src/cypress/ +└── timeout_diagnostics.ts # Lógica principal + +packages/driver/test/unit/cypress/ +└── timeout_diagnostics.spec.ts # Testes unitários +``` + +## 🔧 API + +```typescript +import { TimeoutDiagnostics } from './timeout_diagnostics' + +// Analisar contexto e obter sugestões +const suggestions = TimeoutDiagnostics.analyze({ + command: 'get', + selector: '.loading-spinner', + timeout: 4000, + networkRequests: 5, + animationsRunning: true, +}) + +// Formatar sugestões para exibição +const formatted = TimeoutDiagnostics.formatSuggestions(suggestions) + +// Enriquecer mensagem de erro existente +const enhanced = TimeoutDiagnostics.enhanceTimeoutError( + 'cy.get() timed out', + context +) +``` + +## 🎨 Exemplos de Uso + +### Exemplo 1: Conteúdo Dinâmico +```typescript +// Teste que falha +cy.get('.loading-spinner').click() + +// Erro melhorado sugere: +// "Wait for the loading state to complete: +// cy.get('.loading-spinner').should('not.exist')" +``` + +### Exemplo 2: Problemas de Rede +```typescript +// Teste aguardando resposta API +cy.get('.user-data').should('be.visible') + +// Erro melhorado sugere: +// "Use cy.intercept() to wait for the specific request: +// cy.intercept('GET', '/api/users').as('getUsers') +// cy.wait('@getUsers')" +``` + +### Exemplo 3: Animações +```typescript +// Elemento animando quando clica +cy.get('.modal-button').click() + +// Erro melhorado sugere: +// "Disable animations: .click({ waitForAnimations: false }) +// Or globally: Cypress.config('animationDistanceThreshold', 0)" +``` + +## 🔮 Integração Futura + +Para integrar completamente no Cypress, seria necessário: + +1. **Modificar `error_utils.ts`** para capturar contexto adicional durante timeouts +2. **Coletar métricas** de rede, DOM e animações durante execução do comando +3. **Integrar na pipeline de erro** existente do Cypress +4. **Adicionar configuração** para habilitar/desabilitar diagnósticos + +```typescript +// Exemplo de integração em error_utils.ts +import TimeoutDiagnostics from './timeout_diagnostics' + +const createTimeoutError = (cmd, ms) => { + const context = { + command: cmd.get('name'), + selector: cmd.get('selector'), + timeout: ms, + networkRequests: getNetworkMonitor().pendingCount(), + animationsRunning: hasRunningAnimations(), + domMutations: getDOMMutationCount(), + } + + const baseMessage = `cy.${cmd.get('name')}() timed out waiting ${ms}ms` + return TimeoutDiagnostics.enhanceTimeoutError(baseMessage, context) +} +``` + +## 📊 Benefícios + +1. **Reduz tempo de debugging**: Desenvolvedores identificam problemas mais rapidamente +2. **Educação inline**: Ensina melhores práticas durante o desenvolvimento +3. **Menos frustração**: Erros mais claros = desenvolvedores mais felizes +4. **Reduz issues no GitHub**: Menos perguntas sobre "por que meu teste timeout?" +5. **Melhora adoção**: Desenvolvedores iniciantes aprendem mais rápido + +## 🧪 Testes + +Execute os testes unitários: + +```bash +cd packages/driver +yarn test timeout_diagnostics.spec.ts +``` + +Cobertura inclui: +- ✅ Detecção de todos os tipos de problemas +- ✅ Formatação de mensagens +- ✅ Casos extremos e edge cases +- ✅ Combinação de múltiplos diagnósticos + +## 🚀 Próximos Passos + +1. ✅ Criar módulo de diagnósticos com testes +2. ⏳ Integrar com sistema de erros existente +3. ⏳ Adicionar coleta de métricas de contexto +4. ⏳ Criar configuração para habilitar/desabilitar +5. ⏳ Adicionar mais padrões e sugestões baseado em feedback +6. ⏳ Documentação para usuários finais + +## 🤝 Contribuindo + +Este é um sistema extensível. Para adicionar novos diagnósticos: + +1. Adicione padrão em `COMMON_PATTERNS` +2. Crie método `analyze*Issues()` +3. Adicione testes correspondentes +4. Documente o novo diagnóstico + +## 📝 Licença + +MIT - Consistente com o projeto Cypress diff --git a/packages/driver/src/cypress/timeout_diagnostics.ts b/packages/driver/src/cypress/timeout_diagnostics.ts new file mode 100644 index 00000000000..bd11a6a0957 --- /dev/null +++ b/packages/driver/src/cypress/timeout_diagnostics.ts @@ -0,0 +1,308 @@ +/** + * Timeout Diagnostics - Smart suggestions for timeout errors + * + * This module provides contextual diagnostics and actionable suggestions + * when commands timeout, helping developers quickly identify and fix issues. + */ + +interface TimeoutContext { + command: string + selector?: string + timeout: number + previousCommands?: string[] + networkRequests?: number + domMutations?: number + animationsRunning?: boolean +} + +interface DiagnosticSuggestion { + reason: string + suggestions: string[] + docsUrl?: string +} + +/** + * Analyzes the context of a timeout error and provides intelligent suggestions + */ +export class TimeoutDiagnostics { + private static readonly COMMON_PATTERNS = { + // Selector-based patterns + dynamicContent: /loading|spinner|skeleton|placeholder/i, + asyncLoad: /fetch|api|graphql|ajax/i, + animation: /fade|slide|animate|transition/i, + + // Network patterns + slowNetwork: 3000, // threshold in ms + manyRequests: 5, + } + + private static readonly PARENT_COMMANDS = new Set([ + 'get', + 'contains', + ]) + + private static readonly CHILD_COMMANDS = new Set([ + 'click', + 'dblclick', + 'rightclick', + 'type', + 'check', + 'uncheck', + 'select', + 'clear', + 'submit', + 'focus', + 'blur', + 'trigger', + 'scrollTo', + ]) + + /** + * Generate diagnostic suggestions based on timeout context + */ + static analyze (context: TimeoutContext): DiagnosticSuggestion[] { + const suggestions: DiagnosticSuggestion[] = [] + + // Check for common selector issues + if (context.selector) { + suggestions.push(...this.analyzeSelectorIssues(context)) + } + + // Check for network-related issues + if (context.networkRequests !== undefined) { + suggestions.push(...this.analyzeNetworkIssues(context)) + } + + // Check for animation issues + if (context.animationsRunning) { + suggestions.push(...this.analyzeAnimationIssues(context)) + } + + // Check for DOM mutation issues + if (context.domMutations !== undefined && context.domMutations > 100) { + suggestions.push(this.analyzeDOMMutationIssues(context)) + } + + // If no specific issues found, provide general suggestions + if (suggestions.length === 0) { + suggestions.push(this.getGeneralSuggestions(context)) + } + + return suggestions + } + + private static analyzeSelectorIssues (context: TimeoutContext): DiagnosticSuggestion[] { + const suggestions: DiagnosticSuggestion[] = [] + const { selector = '', command } = context + + // Check for dynamic content indicators + if (this.COMMON_PATTERNS.dynamicContent.test(selector)) { + const escapedSelector = this.escapeSelector(selector) + + suggestions.push({ + reason: 'The selector appears to target dynamic/loading content that may not be ready yet', + suggestions: [ + `If waiting for content to load, wait for the loading indicator to disappear first: cy.get('${escapedSelector}').should('not.exist').then(() => cy.get('[data-cy="content"]'))`, + 'Or wait for the API request: cy.intercept("GET", "/api/*").as("loadData"); cy.wait("@loadData")', + 'Consider using data-cy attributes instead of class names that indicate loading states', + `If you need the loading element itself, ensure it exists before trying to interact: cy.get('${escapedSelector}').should('exist')`, + ], + docsUrl: 'https://on.cypress.io/best-practices#Selecting-Elements', + }) + } + + // Check for potentially incorrect selectors + if (selector.includes(' ') && !selector.includes('[') && command === 'get') { + const escapedFirst = this.escapeSelector(selector.split(' ')[0]) + const escapedRest = this.escapeSelector(selector.split(' ').slice(1).join(' ')) + + suggestions.push({ + reason: 'Complex selector detected - might be fragile or incorrect', + suggestions: [ + 'Verify the selector in DevTools: copy and paste it into the console', + 'Consider using data-cy attributes for more reliable selection', + `Break down into multiple steps: cy.get('${escapedFirst}').find('${escapedRest}')`, + ], + docsUrl: 'https://on.cypress.io/best-practices#Selecting-Elements', + }) + } + + // Check for ID selectors that might be dynamic + if (selector.startsWith('#')) { + const prefixMatch = selector.match(/^#([^\d]+)\d{3,}/) + + if (prefixMatch) { + const escapedPrefix = this.escapeSelector(prefixMatch[1]) + + suggestions.push({ + reason: 'Selector uses an ID with numbers - might be dynamically generated', + suggestions: [ + 'Dynamic IDs change between sessions and will cause flaky tests', + 'Use a data-cy attribute or a more stable selector instead', + `If the ID is dynamic, use a partial match: cy.get('[id^="${escapedPrefix}"]').first()`, + ], + }) + } + } + + return suggestions + } + + private static analyzeNetworkIssues (context: TimeoutContext): DiagnosticSuggestion[] { + const suggestions: DiagnosticSuggestion[] = [] + const { networkRequests = 0, timeout } = context + + // Many pending network requests + if (networkRequests >= this.COMMON_PATTERNS.manyRequests) { + suggestions.push({ + reason: `${networkRequests} network requests are still pending`, + suggestions: [ + 'Wait for specific API calls to complete using cy.intercept()', + 'Consider increasing the timeout if the requests are expected to be slow', + 'Check if some requests are failing or hanging in the Network tab', + 'Example: cy.intercept("GET", "/api/data").as("getData"); cy.wait("@getData")', + ], + docsUrl: 'https://on.cypress.io/intercept', + }) + } + + // Long timeout suggests waiting for async operation + if (timeout > this.COMMON_PATTERNS.slowNetwork) { + suggestions.push({ + reason: 'Long timeout suggests waiting for an async operation', + suggestions: [ + 'Use cy.intercept() to wait for the specific request instead of a timeout', + 'Verify the API endpoint is responding correctly', + 'Check if there are network throttling or CORS issues in DevTools', + 'Consider if the backend service is running and accessible', + ], + docsUrl: 'https://on.cypress.io/network-requests', + }) + } + + return suggestions + } + + private static analyzeAnimationIssues (context: TimeoutContext): DiagnosticSuggestion[] { + return [{ + reason: 'Animations are still running when the command timed out', + suggestions: [ + 'Disable animations in tests for faster and more reliable execution', + 'Add to your support file: Cypress.config("animationDistanceThreshold", 0)', + 'Or for specific commands: .click({ waitForAnimations: false })', + 'Ensure CSS animations have a reasonable duration (< 500ms)', + ], + docsUrl: 'https://on.cypress.io/actionability#Animations', + }] + } + + private static analyzeDOMMutationIssues (context: TimeoutContext): DiagnosticSuggestion { + return { + reason: `The DOM is changing rapidly (${context.domMutations} mutations detected)`, + suggestions: [ + 'Wait for the DOM to stabilize before interacting with elements', + 'Use .should() to wait for specific conditions instead of arbitrary waits', + 'Check if there are infinite loops or rapid re-renders in your application', + 'Example: cy.get(selector).should("be.visible").and("not.be.disabled")', + ], + docsUrl: 'https://on.cypress.io/retry-ability', + } + } + + private static getGeneralSuggestions (context: TimeoutContext): DiagnosticSuggestion { + const { command, timeout, selector } = context + const escapedSelector = selector ? this.escapeSelector(selector) : undefined + + const timeoutSuggestion = this.buildTimeoutSuggestion(command, timeout, escapedSelector) + + const generalSuggestions = [ + timeoutSuggestion, + 'Verify the element/condition you\'re waiting for actually appears', + 'Check the browser console and Network tab for errors', + 'Use .debug() before the failing command to inspect the state: cy.debug()', + ].filter(Boolean) as string[] + + // Add command-specific suggestions + if (command === 'get' && escapedSelector) { + generalSuggestions.unshift( + `Verify selector in DevTools: document.querySelector('${escapedSelector}')`, + 'Ensure the element is not hidden by CSS (display: none, visibility: hidden)', + ) + } + + if (['click', 'type'].includes(command)) { + generalSuggestions.unshift( + 'Ensure the element is visible, enabled, and not covered by another element', + 'Check if the element is being removed/recreated during the test', + ) + } + + return { + reason: `The ${command} command timed out after ${timeout}ms`, + suggestions: generalSuggestions, + docsUrl: `https://on.cypress.io/${command}`, + } + } + + private static escapeSelector (selector: string): string { + return selector + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + } + + private static buildTimeoutSuggestion (command: string, timeout: number, escapedSelector?: string): string { + const doubledTimeout = timeout * 2 + + if (this.PARENT_COMMANDS.has(command)) { + const selectorArg = escapedSelector ? `'${escapedSelector}', ` : '' + + return `Increase timeout if needed: cy.${command}(${selectorArg}{ timeout: ${doubledTimeout} })` + } + + if (this.CHILD_COMMANDS.has(command)) { + if (escapedSelector) { + return `Increase timeout on the query before ${command}: cy.get('${escapedSelector}').${command}({ timeout: ${doubledTimeout} })` + } + + return `Increase timeout on the preceding query before ${command}: cy.get(/* selector */).${command}({ timeout: ${doubledTimeout} })` + } + + return `Increase timeout if needed: cy.${command}({ timeout: ${doubledTimeout} })` + } + + /** + * Format diagnostic suggestions into a readable message + */ + static formatSuggestions (suggestions: DiagnosticSuggestion[]): string { + if (suggestions.length === 0) return '' + + let output = '\n\n🔍 Diagnostic Suggestions:\n' + + suggestions.forEach((suggestion, index) => { + output += `\n${index + 1}. ${suggestion.reason}\n` + + suggestion.suggestions.forEach((tip, tipIndex) => { + output += ` ${String.fromCharCode(97 + tipIndex)}) ${tip}\n` + }) + + if (suggestion.docsUrl) { + output += ` 📚 Learn more: ${suggestion.docsUrl}\n` + } + }) + + return output + } + + /** + * Enhanced error message with diagnostics + */ + static enhanceTimeoutError (originalMessage: string, context: TimeoutContext): string { + const suggestions = this.analyze(context) + const diagnostics = this.formatSuggestions(suggestions) + + return originalMessage + diagnostics + } +} + +export default TimeoutDiagnostics diff --git a/packages/driver/test/unit/cypress/timeout_diagnostics.spec.ts b/packages/driver/test/unit/cypress/timeout_diagnostics.spec.ts new file mode 100644 index 00000000000..7c0d0f59f7b --- /dev/null +++ b/packages/driver/test/unit/cypress/timeout_diagnostics.spec.ts @@ -0,0 +1,354 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect } from 'vitest' +import { TimeoutDiagnostics } from '../../../src/cypress/timeout_diagnostics' + +describe('TimeoutDiagnostics', () => { + describe('analyze', () => { + it('detects dynamic content selectors', () => { + const context = { + command: 'get', + selector: '.loading-spinner', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + expect(suggestions[0].reason).toContain('dynamic/loading content') + expect(suggestions[0].suggestions.some((s) => s.includes('wait for the loading indicator to disappear'))).toBe(true) + }) + + it('escapes quotes in dynamic content selector suggestions', () => { + const context = { + command: 'get', + selector: '[data-test=\'loading\']', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + expect(suggestions[0].suggestions.some((s) => s.includes('\\\''))).toBe(true) + }) + + it('preserves template literal characters in selector suggestions', () => { + const context = { + command: 'get', + selector: '[data-test=`value-${state}`]', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + const combined = suggestions.reduce((acc, suggestion) => { + acc.push(...suggestion.suggestions) + + return acc + }, []).join('\n') + + expect(combined).toContain('`value-${state}`') + expect(combined).not.toContain('\\`') + expect(combined).not.toContain('\\${') + }) + + it('detects complex selectors', () => { + const context = { + command: 'get', + selector: 'div .container button.submit', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('Complex selector'))).toBe(true) + }) + + it('detects dynamic ID selectors', () => { + const context = { + command: 'get', + selector: '#user-12345', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('dynamically generated'))).toBe(true) + const dynamicIdSuggestion = suggestions.find((s) => s.reason.includes('dynamically generated')) + + expect(dynamicIdSuggestion?.suggestions.some((tip) => tip.includes('[id^="user-"'))).toBe(true) + }) + + it('escapes double quotes in dynamic ID suggestions', () => { + const context = { + command: 'get', + selector: '#user"test-12345', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + const dynamicIdSuggestion = suggestions.find((s) => s.reason.includes('dynamically generated')) + const combinedTips = dynamicIdSuggestion?.suggestions.join('\n') ?? '' + + expect(combinedTips).toContain('\\"') + expect(combinedTips).toContain('[id^="user\\"test-"]') + }) + + it('detects network issues with many pending requests', () => { + const context = { + command: 'get', + selector: '.data-table', + timeout: 4000, + networkRequests: 8, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('network requests are still pending'))).toBe(true) + }) + + it('detects long timeout suggesting async operation', () => { + const context = { + command: 'contains', + selector: 'Success', + timeout: 10000, + networkRequests: 2, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('async operation'))).toBe(true) + }) + + it('detects animation issues', () => { + const context = { + command: 'click', + selector: '.modal-button', + timeout: 4000, + animationsRunning: true, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('Animations are still running'))).toBe(true) + expect(suggestions.some((s) => { + return s.suggestions.some((sug) => sug.includes('waitForAnimations: false')) + })).toBe(true) + }) + + it('detects excessive DOM mutations', () => { + const context = { + command: 'get', + selector: '.list-item', + timeout: 4000, + domMutations: 250, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.some((s) => s.reason.includes('DOM is changing rapidly'))).toBe(true) + }) + + it('provides general suggestions when no specific issues detected', () => { + const context = { + command: 'get', + selector: '.simple-div', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + expect(suggestions[0].reason).toContain('timed out after 4000ms') + expect(suggestions[0].suggestions.length).toBeGreaterThan(0) + }) + + it('avoids document.querySelector advice for contains text queries', () => { + const context = { + command: 'contains', + selector: 'Success', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + const combinedSuggestions = suggestions[0].suggestions.join('\n') + + expect(combinedSuggestions.includes('document.querySelector')).toBe(false) + }) + + it('provides command-specific suggestions for click', () => { + const context = { + command: 'click', + selector: '.button', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions[0].suggestions.some((s) => { + return s.includes('visible, enabled, and not covered') + })).toBe(true) + }) + + it('suggests increasing timeout on the querying command for child actions', () => { + const context = { + command: 'click', + selector: '.button', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + const combined = suggestions.flatMap((s) => s.suggestions).join('\n') + + expect(combined).toContain(`cy.get('.button').click({ timeout: 8000 })`) + }) + + it('falls back to a placeholder selector when child actions lack context', () => { + const context = { + command: 'click', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + const combined = suggestions.flatMap((s) => s.suggestions).join('\n') + + expect(combined).toContain('cy.get(/* selector */).click({ timeout: 8000 })') + }) + }) + + describe('formatSuggestions', () => { + it('formats suggestions with proper structure', () => { + const suggestions = [ + { + reason: 'Test reason', + suggestions: ['Suggestion 1', 'Suggestion 2'], + docsUrl: 'https://on.cypress.io/test', + }, + ] + + const formatted = TimeoutDiagnostics.formatSuggestions(suggestions) + + expect(formatted).toContain('🔍 Diagnostic Suggestions:') + expect(formatted).toContain('1. Test reason') + expect(formatted).toContain('a) Suggestion 1') + expect(formatted).toContain('b) Suggestion 2') + expect(formatted).toContain('📚 Learn more: https://on.cypress.io/test') + }) + + it('handles multiple diagnostic suggestions', () => { + const suggestions = [ + { + reason: 'First issue', + suggestions: ['Fix 1'], + }, + { + reason: 'Second issue', + suggestions: ['Fix 2'], + }, + ] + + const formatted = TimeoutDiagnostics.formatSuggestions(suggestions) + + expect(formatted).toContain('1. First issue') + expect(formatted).toContain('2. Second issue') + }) + + it('returns empty string for empty suggestions array', () => { + const formatted = TimeoutDiagnostics.formatSuggestions([]) + + expect(formatted).toBe('') + }) + }) + + describe('enhanceTimeoutError', () => { + it('enhances error message with diagnostics', () => { + const originalMessage = 'cy.get() timed out waiting 4000ms' + const context = { + command: 'get', + selector: '.loading', + timeout: 4000, + } + + const enhanced = TimeoutDiagnostics.enhanceTimeoutError(originalMessage, context) + + expect(enhanced).toContain(originalMessage) + expect(enhanced).toContain('🔍 Diagnostic Suggestions:') + expect(enhanced).toContain('dynamic/loading content') + }) + + it('preserves original message when no diagnostics available', () => { + const originalMessage = 'cy.wait() timed out' + const context = { + command: 'wait', + timeout: 5000, + } + + const enhanced = TimeoutDiagnostics.enhanceTimeoutError(originalMessage, context) + + expect(enhanced).toContain(originalMessage) + }) + }) + + describe('edge cases', () => { + it('handles context with minimal information', () => { + const context = { + command: 'custom', + timeout: 1000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + expect(suggestions[0].reason).toContain('timed out') + }) + + it('handles selector with special characters', () => { + const context = { + command: 'get', + selector: '[data-testid="user-profile"]', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions).toHaveLength(1) + }) + + it('escapes quotes in code suggestions to prevent syntax errors', () => { + const context = { + command: 'get', + selector: '[data-test=\'value\']', + timeout: 4000, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + const formatted = TimeoutDiagnostics.formatSuggestions(suggestions) + + // Verify quotes are escaped in suggestions + expect(formatted.includes('\\\'')).toBe(true) + // Verify no unescaped single quotes that would break JS + expect(formatted.match(/cy\.get\('\[data-test='value'\]'\)/)).toBe(null) + }) + + it('combines multiple diagnostic issues', () => { + const context = { + command: 'get', + selector: '.loading-spinner', + timeout: 8000, + networkRequests: 6, + animationsRunning: true, + domMutations: 150, + } + + const suggestions = TimeoutDiagnostics.analyze(context) + + expect(suggestions.length).toBeGreaterThan(1) + expect(suggestions.some((s) => s.reason.includes('dynamic/loading'))).toBe(true) + expect(suggestions.some((s) => s.reason.includes('network requests'))).toBe(true) + expect(suggestions.some((s) => s.reason.includes('Animations'))).toBe(true) + expect(suggestions.some((s) => s.reason.includes('DOM is changing'))).toBe(true) + }) + }) +}) diff --git a/scripts/semantic-commits/parse-changelog.js b/scripts/semantic-commits/parse-changelog.js index 804e01459ad..77a20f52f0a 100644 --- a/scripts/semantic-commits/parse-changelog.js +++ b/scripts/semantic-commits/parse-changelog.js @@ -3,9 +3,55 @@ const path = require('path') const { userFacingChanges } = require('./change-categories') const userFacingSections = Object.values(userFacingChanges).map(({ section }) => section) +const HEADER_PREVIEW_LINE_COUNT = 7 + +const normalizeChangelogContent = (content) => { + return content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') +} + +const releaseLinePatterns = { + pending: [ + /_Released \d+\/\d+\/\d+ \(PENDING\)_/, + /_Released xx\/xx\/xxxx \(PENDING\)_/i, + ], + released: [/_Released \d+\/\d+\/\d+_/], +} + +const findReleaseLineIndex = (lines, pendingRelease) => { + const patterns = pendingRelease ? releaseLinePatterns.pending : releaseLinePatterns.released + const maxIndex = Math.min(lines.length - 1, HEADER_PREVIEW_LINE_COUNT) + + for (let i = 0; i <= maxIndex; i++) { + const trimmedLine = lines[i].trim() + + if (!trimmedLine) { + continue + } + + const hasMatch = patterns.some((pattern) => pattern.test(trimmedLine)) + + if (hasMatch) { + return i + } + } + + return -1 +} + async function parseChangelog ({ pendingRelease = true, changelogContent = null } = {}) { - const changelog = changelogContent || fs.readFileSync(path.join(__dirname, '..', '..', 'cli', 'CHANGELOG.md'), 'utf8') + const changelog = normalizeChangelogContent(changelogContent || fs.readFileSync(path.join(__dirname, '..', '..', 'cli', 'CHANGELOG.md'), 'utf8')) const changeLogLines = changelog.split('\n') + const releaseLineIndex = findReleaseLineIndex(changeLogLines, pendingRelease) + const headerPreview = changeLogLines.slice(0, HEADER_PREVIEW_LINE_COUNT + 1) + .map((line, idx) => `${idx + 1}: ${JSON.stringify(line)}`).join('\n') + + console.log('Changelog header preview:\n' + headerPreview) + + if (releaseLineIndex === -1) { + const expected = pendingRelease ? '"_Released xx/xx/xxxx (PENDING)_"' : '"_Released xx/xx/xxxx_"' + + throw new Error(`Could not locate ${expected} within the first ${HEADER_PREVIEW_LINE_COUNT + 1} lines of cli/CHANGELOG.md.`) + } let parseChangelog = true const sections = {} @@ -23,34 +69,31 @@ async function parseChangelog ({ pendingRelease = true, changelogContent = null } const line = changeLogLines[index] + const trimmedLine = line.trim() // reached next release section - if (index > 1 && /^## \d+\.\d+\.\d+/.test(line)) { + if (index > 1 && /^## \d+\.\d+\.\d+/.test(trimmedLine)) { sections[currentSection] = content parseChangelog = false } if (index === 1) { - if (!/^## \d+\.\d+\.\d+/.test(line)) { + if (!/^## \d+\.\d+\.\d+/.test(trimmedLine)) { throw new Error(`Expected line number ${index + 1} to include "## x.x.x"`) } - sections['version'] = line - } else if (index === 3) { + sections['version'] = trimmedLine + } else if (index === releaseLineIndex) { nextKnownLineBreak = index + 1 - if (pendingRelease && !/_Released \d+\/\d+\/\d+ \(PENDING\)_/.test(line)) { - throw new Error(`Expected line number ${index + 1} to include "_Released xx/xx/xxxx (PENDING)_"`) - } else if (!pendingRelease && !/_Released \d+\/\d+\/\d+_/.test(line)) { - throw new Error(`Expected line number ${index + 1} to include "_Released xx/xx/xxxx_"`) - } - - sections['releaseDate'] = line + sections['releaseDate'] = trimmedLine } else if (index === nextKnownLineBreak) { - if (line !== '') { + if (trimmedLine !== '') { throw new Error(`Expected line number ${index + 1} to be a line break`) } + } else if (trimmedLine === '') { + continue } else { - const result = /^\*\*.+?:\*\*/.exec(line) + const result = /^\*\*.+?:\*\*/.exec(trimmedLine) if (currentSection === '' && !result) { throw new Error(`Expected line number ${index + 1} to be a valid section header. Received ${line}. Expected one of ...\n - ${userFacingSections.join('\n - ')}`)