* test: assert with vitest's matchers instead of chai
Every assertion moves from chai's chainable language to vitest's own
matchers, so an assertion is always a call and never a bare property read:
- `to.equal` → `toBe`, `to.deep.equal`/`eql` → `toEqual`, `to.be.true` →
`toBe(true)`, `to.have.property` → `toHaveProperty`, `to.include` →
`toContain`/`toMatchObject`/`toContainEqual`, `to.be.a('string')` →
`toBeTypeOf('string')`, `instanceOf` → `toBeInstanceOf`, `lengthOf` →
`toHaveLength`, `above`/`least`/`below`/`most` → `toBeGreaterThan` and
friends, `match` → `toMatch`, `throw` → `toThrow`, `oneOf` → `toContain`
with the operands swapped, `closeTo(e, d)` → `Math.abs(x - e) <= d`,
`members`/`keys` → `arrayContaining`/`Object.keys`. A chain that kept
going after `.property()` or `.a()` becomes one matcher where vitest has
one (`toHaveProperty(key, expect.any(String))`) and separate assertions
otherwise. chai-as-promised becomes `.resolves`/`.rejects`, a failed
manual check is `assert.fail()` rather than `new chai.AssertionError`.
- vitest reports promise assertions that are not awaited; the app badge,
webview zoom and `import('electron')` specs had some that never ran and
now do (the service worker badge ones waited for a count the worker had
already cleared, so those just check the worker's answer; the zoom fixture
issued loadURL() from dom-ready, which aborts, and now waits for the load).
- Code that runs outside the spec module graph (itremote bodies in a
renderer, the utility-process net harness, two crash-case apps) is
CommonJS and vitest refuses `require('vitest')`, so it loads the same
module through spec/lib/vitest-cjs.cjs.
- A lint rule (script/lint-plugins/no-chai-assertions.mjs) rejects chai
chains on `expect()`, and no-unused-expressions is on for the specs, so
neither form of property-style assertion can come back.
- chai, chai-as-promised, dirty-chai and their @types leave
spec/package.json; the refresh-page fixture served chai.js only as "a
large script" and serves the jquery fixture instead.
* test: return promises instead of taking mocha's `done` callback
vitest has no `done` parameter. The tests and hooks that used one now
return a promise instead; where the callback was only ever called as
`done()` it is the promise's `resolve`, and where it was also handed to an
error-first API or called as `done(error)` it keeps mocha's meaning
(reject on a truthy argument, resolve otherwise) through a local `done`.
* test: move the spec runner's own semantics out of the mocha shim
Three things the shim did are not mocha compatibility but how the Electron
specs need to run, whichever API they are written against: every hook and
test starts from a fresh macrotask so that Chromium's message loop turns
between them, `defer()`-ed cleanup runs before the enclosing suites' own
afterEach hooks, and spec/disabled-tests.json can switch a test off by its
title. Those now live in a small custom vitest runner
(spec/vitest/runner.ts) that applies to every test, and the shim only
translates mocha's API.
* test: write the specs against vitest's API instead of mocha's
Mechanical conversion of every spec file and helper:
- `describe`/`it`/`beforeEach`/... are imported from 'vitest' rather than
taken from the runner's globals; `before`/`after` become
`beforeAll`/`afterAll`.
- `this.timeout(n)` becomes the `{ timeout: n }` option of the test or
suite (a hook's second argument; hooks inside a suite that had a timeout
get it explicitly, since vitest's suite option only reaches the tests), `this.retries(n)` `{ retry: n }`, and
`this.skip()` the test context's `ctx.skip()`. The two `before` hooks that
skipped a whole suite now compute the condition up front
(`describe.runIf`) or per test. `function () {}` callbacks that only
existed for `this` are arrows again.
- `ifit`/`ifdescribe` map to `it.skip`/`describe.skip`, mocha's chained
`it(...).timeout(n)` and `this.bail()` are gone, and the two helpers that
read `this.test` use the context's task state.
* test: remove the mocha compatibility layer
Nothing imports mocha's API any more, so spec/vitest/mocha-compat.ts and the
setup file that installed its globals go, together with @types/mocha and the
Mocha type overloads in spec/ambient.d.ts. Timeouts are vitest's own again
(30 s per test and hook, ELECTRON_SPEC_TIMEOUT raises it for the sanitizer
jobs, replacing MOCHA_TIMEOUT), the junit path variable becomes
ELECTRON_SPEC_JUNIT_FILE, and `closeAllWindows` loses the parameter that a
hook's context argument would otherwise land in.
14 KiB
Electron Development Guide
Running node_modules binaries
Never use npx. It is considered dangerous because it can silently fetch and execute arbitrary packages from the registry. Always run binaries through one of these safer mechanisms instead:
- Preferred — spawn the executable directly from
node_modules/.bin/<tool>(or the platform equivalent on Windows). This is whatscript/lint.jsdoes foroxlint. - Acceptable — invoke via
yarn <tool>oryarn run <tool>, which resolves to the locally installed version without the registry fallback thatnpxperforms.
This rule applies to shell commands you run yourself and to any scripts you author or modify in this repo.
Project Overview
Electron is a framework for building cross-platform desktop applications using web technologies. It embeds Chromium for rendering and Node.js for backend functionality.
Directory Structure
electron/ # This repo (run `e` commands here)
├── shell/ # Core C++ application code
│ ├── browser/ # Main process implementation (107+ API modules)
│ ├── renderer/ # Renderer process code
│ ├── common/ # Shared code between processes
│ ├── app/ # Application entry points
│ └── services/ # Node.js service integration
├── lib/ # TypeScript/JavaScript library code
│ ├── browser/ # Main process JS (47 API implementations)
│ ├── renderer/ # Renderer process JS
│ └── common/ # Shared JS modules
├── patches/ # Patches for upstream dependencies
│ ├── chromium/ # ~159 patches to Chromium
│ ├── node/ # ~48 patches to Node.js
│ └── ... # Other targets (v8, boringssl, etc.)
├── spec/ # Test suite (1189+ TypeScript test files)
├── docs/ # API documentation and guides
├── build/ # Build configuration
├── script/ # Build and automation scripts
└── chromium_src/ # Chromium source overrides
../ # Parent directory is Chromium source
Build Tools Setup
Electron uses @electron/build-tools for development. The e command is the primary CLI.
Installation:
npm i -g @electron/build-tools
Configuration location: ~/.electron_build_tools/configs/
Essential Commands
Configuration Management
| Command | Purpose |
|---|---|
e init <name> --root=<path> --bootstrap testing |
Create new build config and sync |
e use <name> |
Switch to a different build configuration |
e show current |
Display active configuration name |
e show configs |
List all available configurations |
Build & Development Loop
| Command | Purpose |
|---|---|
e sync |
Fetch/update all source code and apply patches |
e sync --3 |
Sync with 3-way merge (required for Chromium upgrades) |
e build |
Build Electron (runs GN + Ninja) |
e build -k 999 |
Build and continue on errors (up to 999) |
e build -t <target> |
Build specific target (e.g., electron:node_headers) |
e start |
Run the built Electron executable |
e start --version |
Verify Electron launches and print version |
e test |
Run the test suite |
e debug |
Run Electron in debugger (lldb on macOS, gdb on Linux) |
Patch Management
| Command | Purpose |
|---|---|
e patches <target> |
Export patches for a target (chromium, node, v8, etc.) |
e patches all |
Export all patches from all targets |
e patches --list-targets |
List available patch targets |
Typical Development Workflow
# 1. Ensure you're on the right config
e show current
# 2. Sync to get latest code
e sync
# 3. Make your changes in shell/ or lib/ or ../
# 4. Build
e build
# 5. Test your changes (Leave the user to do this, don't run these commands unless asked)
e start
e test
# 6. If you modified patched files in Chromium:
cd .. # Go to Chromium repo
git add <files>
git commit -m "description of change"
cd electron
e patches chromium # Export the patch
Patches System
Electron patches upstream dependencies (Chromium, Node.js, V8, etc.) to add features or modify behavior.
How patches work:
patches/{target}/*.patch → [e sync --3] → target repo commits
← [e patches] ←
Patch configuration: patches/config.json maps patch directories to target repos.
Key rules:
- Fix existing patches 99% of the time rather than creating new ones
- Preserve original authorship in TODO comments
- Never change TODO assignees (
TODO(name)must retain original name) - Each patch file includes commit message explaining its purpose
Creating/modifying patches:
- Make changes in the target repo (e.g.,
../for Chromium) - Create a git commit
- Run
e patches <target>to export
Fixing patch conflicts on an existing PR:
If asked to fix a patch conflict on a branch that already has an open PR, check the PR's failed Apply Patches CI run for an update-patches.patch artifact before running e sync locally. CI has already performed the 3-way merge and exported the resolved patch diff — applying it is much faster than a full local sync.
# Find the failed Apply Patches run for the PR, then fetch its artifact. The
# artifact is uploaded unarchived (name `update-patches.patch`), so
# `gh run download` rejects it as "not a valid zip"; the artifact zip endpoint
# returns the raw patch instead.
gh run list --repo electron/electron --branch <pr-branch> --workflow "Apply Patches" --limit 1
gh api repos/electron/electron/actions/runs/<run-id>/artifacts --jq '.artifacts[] | select(.name == "update-patches.patch") | .id'
gh api repos/electron/electron/actions/artifacts/<artifact-id>/zip > update-patches.patch
git am update-patches.patch
git push
If no artifact exists (e.g. the 3-way merge itself failed), fall back to e sync --3 and resolve manually.
Testing
Test location: spec/ directory
Running tests:
e test # Run full test suite
Test frameworks: vitest (describe/it/expect imported from vitest; assertions use its matchers, expect(x).toBe(y), never chai-style .to.equal chains)
Build Configuration
GN build arguments: Located in build/args/:
testing.gn- Debug/testing buildsrelease.gn- Release buildsall.gn- Common arguments for all builds
Main build file: BUILD.gn
Feature flags: buildflags/buildflags.gni
Chromium Upgrade Workflow
When working on the roller/chromium/main branch to upgrade Chromium activate the "Electron Chromium Upgrade" skill.
Node.js Upgrade Workflow
When working on the roller/node/main branch to upgrade Node.js activate the "Electron Node.js Upgrade" skill.
Pull Requests
PR bodies must always include a Notes: section as the last line of the body. This is a consumer-facing release note for Electron app developers — describe the user-visible fix or change, not internal implementation details. Use Notes: none if there is no user-facing change.
PR Labeling (write-access only)
When the user has write access to electron/electron, add these labels when creating PRs:
Semver label — one of:
semver/none— build changes, refactors, CI, or anything with no end-user impactsemver/patch— backwards-compatible bug fixessemver/minor— backwards-compatible new functionalitysemver/major— incompatible API changes
Backport target labels — add target/{N}-x-y for each supported release branch the change should land on. Default policy:
- Bug fixes — backport to all active release lines except the oldest
- Security fixes — backport to all active release lines including the oldest
- Features (semver/minor) and breaking changes (semver/major) — no backport labels; main-only by default
To find which release branches are active, check label colors — active target/* labels use color #ad244f, older/EOL ones use #ededed:
gh label list --repo electron/electron --search target/ --json name,color --jq '.[] | select(.color == "ad244f") | .name'
Code Style
C++: Follows Chromium style, enforced by clang-format
TypeScript/JavaScript: oxlint configuration in .oxlintrc.json
cppgc / Oilpan
- Treat prefinalizers as an absolute last resort. They add GC cost to every
collection involving the object; first redesign ownership or make teardown
release only non GC resources in the destructor. Use
CPPGC_USING_PRE_FINALIZERonly when teardown unavoidably needs to inspect another GC managed object. - Destructors must not access other GC managed objects: sweeping has no object destruction order or timing guarantee.
- Never store a bare pointer or
raw_ptrto a GC managed object in a heap field. Usecppgc::Member/WeakMemberfor on heap edges andcppgc::Persistent/WeakPersistentfor off heap edges. A bare pointer is safe only as a native stack local. - Allocate GC-managed objects only with
cppgc::MakeGarbageCollected; never usenew,delete,std::unique_ptr, orscoped_refptr. - Trace every
Member,WeakMember,v8::TracedReference, and traceable helper such asgin::WeakCellFactory; call the GC managed base class'sTrace()first. Missing an edge can cause use-after-free. - Use
v8::TracedReferencefor V8 values that participate in unified heap tracing. Usev8::Globalonly for an intentionally independent V8 root. - Keep handle construction, access, and destruction on the owning isolate's thread. Use a cross thread handle only when a cross thread reference is unavoidable.
- Use
gin_helper::SelfKeepAliveonly when native asynchronous work must keep the object alive after all JavaScript references are gone. Clear it on every terminal path. - For callbacks that may outlive or escape the object, use
WeakCellFactory. Usebase::Unretained(this)only when the callback is registered on a member owned by the object and destroying that member guarantees cancellation. GetHumanReadableName()must return stable storage (normally a string literal) and must not allocate GC objects or mutate the heap.- Annotate intentionally untraced owned members that the Blink GC plugin cannot
understand, such as Mojo remotes and receivers, with
GC_PLUGIN_IGNOREand a specific justification. - Put native observer, client, and delegate registrations, native resources,
and independent V8 roots such as pending promises on a native peer derived
from
NativePeer<Wrapper>(shell/browser/native_peer.h), not on the GC wrapper. The wrapper holds the peer withNativePeerBase::Deleter, so peer teardown never runs during sweeping; keep peer destructors inert and do all teardown inTearDownNative(), reached only throughRelease(). - Native peers reach the wrapper only through
wrapper(), which returns a stack only borrow that roots the wrapper for the rest of the frame, including across JavaScript and nested run loops. Bind it withgin::WrapPersistentonly when it must escape the frame. A non-empty borrow proves only reachability, not native liveness. Refuse new native work unlessis_active().
Linting:
npm run lint # Run all linters
npm run lint:js # Run oxlint over all JS/TS/MJS sources
npm run lint:clang-format # C++ formatting
npm run lint:api-history # Validate API history YAML blocks in docs
Key Files
| File | Purpose |
|---|---|
BUILD.gn |
Main GN build configuration |
DEPS |
Dependency versions and checkout paths |
patches/config.json |
Patch target configuration |
filenames.gni |
Source file lists by platform |
package.json |
Node.js dependencies and scripts |
Environment Variables
| Variable | Purpose |
|---|---|
GN_EXTRA_ARGS |
Additional GN arguments (useful in CI) |
ELECTRON_RUN_AS_NODE=1 |
Run Electron as Node.js |
Useful Git Commands for Chromium
# Find CL that changed a file
cd ..
git log --oneline -10 -- {file}
git blame -L {start},{end} -- {file}
# Look for Chromium CL reference in commit
git log -1 {commit_sha} # Find "Reviewed-on:" line
# Find which patch affects a file
grep -l "filename.cc" patches/chromium/*.patch
CI/CD
GitHub Actions workflows in .github/workflows/:
build.yml- Main build workflowpipeline-electron-lint.yml- Lintingpipeline-segment-electron-test.yml- Testing
Reading audit findings
These workflows upload their findings as a build artifact named audit-results.md because GitHub Actions step summaries cannot be read via the API:
.github/workflows/audit-branch-ci.yml- Table of release-branch CI runs that errored.github/workflows/archaeologist-dig.yml- Theelectron.d.tsdiff report ("Changes Detected" patch, or a no-changes note)
The artifact is uploaded unarchived (archive: false), so it downloads as raw markdown — no unzipping needed. Agents should list the run's artifacts (GET /repos/electron/electron/actions/runs/{run_id}/artifacts), find the one named audit-results.md, and fetch its archive_download_url to read the findings directly.
Common Issues
Patch conflict during sync:
- Use
e sync --3for 3-way merge - Check if file was renamed/moved upstream
- Verify patch is still needed
Build error in patched file:
- Find the patch:
grep -l "filename" patches/chromium/*.patch - Match existing patch style (#if 0 guards, BUILDFLAG conditionals, etc.)
Remote build issues:
- Try
e build --no-remoteto build locally - Check reclient/siso configuration in your build config