From `cypress run` to Test Execution: Tracing the Boot Sequence
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 moderun— launches headless run modeinstall— downloads and installs the Cypress binaryverify— checks that the binary is validcache— 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 thedist/directory. When reading CLI source, look at thelib/files — thedist/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:lifecycledebug 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.