Read OSS

From `cypress run` to Test Execution: Tracing the Boot Sequence

Intermediate

Prerequisites

  • Article 1: Architecture and Navigation Guide
  • Node.js child_process basics
  • Basic Electron concepts (main/renderer processes)

From cypress run to Test Execution: Tracing the Boot Sequence

In Part 1, we mapped the four layers of Cypress's architecture. Now we'll trace a single command — cypress run --spec "cypress/e2e/login.cy.ts" — from the moment you press Enter to the point where tests start executing in the browser. Understanding this boot sequence is essential for debugging startup issues, understanding configuration resolution, and knowing where to add instrumentation.

CLI Entry: Parsing Commands with Commander.js

Everything starts with a five-line shim. When you run cypress run, Node.js executes cli/bin/cypress:

#!/usr/bin/env node
const CLI = require('../dist/cli').default
CLI.init()

The CLI.init() call lands in cli/lib/cli.ts, which uses Commander.js to register commands. The key commands are:

  • open — launches interactive mode
  • run — launches headless run mode
  • install — downloads and installs the Cypress binary
  • verify — checks that the binary is valid
  • cache — manages the binary cache

Commander.js parses the arguments, normalizes flags (handling quirks like space-delimited --spec arguments), and delegates to the appropriate module. For cypress run, control flows to cli/lib/exec/run.ts, which collects all options and calls spawn.start().

flowchart TD
    BIN["cli/bin/cypress<br/>CLI.init()"]
    COMMANDER["Commander.js<br/>Parse open/run/install/verify"]
    RUN_MOD["cli/lib/exec/run.ts<br/>Collect options"]
    SPAWN["cli/lib/exec/spawn.ts<br/>Spawn Electron process"]

    BIN --> COMMANDER
    COMMANDER -->|"cypress run"| RUN_MOD
    RUN_MOD --> SPAWN

Tip: The CLI is bundled with Rollup (see cli/rollup.config.mjs) into the dist/ directory. When reading CLI source, look at the lib/ files — the dist/ files are build artifacts.

Spawning the Electron Binary

The spawn logic in cli/lib/exec/spawn.ts is where the development vs. production paths diverge. This is one of the most pragmatic pieces of code in the entire codebase:

sequenceDiagram
    participant CLI as CLI Process
    participant Spawn as spawn.ts
    participant Child as Child Process

    CLI->>Spawn: start(options)
    alt Development Mode (options.dev)
        Spawn->>Child: cp.spawn('node', ['scripts/start.js', ...args])
    else Production Mode
        Spawn->>Child: cp.spawn(electronBinary, ['--', ...args])
    end
    Child-->>Spawn: exit code
    Spawn-->>CLI: resolve({totalFailed})

In development mode (options.dev is true), the executable is simply node, and the entry point is scripts/start.js. No Electron binary needed — the server runs as a plain Node.js process.

In production mode, the code locates the installed Electron binary, optionally adds --no-sandbox if needed, and prepends -- to arguments so Electron doesn't try to interpret them (this fixes a Windows crash when URLs are in the arguments — see the inline comment referencing #5466).

The spawn function also handles platform-specific stderr filtering. On macOS and Linux, Xlib and libuv emit noise to stderr that would confuse users. The @packages/stderr-filtering package strips this noise before forwarding output.

Server Entry and Mode Determination

Once the Electron (or Node) process boots, control reaches packages/server/lib/cypress.ts. The comment at the top of this file explains a critical design decision:

"we are not requiring everything up front to optimize how quickly electron boots"

The start() method is the server's true entry point. It strips -- separators from argv, converts arguments to an options object, and then determines the mode:

flowchart TD
    START["start(argv)"]
    PARSE["argsUtils.toObject(argv)"]
    ENSURE["ensure appData folder exists"]

    CHECK_VER{options.version?}
    CHECK_SMOKE{options.smokeTest?}
    CHECK_RUN{options.runProject?}
    DEFAULT["mode = 'interactive'"]

    MODE_VER["mode = 'version'"]
    MODE_SMOKE["mode = 'smokeTest'"]
    MODE_RUN["mode = 'run'"]

    DISPATCH["startInMode(mode, options)"]

    START --> PARSE --> ENSURE
    ENSURE --> CHECK_VER
    CHECK_VER -->|yes| MODE_VER
    CHECK_VER -->|no| CHECK_SMOKE
    CHECK_SMOKE -->|yes| MODE_SMOKE
    CHECK_SMOKE -->|no| CHECK_RUN
    CHECK_RUN -->|yes| MODE_RUN
    CHECK_RUN -->|no| DEFAULT
    MODE_VER --> DISPATCH
    MODE_SMOKE --> DISPATCH
    MODE_RUN --> DISPATCH
    DEFAULT --> DISPATCH

