Skip to content

Commit 39864da

Browse files
fix(driver): preserve literal selectors in diagnostics
1 parent 2b93731 commit 39864da

File tree

2 files changed

+86
-43
lines changed

2 files changed

+86
-43
lines changed

packages/driver/src/cypress/timeout_diagnostics.ts

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
/**
22
* Timeout Diagnostics - Smart suggestions for timeout errors
3-
*
3+
*
44
* This module provides contextual diagnostics and actionable suggestions
55
* when commands timeout, helping developers quickly identify and fix issues.
66
*/
77

8-
import _ from 'lodash'
9-
108
interface TimeoutContext {
119
command: string
1210
selector?: string
@@ -32,7 +30,7 @@ export class TimeoutDiagnostics {
3230
dynamicContent: /loading|spinner|skeleton|placeholder/i,
3331
asyncLoad: /fetch|api|graphql|ajax/i,
3432
animation: /fade|slide|animate|transition/i,
35-
33+
3634
// Network patterns
3735
slowNetwork: 3000, // threshold in ms
3836
manyRequests: 5,
@@ -41,7 +39,7 @@ export class TimeoutDiagnostics {
4139
/**
4240
* Generate diagnostic suggestions based on timeout context
4341
*/
44-
static analyze(context: TimeoutContext): DiagnosticSuggestion[] {
42+
static analyze (context: TimeoutContext): DiagnosticSuggestion[] {
4543
const suggestions: DiagnosticSuggestion[] = []
4644

4745
// Check for common selector issues
@@ -72,13 +70,13 @@ export class TimeoutDiagnostics {
7270
return suggestions
7371
}
7472

75-
private static analyzeSelectorIssues(context: TimeoutContext): DiagnosticSuggestion[] {
73+
private static analyzeSelectorIssues (context: TimeoutContext): DiagnosticSuggestion[] {
7674
const suggestions: DiagnosticSuggestion[] = []
7775
const { selector = '', command } = context
7876

7977
// Check for dynamic content indicators
8078
if (this.COMMON_PATTERNS.dynamicContent.test(selector)) {
81-
const escapedSelector = selector.replace(/'/g, "\\'");
79+
const escapedSelector = this.escapeSelector(selector)
8280

8381
suggestions.push({
8482
reason: 'The selector appears to target dynamic/loading content that may not be ready yet',
@@ -94,8 +92,8 @@ export class TimeoutDiagnostics {
9492

9593
// Check for potentially incorrect selectors
9694
if (selector.includes(' ') && !selector.includes('[') && command === 'get') {
97-
const escapedFirst = selector.split(' ')[0].replace(/'/g, "\\'");
98-
const escapedRest = selector.split(' ').slice(1).join(' ').replace(/'/g, "\\'");
95+
const escapedFirst = this.escapeSelector(selector.split(' ')[0])
96+
const escapedRest = this.escapeSelector(selector.split(' ').slice(1).join(' '))
9997

10098
suggestions.push({
10199
reason: 'Complex selector detected - might be fragile or incorrect',
@@ -109,24 +107,27 @@ export class TimeoutDiagnostics {
109107
}
110108

111109
// Check for ID selectors that might be dynamic
112-
if (selector.startsWith('#') && /\d{3,}/.test(selector)) {
113-
const prefix = selector.split(/\d/)[0];
114-
const escapedPrefix = prefix.replace(/'/g, "\\'");
115-
116-
suggestions.push({
117-
reason: 'Selector uses an ID with numbers - might be dynamically generated',
118-
suggestions: [
119-
'Dynamic IDs change between sessions and will cause flaky tests',
120-
'Use a data-cy attribute or a more stable selector instead',
121-
`If the ID is dynamic, use a partial match: cy.get('[id^="${escapedPrefix}"]').first()`,
122-
],
123-
})
110+
if (selector.startsWith('#')) {
111+
const prefixMatch = selector.match(/^#([^\d]+)\d{3,}/)
112+
113+
if (prefixMatch) {
114+
const escapedPrefix = this.escapeSelector(prefixMatch[1])
115+
116+
suggestions.push({
117+
reason: 'Selector uses an ID with numbers - might be dynamically generated',
118+
suggestions: [
119+
'Dynamic IDs change between sessions and will cause flaky tests',
120+
'Use a data-cy attribute or a more stable selector instead',
121+
`If the ID is dynamic, use a partial match: cy.get('[id^="${escapedPrefix}"]').first()`,
122+
],
123+
})
124+
}
124125
}
125126

126127
return suggestions
127128
}
128129

129-
private static analyzeNetworkIssues(context: TimeoutContext): DiagnosticSuggestion[] {
130+
private static analyzeNetworkIssues (context: TimeoutContext): DiagnosticSuggestion[] {
130131
const suggestions: DiagnosticSuggestion[] = []
131132
const { networkRequests = 0, timeout } = context
132133

@@ -161,7 +162,7 @@ export class TimeoutDiagnostics {
161162
return suggestions
162163
}
163164

164-
private static analyzeAnimationIssues(context: TimeoutContext): DiagnosticSuggestion[] {
165+
private static analyzeAnimationIssues (context: TimeoutContext): DiagnosticSuggestion[] {
165166
return [{
166167
reason: 'Animations are still running when the command timed out',
167168
suggestions: [
@@ -174,7 +175,7 @@ export class TimeoutDiagnostics {
174175
}]
175176
}
176177

177-
private static analyzeDOMMutationIssues(context: TimeoutContext): DiagnosticSuggestion {
178+
private static analyzeDOMMutationIssues (context: TimeoutContext): DiagnosticSuggestion {
178179
return {
179180
reason: `The DOM is changing rapidly (${context.domMutations} mutations detected)`,
180181
suggestions: [
@@ -187,20 +188,19 @@ export class TimeoutDiagnostics {
187188
}
188189
}
189190

190-
private static getGeneralSuggestions(context: TimeoutContext): DiagnosticSuggestion {
191+
private static getGeneralSuggestions (context: TimeoutContext): DiagnosticSuggestion {
191192
const { command, timeout, selector } = context
193+
const escapedSelector = selector ? this.escapeSelector(selector) : undefined
192194

193195
const generalSuggestions = [
194-
`Increase timeout if needed: cy.${command}(${selector ? `'${selector}', ` : ''}{ timeout: ${timeout * 2} })`,
196+
`Increase timeout if needed: cy.${command}(${escapedSelector ? `'${escapedSelector}', ` : ''}{ timeout: ${timeout * 2} })`,
195197
'Verify the element/condition you\'re waiting for actually appears',
196198
'Check the browser console and Network tab for errors',
197199
'Use .debug() before the failing command to inspect the state: cy.debug()',
198200
]
199201

200202
// Add command-specific suggestions
201-
if (['get', 'contains'].includes(command) && selector) {
202-
const escapedSelector = selector.replace(/'/g, "\\'");
203-
203+
if (command === 'get' && escapedSelector) {
204204
generalSuggestions.unshift(
205205
`Verify selector in DevTools: document.querySelector('${escapedSelector}')`,
206206
'Ensure the element is not hidden by CSS (display: none, visibility: hidden)',
@@ -221,17 +221,23 @@ export class TimeoutDiagnostics {
221221
}
222222
}
223223

224+
private static escapeSelector (selector: string): string {
225+
return selector
226+
.replace(/\\/g, '\\\\')
227+
.replace(/'/g, '\\\'')
228+
}
229+
224230
/**
225231
* Format diagnostic suggestions into a readable message
226232
*/
227-
static formatSuggestions(suggestions: DiagnosticSuggestion[]): string {
233+
static formatSuggestions (suggestions: DiagnosticSuggestion[]): string {
228234
if (suggestions.length === 0) return ''
229235

230236
let output = '\n\n🔍 Diagnostic Suggestions:\n'
231237

232238
suggestions.forEach((suggestion, index) => {
233239
output += `\n${index + 1}. ${suggestion.reason}\n`
234-
240+
235241
suggestion.suggestions.forEach((tip, tipIndex) => {
236242
output += ` ${String.fromCharCode(97 + tipIndex)}) ${tip}\n`
237243
})
@@ -247,10 +253,10 @@ export class TimeoutDiagnostics {
247253
/**
248254
* Enhanced error message with diagnostics
249255
*/
250-
static enhanceTimeoutError(originalMessage: string, context: TimeoutContext): string {
256+
static enhanceTimeoutError (originalMessage: string, context: TimeoutContext): string {
251257
const suggestions = this.analyze(context)
252258
const diagnostics = this.formatSuggestions(suggestions)
253-
259+
254260
return originalMessage + diagnostics
255261
}
256262
}

packages/driver/test/unit/cypress/timeout_diagnostics.spec.ts

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,33 @@ describe('TimeoutDiagnostics', () => {
2323
it('escapes quotes in dynamic content selector suggestions', () => {
2424
const context = {
2525
command: 'get',
26-
selector: "[data-test='loading']",
26+
selector: '[data-test=\'loading\']',
2727
timeout: 4000,
2828
}
2929

3030
const suggestions = TimeoutDiagnostics.analyze(context)
3131

3232
expect(suggestions).toHaveLength(1)
33-
expect(suggestions[0].suggestions.some((s) => s.includes("\\'"))).toBe(true)
33+
expect(suggestions[0].suggestions.some((s) => s.includes('\\\''))).toBe(true)
34+
})
35+
36+
it('preserves template literal characters in selector suggestions', () => {
37+
const context = {
38+
command: 'get',
39+
selector: '[data-test=`value-${state}`]',
40+
timeout: 4000,
41+
}
42+
43+
const suggestions = TimeoutDiagnostics.analyze(context)
44+
const combined = suggestions.reduce<string[]>((acc, suggestion) => {
45+
acc.push(...suggestion.suggestions)
46+
47+
return acc
48+
}, []).join('\n')
49+
50+
expect(combined).toContain('`value-${state}`')
51+
expect(combined).not.toContain('\\`')
52+
expect(combined).not.toContain('\\${')
3453
})
3554

3655
it('detects complex selectors', () => {
@@ -55,6 +74,9 @@ describe('TimeoutDiagnostics', () => {
5574
const suggestions = TimeoutDiagnostics.analyze(context)
5675

5776
expect(suggestions.some((s) => s.reason.includes('dynamically generated'))).toBe(true)
77+
const dynamicIdSuggestion = suggestions.find((s) => s.reason.includes('dynamically generated'))
78+
79+
expect(dynamicIdSuggestion?.suggestions.some((tip) => tip.includes('[id^="user-"'))).toBe(true)
5880
})
5981

6082
it('detects network issues with many pending requests', () => {
@@ -94,9 +116,9 @@ describe('TimeoutDiagnostics', () => {
94116
const suggestions = TimeoutDiagnostics.analyze(context)
95117

96118
expect(suggestions.some((s) => s.reason.includes('Animations are still running'))).toBe(true)
97-
expect(suggestions.some((s) =>
98-
s.suggestions.some((sug) => sug.includes('waitForAnimations: false')),
99-
)).toBe(true)
119+
expect(suggestions.some((s) => {
120+
return s.suggestions.some((sug) => sug.includes('waitForAnimations: false'))
121+
})).toBe(true)
100122
})
101123

102124
it('detects excessive DOM mutations', () => {
@@ -126,6 +148,21 @@ describe('TimeoutDiagnostics', () => {
126148
expect(suggestions[0].suggestions.length).toBeGreaterThan(0)
127149
})
128150

151+
it('avoids document.querySelector advice for contains text queries', () => {
152+
const context = {
153+
command: 'contains',
154+
selector: 'Success',
155+
timeout: 4000,
156+
}
157+
158+
const suggestions = TimeoutDiagnostics.analyze(context)
159+
160+
expect(suggestions).toHaveLength(1)
161+
const combinedSuggestions = suggestions[0].suggestions.join('\n')
162+
163+
expect(combinedSuggestions.includes('document.querySelector')).toBe(false)
164+
})
165+
129166
it('provides command-specific suggestions for click', () => {
130167
const context = {
131168
command: 'click',
@@ -135,9 +172,9 @@ describe('TimeoutDiagnostics', () => {
135172

136173
const suggestions = TimeoutDiagnostics.analyze(context)
137174

138-
expect(suggestions[0].suggestions.some((s) =>
139-
s.includes('visible, enabled, and not covered'),
140-
)).toBe(true)
175+
expect(suggestions[0].suggestions.some((s) => {
176+
return s.includes('visible, enabled, and not covered')
177+
})).toBe(true)
141178
})
142179
})
143180

@@ -242,15 +279,15 @@ describe('TimeoutDiagnostics', () => {
242279
it('escapes quotes in code suggestions to prevent syntax errors', () => {
243280
const context = {
244281
command: 'get',
245-
selector: "[data-test='value']",
282+
selector: '[data-test=\'value\']',
246283
timeout: 4000,
247284
}
248285

249286
const suggestions = TimeoutDiagnostics.analyze(context)
250287
const formatted = TimeoutDiagnostics.formatSuggestions(suggestions)
251288

252289
// Verify quotes are escaped in suggestions
253-
expect(formatted.includes("\\'")).toBe(true)
290+
expect(formatted.includes('\\\'')).toBe(true)
254291
// Verify no unescaped single quotes that would break JS
255292
expect(formatted.match(/cy\.get\('\[data-test='value'\]'\)/)).toBe(null)
256293
})

0 commit comments

Comments
 (0)