Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli/types/cypress.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3011,6 +3011,12 @@ declare namespace Cypress {
* @default 60000
*/
taskTimeout: number
/**
* Global test timeout in milliseconds. If a test (including all its hooks) exceeds this duration, it will fail.
* Set to 0 to disable global test timeout.
* @default 0
*/
testTimeout: number
/**
* Path to folder where application files will attempt to be served from
* @default root project folder
Expand Down
5 changes: 5 additions & 0 deletions packages/config/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,11 @@ const driverConfigOptions: Array<DriverConfigOption> = [
defaultValue: 60000,
validation: validate.isNumber,
overrideLevel: 'any',
}, {
name: 'testTimeout',
defaultValue: 0,
validation: validate.isNumber,
overrideLevel: 'any',
}, {
name: 'testIsolation',
defaultValue: true,
Expand Down
6 changes: 6 additions & 0 deletions packages/driver/src/cypress/error_messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,12 @@ export default {
async_timed_out: 'Timed out after `{{ms}}ms`. The `done()` callback was never invoked!',
invalid_interface: 'Invalid mocha interface `{{name}}`',
timed_out: 'Cypress command timeout of `{{ms}}ms` exceeded.',
global_test_timeout: {
message: `Test exceeded the global timeout of \`{{ms}}ms\`. The test ran for \`{{elapsed}}ms\` before being stopped.

Check the browser console for detailed diagnostics including the command queue and current state.`,
docsUrl: 'https://on.cypress.io/test-timeout',
},
overspecified: {
message: stripIndent`\
Cypress detected that you returned a promise in a test, but also invoked a done callback. Return a promise -or- invoke a done callback, not both.
Expand Down
126 changes: 126 additions & 0 deletions packages/driver/src/cypress/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,129 @@ const duration = (before: Date, after: Date) => {
return Number(before) - Number(after)
}

const collectTimeoutDiagnostics = (cy, Cypress) => {
const diagnostics: Record<string, any> = {}

try {
// Get current command being executed
const currentCommand = cy.state('current')

if (currentCommand) {
diagnostics.currentCommand = {
name: currentCommand.get('name'),
args: currentCommand.get('args'),
type: currentCommand.get('type'),
timeout: currentCommand.get('timeout'),
}
}

// Get command queue state
const queue = cy.queue
const commands = queue.get()

diagnostics.commandQueue = {
total: commands.length,
names: queue.names().slice(-20), // last 20 command names
currentIndex: queue.index,
}

// Get all logs from the queue
const logs = queue.logs()

diagnostics.logs = logs.slice(-10).map((log) => ({
name: log.get('name'),
message: log.get('message'),
state: log.get('state'),
type: log.get('type'),
}))

// Get current URL
const win = cy.state('window')

if (win) {
diagnostics.currentUrl = win.location?.href
}

// Get aliases
const aliases = cy.state('aliases') || {}

diagnostics.aliases = Object.keys(aliases)

} catch (e) {
debug('error collecting timeout diagnostics: %o', e)
diagnostics.error = 'Failed to collect some diagnostics'
}

return diagnostics
}

const setupGlobalTestTimeout = (test, Cypress, cy) => {
const testTimeout = Cypress.config('testTimeout')

if (testTimeout > 0 && !test._globalTimeoutId) {
debug('setting up global test timeout: %dms', testTimeout)
test._globalTimeoutId = setTimeout(() => {
const elapsed = duration(new Date(), test.wallClockStartedAt)

// Collect diagnostic information before failing
const diagnostics = collectTimeoutDiagnostics(cy, Cypress)

debug('global test timeout exceeded: %dms elapsed, diagnostics: %o', elapsed, diagnostics)

// Log diagnostics to console for debugging
// eslint-disable-next-line no-console
console.group('🕐 Global Test Timeout Diagnostics')
// eslint-disable-next-line no-console
console.log('Test:', test.title)
// eslint-disable-next-line no-console
console.log('Timeout:', testTimeout, 'ms')
// eslint-disable-next-line no-console
console.log('Elapsed:', elapsed, 'ms')

if (diagnostics.currentCommand) {
// eslint-disable-next-line no-console
console.log('Current Command:', diagnostics.currentCommand.name)
}

if (diagnostics.commandQueue) {
// eslint-disable-next-line no-console
console.log('Command Queue:', diagnostics.commandQueue.names.join(' → '))
}

if (diagnostics.currentUrl) {
// eslint-disable-next-line no-console
console.log('Current URL:', diagnostics.currentUrl)
}

// eslint-disable-next-line no-console
console.log('Full diagnostics:', diagnostics)
// eslint-disable-next-line no-console
console.groupEnd()

const err = $errUtils.errByPath('mocha.global_test_timeout', {
ms: testTimeout,
elapsed,
})

// Attach diagnostics to the error for programmatic access
;(err as any).diagnostics = diagnostics

test._globalTimeoutId = null

// fail the test via cy.fail which handles the error properly
cy.fail(err)
}, testTimeout)
}
}

const clearGlobalTestTimeout = (test) => {
if (test._globalTimeoutId) {
debug('clearing global test timeout')
clearTimeout(test._globalTimeoutId)
test._globalTimeoutId = null
}
}

const fire = (event: typeof RUNNER_EVENTS[number], runnable, Cypress, ...args) => {
debug('fire: %o', { event })
if (runnable._fired == null) {
Expand Down Expand Up @@ -116,6 +239,7 @@ const runnableAfterRunAsync = (runnable, Cypress) => {

const testAfterRun = (test, Cypress) => {
test.clearTimeout()
clearGlobalTestTimeout(test)
if (!fired(TEST_AFTER_RUN_EVENT, test)) {
setWallClockDuration(test)
try {
Expand Down Expand Up @@ -1693,6 +1817,8 @@ export default {

if (test.wallClockStartedAt == null) {
test.wallClockStartedAt = wallClockStartedAt
// Set up global test timeout if configured
setupGlobalTestTimeout(test, Cypress, cy)
}

const isHook = runnable.type === 'hook'
Expand Down
Loading