The mode determination at line 201-215 follows a priority chain: version > smokeTest > returnPkg > exitWithCode > runProject (which maps to 'run' mode) > default 'interactive'.

For startInMode, interactive mode calls this.runElectron(mode, options), which either boots Electron (if not already running) or directly requires the modes module. Non-interactive modes like 'run' also go through runElectron — the method name is misleading, but the path through it is clear: if already inside Electron, it calls require('./modes')(mode, options) directly.

DataContext Creation and Mode Dispatch

The modes module at packages/server/lib/modes/index.ts is the critical junction where Cypress's state management system initializes:

const ctx = setCtx(makeDataContext({
  mode: mode === 'run' ? mode : 'open',
  modeOptions: options,
}))

const loadingPromise = ctx.initializeMode()

This creates the DataContext — Cypress's centralized state hub (which we'll explore in depth in Part 6). The DataContext instantiates ~15 DataSource classes, creates the ProjectLifecycleManager, and initializes the GraphQL schema.

sequenceDiagram
    participant Modes as modes/index.ts
    participant DC as DataContext
    participant PLM as ProjectLifecycleManager
    participant Run as modes/run.ts
    participant Interactive as modes/interactive.ts

    Modes->>DC: makeDataContext({mode, modeOptions})
    DC->>PLM: new ProjectLifecycleManager(ctx)
    Modes->>DC: ctx.initializeMode()
    alt mode === 'run'
        Modes->>Run: run(options, loadingPromise)
    else mode === 'interactive'
        Modes->>Interactive: run(options, loadingPromise)
    end

Note how the loadingPromise (from ctx.initializeMode()) is passed to both run and interactive modes. This allows mode-specific code to wait for initialization to complete before proceeding — a pattern that avoids blocking the main thread during config loading.

For run mode, the defaults are telling: browser defaults to 'electron', isTextTerminal is true, and testingType defaults to 'e2e'. These defaults shape everything downstream.

Config File Detection and Project Lifecycle

The ProjectLifecycleManager (PLM) at packages/data-context/src/data/ProjectLifecycleManager.ts is responsible for one of Cypress's most user-facing features: detecting and loading the configuration file.

The PLM searches for config files in a priority order:

const POTENTIAL_CONFIG_FILES = [
  'cypress.config.ts',
  'cypress.config.mjs',
  'cypress.config.cjs',
  'cypress.config.js',
]

It maintains a ProjectMetaState object tracking what it has discovered:

interface ProjectMetaState {
  isUsingTypeScript: boolean
  hasCypressEnvFile: boolean
  hasValidConfigFile: boolean
  hasSpecifiedConfigViaCLI: false | string
  allFoundConfigFiles: string[]
  isProjectUsingESModules: boolean
}

The PLM delegates to a ProjectConfigManager that handles the actual config loading via IPC — it spawns a child process to evaluate the user's config file (which may contain setupNodeEvents, arbitrary Node.js code), captures the result, and merges it with defaults from @packages/config.

flowchart TD
    PLM["ProjectLifecycleManager"]
    SCAN["Scan for cypress.config.*"]
    DETECT["Detect TypeScript, ESM, env file"]
    PCM["ProjectConfigManager"]
    IPC["Child process<br/>Evaluate config file"]
    MERGE["Merge with defaults"]
    READY["Config ready"]

    PLM --> SCAN --> DETECT --> PCM --> IPC --> MERGE --> READY

Tip: If you're debugging config loading issues, enable the cypress:lifecycle debug namespace. The PLM logs every state transition, making it easy to see where config resolution stalls.

Run Mode: Spec Iteration and Result Collection

Once config is loaded and browsers are detected, run mode enters its main loop in packages/server/lib/modes/run.ts. The iterateThroughSpecs function supports two strategies:

Serial execution (the default) uses Bluebird.mapSeries(specs, runEachSpec) to run each spec file one after another. This is the simplest path and what most developers encounter.

Parallel execution (with --record and --parallel) uses a recursive pattern: it calls beforeSpecRun() to claim the next spec from the Cypress Cloud API, runs it, reports results via afterSpecRun(), and recurses until no more specs are available. This is how Cypress achieves parallelism across CI machines — the Cloud API acts as a spec queue.

For each spec, the flow is: launch browser → inject driver → wait for Socket.IO connection → receive test results → collect artifacts (screenshots, videos) → move to next spec.

The result collection culminates in an exit code: totalFailed is passed back through the promise chain to cypress.ts, which calls process.exit(totalFailed). In POSIX mode (--posix-exit-codes), this simplifies to 0 for success or 1 for any failure.

What's Next

We've traced the boot sequence from CLI to spec execution. But we skipped over the most architecturally interesting component: the HTTP proxy that sits between the browser and the network. In Part 3, we'll dive deep into the proxy pipeline — the mechanism that makes Cypress's script injection, request interception, and cross-origin support possible.