How Cypress Implements the Cypress.on() and cy.on() Event System

Cypress builds its event system on top of EventEmitter2, using a $Cy class that extends EventEmitter2 to power the cy object, while an extend helper proxies EventEmitter2 methods onto the global Cypress object to create a unified event API.

The cypress-io/cypress repository implements a sophisticated event architecture that powers both the global Cypress.on() API and the per-test cy.on() method. This system allows developers to hook into test lifecycle events, command execution, and browser interactions through a consistent interface. Understanding how Cypress implements this event system reveals why the global and instance-level APIs behave identically while maintaining separate event scopes.

Core Architecture: EventEmitter2 Foundation

Cypress does not implement its own event system from scratch. Instead, it relies on the EventEmitter2 library to provide the underlying listener management and emission capabilities.

The architecture consists of two cooperating pieces:

  • $Cy class – A subclass of EventEmitter2 defined in packages/driver/src/cypress/cy.ts that powers the cy object. It holds test-run state, the command queue, and registers listeners for Cypress-wide events.
  • extend helper – A utility in packages/driver/src/cypress/events.ts that copies the EventEmitter2 API onto any object, specifically the global Cypress object.

The $Cy Class Implementation

The $Cy class serves as the concrete implementation of the cy object that test code interacts with. Because it inherits from EventEmitter2, it natively possesses methods like .on(), .once(), .off(), and .emit().

When a test run starts, the driver creates a new $Cy instance (referenced as curCy in lines 72-74 of cy.ts):

// packages/driver/src/cypress/cy.ts
const cy = new $Cy()

This instance holds the current test's state and becomes the target for event proxying from the global Cypress object.

The extend Helper and Global Cypress Object

The global Cypress object that users access in support files and plugins receives its event capabilities through the extend function in events.ts. This function creates a fresh EventEmitter2 instance and proxies its methods onto the passed-in object:

// packages/driver/src/cypress/events.ts
import { extend as extendEvents } from './events'

const Cypress = {} as any
extendEvents(Cypress) // Adds .on, .once, .emit, emitThen, etc.

The extend function also wires a proxyTo method that allows a child emitter (the $Cy instance) to forward its events to the parent while managing logging behavior.

Event Proxying and Forwarding

The connection between Cypress.on() and cy.on() happens through explicit proxying. Inside cy.ts, the driver hooks the $Cy instance into the global emitter:

// packages/driver/src/cypress/cy.ts
const cy = new $Cy()
extendEvents(Cypress).proxyTo(cy)

From this point forward, any call to Cypress.on('test:before:run', fn) registers the listener on the underlying $Cy emitter. The proxyTo method (lines 30-68 in events.ts) forwards events to the parent while temporarily silencing duplicate logs, preventing double-printing when both Cypress and cy listen to the same event.

Lifecycle Events and Emission

Throughout the driver codebase, events are emitted at critical lifecycle points:

  • Cypress.emit('enqueue:command') – Emitted in cy.ts when a command is queued (line 353)
  • Cypress.emit('test:before:run') – Emitted during test lifecycle transitions in command_queue.ts and proxy-logging.ts
  • Cypress.emit('command:enqueued') – Used to notify listeners when commands enter the queue

EventEmitter2 provides rich emission patterns including emit, emitThen, and emitThenSeries, which Cypress wraps with logging controls (logEmit flag) to conditionally output debug information.

Practical Usage Examples

Global Event Listeners

Use Cypress.on() in support files or plugins to handle events across all tests:

// cypress/support/e2e.ts
Cypress.on('uncaught:exception', (err) => {
  // Return false to prevent Cypress from failing the test
  return !err.message.includes('ResizeObserver loop')
})

Per-Test Listeners

Use cy.on() within individual tests for scoped event handling:

it('tracks command queue', () => {
  cy.on('command:enqueued', (cmd) => {
    console.log('Queued command:', cmd)
  })

  cy.get('button').click()
})

Custom Events

Emit and listen to custom events for plugin communication:

// Listening
Cypress.on('my:plugin:event', (payload) => {
  console.log('Plugin says:', payload)
})

// Emitting (rare in test code, but possible)
Cypress.emit('my:plugin:event', { foo: 'bar' })

Key Source Files

Understanding the implementation requires examining these specific files:

Summary

  • EventEmitter2 Foundation – Cypress leverages EventEmitter2 rather than building a custom event system, inheriting methods like on, once, emit, and emitThen.
  • Dual API Design – The $Cy class provides the cy.on() API, while the extend helper in events.ts proxies these capabilities to the global Cypress.on() API.
  • Proxy Pattern – The proxyTo method connects global and instance-level event emitters, ensuring events flow correctly while preventing duplicate logging.
  • Lifecycle Integration – The driver emits standard events like test:before:run, command:enqueued, and uncaught:exception throughout the test lifecycle.

Frequently Asked Questions

What is the difference between Cypress.on() and cy.on()?

Cypress.on() registers listeners on the global event emitter, affecting all tests in the run, while cy.on() registers listeners on the specific test's $Cy instance. However, because the global Cypress object proxies events to the current $Cy instance through the proxyTo method, both APIs ultimately operate on the same underlying event bus during test execution.

Why does Cypress use EventEmitter2 instead of Node.js EventEmitter?

EventEmitter2 provides advanced features like namespaced events, wildcards, and asynchronous emission patterns (emitThen, emitThenSeries) that the standard Node.js EventEmitter lacks. These capabilities are essential for Cypress's complex event handling across test lifecycles, command queues, and browser automation.

How do I prevent duplicate event handlers when using both Cypress.on() and cy.on()?

The proxyTo implementation in events.ts (lines 30-68) automatically suppresses duplicate logging, but you should still use Cypress.once() or manually call .off() if you need to ensure a handler fires only once. For per-test cleanup, cy.on() listeners automatically scope to the test and do not persist across tests, while Cypress.on() listeners persist until explicitly removed.

Can I emit custom events in Cypress?

Yes, you can emit custom events using Cypress.emit('event:name', payload), though this is rare in standard test code. This pattern is more commonly used in plugins and internal driver code to communicate between different parts of the Cypress architecture, such as between the driver and the reporter.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →