Skip to content

Commit 77deef7

Browse files
lucgonplucasgontijopacto
authored andcommitted
fix(driver): harden timeout diagnostics suggestions
1 parent 454b5db commit 77deef7

File tree

2 files changed

+57
-48
lines changed

2 files changed

+57
-48
lines changed

packages/driver/src/cypress/timeout_diagnostics.ts

Lines changed: 29 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export class TimeoutDiagnostics {
7676

7777
// Check for dynamic content indicators
7878
if (this.COMMON_PATTERNS.dynamicContent.test(selector)) {
79-
const escapedSelector = selector.replace(/'/g, '\\\'')
79+
const escapedSelector = this.escapeSelector(selector)
8080

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

9393
// Check for potentially incorrect selectors
9494
if (selector.includes(' ') && !selector.includes('[') && command === 'get') {
95-
const escapedFirst = selector.split(' ')[0].replace(/'/g, '\\\'')
96-
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(' '))
9797

9898
suggestions.push({
9999
reason: 'Complex selector detected - might be fragile or incorrect',
@@ -107,18 +107,21 @@ export class TimeoutDiagnostics {
107107
}
108108

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

124127
return suggestions
@@ -187,37 +190,17 @@ export class TimeoutDiagnostics {
187190

188191
private static getGeneralSuggestions (context: TimeoutContext): DiagnosticSuggestion {
189192
const { command, timeout, selector } = context
190-
const escapedSelector = selector?.replace(/'/g, '\\\'')
191-
const actionCommands = new Set([
192-
'click', 'type', 'dblclick', 'rightclick', 'check', 'uncheck',
193-
'select', 'focus', 'blur', 'submit', 'trigger',
194-
])
195-
196-
const timeoutSuggestion = (() => {
197-
if (escapedSelector && actionCommands.has(command)) {
198-
return `Increase timeout if needed: cy.get('${escapedSelector}').${command}({ timeout: ${timeout * 2} })`
199-
}
200-
201-
const args: string[] = []
202-
203-
if (escapedSelector) {
204-
args.push(`'${escapedSelector}'`)
205-
}
206-
207-
args.push(`{ timeout: ${timeout * 2} }`)
208-
209-
return `Increase timeout if needed: cy.${command}(${args.join(', ')})`
210-
})()
193+
const escapedSelector = selector ? this.escapeSelector(selector) : undefined
211194

212195
const generalSuggestions = [
213-
timeoutSuggestion,
196+
`Increase timeout if needed: cy.${command}(${escapedSelector ? `'${escapedSelector}', ` : ''}{ timeout: ${timeout * 2} })`,
214197
'Verify the element/condition you\'re waiting for actually appears',
215198
'Check the browser console and Network tab for errors',
216199
'Use .debug() before the failing command to inspect the state: cy.debug()',
217200
]
218201

219202
// Add command-specific suggestions
220-
if (['get', 'contains'].includes(command) && escapedSelector) {
203+
if (command === 'get' && escapedSelector) {
221204
generalSuggestions.unshift(
222205
`Verify selector in DevTools: document.querySelector('${escapedSelector}')`,
223206
'Ensure the element is not hidden by CSS (display: none, visibility: hidden)',
@@ -238,6 +221,14 @@ export class TimeoutDiagnostics {
238221
}
239222
}
240223

224+
private static escapeSelector (selector: string): string {
225+
return selector
226+
.replace(/\\/g, '\\\\')
227+
.replace(/`/g, '\\`')
228+
.replace(/\$\{/g, '\\${')
229+
.replace(/'/g, '\\\'')
230+
}
231+
241232
/**
242233
* Format diagnostic suggestions into a readable message
243234
*/

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

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ 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)
3434
})
3535

3636
it('detects complex selectors', () => {
@@ -55,6 +55,9 @@ describe('TimeoutDiagnostics', () => {
5555
const suggestions = TimeoutDiagnostics.analyze(context)
5656

5757
expect(suggestions.some((s) => s.reason.includes('dynamically generated'))).toBe(true)
58+
const dynamicIdSuggestion = suggestions.find((s) => s.reason.includes('dynamically generated'))
59+
60+
expect(dynamicIdSuggestion?.suggestions.some((tip) => tip.includes('[id^="user-"'))).toBe(true)
5861
})
5962

6063
it('detects network issues with many pending requests', () => {
@@ -94,9 +97,9 @@ describe('TimeoutDiagnostics', () => {
9497
const suggestions = TimeoutDiagnostics.analyze(context)
9598

9699
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)
100+
expect(suggestions.some((s) => {
101+
return s.suggestions.some((sug) => sug.includes('waitForAnimations: false'))
102+
})).toBe(true)
100103
})
101104

102105
it('detects excessive DOM mutations', () => {
@@ -126,6 +129,21 @@ describe('TimeoutDiagnostics', () => {
126129
expect(suggestions[0].suggestions.length).toBeGreaterThan(0)
127130
})
128131

132+
it('avoids document.querySelector advice for contains text queries', () => {
133+
const context = {
134+
command: 'contains',
135+
selector: 'Success',
136+
timeout: 4000,
137+
}
138+
139+
const suggestions = TimeoutDiagnostics.analyze(context)
140+
141+
expect(suggestions).toHaveLength(1)
142+
const combinedSuggestions = suggestions[0].suggestions.join('\n')
143+
144+
expect(combinedSuggestions.includes('document.querySelector')).toBe(false)
145+
})
146+
129147
it('provides command-specific suggestions for click', () => {
130148
const context = {
131149
command: 'click',
@@ -135,9 +153,9 @@ describe('TimeoutDiagnostics', () => {
135153

136154
const suggestions = TimeoutDiagnostics.analyze(context)
137155

138-
expect(suggestions[0].suggestions.some((s) =>
139-
s.includes('visible, enabled, and not covered'),
140-
)).toBe(true)
156+
expect(suggestions[0].suggestions.some((s) => {
157+
return s.includes('visible, enabled, and not covered')
158+
})).toBe(true)
141159
})
142160
})
143161

@@ -242,15 +260,15 @@ describe('TimeoutDiagnostics', () => {
242260
it('escapes quotes in code suggestions to prevent syntax errors', () => {
243261
const context = {
244262
command: 'get',
245-
selector: "[data-test='value']",
263+
selector: '[data-test=\'value\']',
246264
timeout: 4000,
247265
}
248266

249267
const suggestions = TimeoutDiagnostics.analyze(context)
250268
const formatted = TimeoutDiagnostics.formatSuggestions(suggestions)
251269

252270
// Verify quotes are escaped in suggestions
253-
expect(formatted.includes("\\'")).toBe(true)
271+
expect(formatted.includes('\\\'')).toBe(true)
254272
// Verify no unescaped single quotes that would break JS
255273
expect(formatted.match(/cy\.get\('\[data-test='value'\]'\)/)).toBe(null)
256274
})

0 commit comments

Comments
 (0)