From 196dd1e5abc59c311fd9d2378e53bd6d8fdcb298 Mon Sep 17 00:00:00 2001 From: Omer Biran Date: Tue, 20 Jan 2026 20:57:27 +0200 Subject: [PATCH 1/2] feat: add global test timeout configuration option Add new `testTimeout` configuration option that allows setting a global maximum duration for tests. If a test (including all its hooks) exceeds this duration, it will fail automatically. - Default value is 0 (disabled for backward compatibility) - Can be overridden at any level (config file, describe, it) - Properly cleans up timeout when test completes or is retried This addresses the need for a global test timeout that limits overall test duration, complementing existing command-level timeouts. --- cli/types/cypress.d.ts | 6 ++++ packages/config/src/options.ts | 5 +++ packages/driver/src/cypress/error_messages.ts | 4 +++ packages/driver/src/cypress/runner.ts | 32 +++++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/cli/types/cypress.d.ts b/cli/types/cypress.d.ts index 0ba36524b2c..8925c6cf8b2 100644 --- a/cli/types/cypress.d.ts +++ b/cli/types/cypress.d.ts @@ -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 diff --git a/packages/config/src/options.ts b/packages/config/src/options.ts index 8e1faf30934..606b7ec8cc2 100644 --- a/packages/config/src/options.ts +++ b/packages/config/src/options.ts @@ -429,6 +429,11 @@ const driverConfigOptions: Array = [ defaultValue: 60000, validation: validate.isNumber, overrideLevel: 'any', + }, { + name: 'testTimeout', + defaultValue: 0, + validation: validate.isNumber, + overrideLevel: 'any', }, { name: 'testIsolation', defaultValue: true, diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 8f9612c8ced..62c356c5a43 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -961,6 +961,10 @@ 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.', + 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. diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index 889ed4432d2..404ee80f0c9 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -61,6 +61,35 @@ const duration = (before: Date, after: Date) => { return Number(before) - Number(after) } +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) + const err = $errUtils.errByPath('mocha.global_test_timeout', { + ms: testTimeout, + elapsed, + }) + + debug('global test timeout exceeded: %dms elapsed', elapsed) + 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) { @@ -116,6 +145,7 @@ const runnableAfterRunAsync = (runnable, Cypress) => { const testAfterRun = (test, Cypress) => { test.clearTimeout() + clearGlobalTestTimeout(test) if (!fired(TEST_AFTER_RUN_EVENT, test)) { setWallClockDuration(test) try { @@ -1693,6 +1723,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' From ab17c8711bdc2e23cc504b1399ea39432c847087 Mon Sep 17 00:00:00 2001 From: Omer Biran Date: Tue, 20 Jan 2026 21:00:32 +0200 Subject: [PATCH 2/2] feat: add diagnostics dump when global test timeout is reached When a test exceeds the global timeout, collect and log diagnostic information to help debug what was happening: - Current command being executed (name, args, type, timeout) - Command queue state (total commands, last 20 command names) - Recent logs (last 10 log entries with name, message, state) - Current URL - Registered aliases Diagnostics are: 1. Logged to browser console in a grouped format 2. Attached to the error object as 'diagnostics' property for programmatic access in event handlers --- packages/driver/src/cypress/error_messages.ts | 4 +- packages/driver/src/cypress/runner.ts | 96 ++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/driver/src/cypress/error_messages.ts b/packages/driver/src/cypress/error_messages.ts index 62c356c5a43..0a946b4a6f7 100644 --- a/packages/driver/src/cypress/error_messages.ts +++ b/packages/driver/src/cypress/error_messages.ts @@ -962,7 +962,9 @@ export default { 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.', + 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: { diff --git a/packages/driver/src/cypress/runner.ts b/packages/driver/src/cypress/runner.ts index 404ee80f0c9..0bbf17ec26e 100644 --- a/packages/driver/src/cypress/runner.ts +++ b/packages/driver/src/cypress/runner.ts @@ -61,6 +61,62 @@ const duration = (before: Date, after: Date) => { return Number(before) - Number(after) } +const collectTimeoutDiagnostics = (cy, Cypress) => { + const diagnostics: Record = {} + + 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') @@ -68,12 +124,50 @@ const setupGlobalTestTimeout = (test, Cypress, cy) => { 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, }) - debug('global test timeout exceeded: %dms elapsed', 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