chore: Migrate @packages/electron to TS (#32417)

* migrates electron pkg to typescript

* build multi platform binaries on this branch

* Update packages/electron/BUILD.md

* Update packages/electron/BUILD.md

* Update packages/electron/BUILD.md

* update docs

* lint

* Update .circleci/workflows.yml

* Update .circleci/workflows.yml

* fix inverted fuse logic

* convert buffer to Uint8Array before hashing

* use pipeline to simplify

* fix ide error display of disabled rule

* rm redundant md

* fix error handling / exit code

* update docs for cli params

* fix async try/catch

* re-apply obfuscated requires ...

* improve readability, correct debug output regarding access vs stat

* flip fuses the right way again

* move back to some fs-extra, clean up dist a little better

* correct normalization for paths

* icons path

* exit(1) on electron signal>=1

* Update packages/electron/lib/electron.ts

Co-authored-by: Bill Glesias <bglesias@gmail.com>

---------

Co-authored-by: Jennifer Shehane <jennifer@cypress.io>
Co-authored-by: Bill Glesias <bglesias@gmail.com>
This commit is contained in:
Cacie Prins
2025-09-08 10:15:25 -04:00
committed by GitHub
parent 4671317eb1
commit 9ebdddad9e
19 changed files with 762 additions and 504 deletions

View File

@@ -49,7 +49,7 @@ macWorkflowFilters: &darwin-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'chore/refactor_cli_to_ts', << pipeline.git.branch >> ]
- equal: [ 'chore/migrate-electron-lib-ts', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -60,7 +60,7 @@ linuxArm64WorkflowFilters: &linux-arm64-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'chore/refactor_cli_to_ts', << pipeline.git.branch >> ]
- equal: [ 'chore/migrate-electron-lib-ts', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>
@@ -83,7 +83,7 @@ windowsWorkflowFilters: &windows-workflow-filters
- equal: [ develop, << pipeline.git.branch >> ]
# use the following branch as well to ensure that v8 snapshot cache updates are fully tested
- equal: [ 'update-v8-snapshot-cache-on-develop', << pipeline.git.branch >> ]
- equal: [ 'chore/refactor_cli_to_ts', << pipeline.git.branch >> ]
- equal: [ 'chore/migrate-electron-lib-ts', << pipeline.git.branch >> ]
- matches:
pattern: /^release\/\d+\.\d+\.\d+$/
value: << pipeline.git.branch >>

View File

@@ -1,5 +0,0 @@
**/dist
**/*.d.ts
**/package-lock.json
**/tsconfig.json
**/cypress/fixtures

View File

@@ -1,25 +1,138 @@
# @packages/electron
This is the lib responsible for installing + building Electron. This enables us to develop with the Electron shell that will match how the final compiled Cypress binary looks 1:1.
This package is responsible for installing, building, and managing the Electron binary that powers Cypress. It enables development with an Electron shell that matches the final compiled Cypress binary 1:1 by using symlinks during development.
It does this by using symlinks while in development.
## Build System
The package uses TypeScript to compile to CJS. ESM builds are not run by default, but can be enabled or tested with `build:esm`.
- **CommonJS**: Primary build used by the binary script and other packages
- **ES Modules**: Alternative build for modern Node.js applications
- **Output**: Compiled JavaScript in `dist/cjs/`, and a binary in `dist/Cypress`.
## Building
```bash
# Build both CommonJS and ES Module versions
yarn workspace @packages/electron build
# Build only CommonJS version
yarn workspace @packages/electron build:cjs
# Build only ES Module version
yarn workspace @packages/electron build:esm
# Clean build artifacts
yarn workspace @packages/electron clean-deps
```
**Note**: The build process compiles TypeScript source to JavaScript. The `--install` command packages the actual Electron binary for your OS-specific platform.
## Usage
### Command Line Interface
The package provides a binary script `cypress-electron` with several commands:
```bash
# Install/build Electron binary for current platform
./bin/cypress-electron --install
# Show help and usage information
./bin/cypress-electron --help
# Launch an Electron app (development mode)
./bin/cypress-electron /path/to/your/app
# Launch with debugging enabled
./bin/cypress-electron /path/to/your/app --inspect-brk
```
These commands are parsed out from argv in the `cli()` function defined in `./lib/electron.ts`
### Public Interface
```typescript
/**
* Checks if Electron binary exists and is up-to-date, installs if needed
*/
function installIfNeeded(): Promise<void>
/**
* Forces installation of Electron binary with optional arguments
*/
function install(...args: any[]): Promise<void>
/**
* Launches an Electron app with the specified path and arguments
* @param appPath - Path to the application to launch
* @param argv - Command line arguments to pass to the app
* @param callback - Optional callback when the app exits
* @returns Promise that resolves to the spawned Electron process
*/
function open(
appPath: string,
argv: string[],
callback?: (code: number) => void
): Promise<ChildProcess>
/**
* Returns the Electron version being used
* @returns String version (e.g., "36.4.0")
*/
function getElectronVersion(): string
/**
* Returns the Node.js version bundled with Electron
* @returns Promise that resolves to Node.js version string
*/
function getElectronNodeVersion(): Promise<string>
/**
* Returns the icons package for platform-specific icon paths
* @returns Icons package object
*/
function icons(): any
/**
* CLI entry point for command-line operations
* @param argv - Command line arguments array
*/
function cli(argv: string[]): void
```
Note: this just installs Electron binary for your OS specific platform
## Testing
```bash
# Run unit tests
yarn workspace @packages/electron test
# Run tests with debugger
yarn workspace @packages/electron test-debug
# Run tests in watch mode
yarn workspace @packages/electron test-watch
```
## Package Structure
```
packages/electron/
├── bin/ # Binary scripts
│ └── cypress-electron # Main CLI script
├── lib/ # TypeScript source
│ ├── electron.ts # Main entry point and CLI logic
│ ├── install.ts # Installation and packaging logic
│ ├── paths.ts # Platform-specific path resolution
│ └── print-node-version.ts
├── dist/ # Compiled output
│ ├── cjs/ # CommonJS build
│ ├── esm/ # ES Module build
│ └── Cypress/ # Electron app binary (created by --install)
├── app/ # App template for packaging
└── test/ # Test files
```
## Upgrading Electron
The version of `electron` that is bundled with Cypress should be kept as up-to-date as possible with the [stable Electron releases](https://www.electronjs.org/releases/stable). Many users expect the bundled Chromium and Node.js to be relatively recent. Also, historically, it has been extremely difficult to upgrade over multiple major versions of Electron at once, because of all the breaking changes in Electron and Node.js that impact Cypress.
@@ -73,6 +186,51 @@ Upgrading `electron` involves more than just bumping this package's `package.jso
- [ ] If needed, update the **[V8 Snapshot Cache](https://github.com/cypress-io/cypress/actions/workflows/update_v8_snapshot_cache.yml)** by running the GitHub workflow. Make sure to use the branch that contains the electron updates to populate the `'workflow from'` and `'branch to update'` arguments. Select `'Generate from scratch'` and `'commit directly to branch'`. This will usually take 6-8 hours to complete and is best to not be actively developing on the branch when this workflow runs.
## Development
### Local Development
1. **Build the package**: `yarn build:cjs` (or `yarn build` for both formats)
2. **Test the binary**: `./bin/cypress-electron --install`
3. **Run tests**: `yarn test`
### Debugging
Enable debug logging by setting the `DEBUG` environment variable:
```bash
DEBUG=cypress:electron* ./bin/cypress-electron --install
DEBUG=cypress:electron:install* ./bin/cypress-electron --install
```
### Common Issues
#### Build Errors
- **TypeScript compilation errors**: Check that all dependencies are installed and TypeScript config is correct
- **Missing dependencies**: Ensure `@electron/packager` and other dev dependencies are available
#### Runtime Errors
- **Path resolution issues**: Verify that the compiled output structure matches the expected paths
- **Binary not found**: Run `./bin/cypress-electron --install` to create the Electron binary
- **Permission errors**: On Linux, ensure proper permissions for the binary directory
#### Platform-Specific Issues
- **macOS**: ARM64 vs x64 architecture detection may need updates
- **Linux**: Sandbox issues when running as root (automatically handled)
- **Windows**: Junction vs directory symlink handling (automatically handled)
## Contributing
When contributing to this package:
1. **Follow the existing patterns** for error handling and logging
2. **Test on multiple platforms** if making platform-specific changes
3. **Update tests** for any new functionality
4. **Rebuild after changes** using `yarn build:cjs`
5. **Test the binary** with `./bin/cypress-electron --install`
For more detailed information about the build system, see [BUILD.md](./BUILD.md).
### Common Upgrade Issues

View File

@@ -1,3 +1,3 @@
#!/usr/bin/env node
require('../').cli(process.argv.slice(2))
require('../dist/src/index.js').cli(process.argv.slice(2))

View File

@@ -0,0 +1,22 @@
import { baseConfig, cliOverrides } from '@packages/eslint-config'
export default [
...baseConfig,
...cliOverrides,
{
ignores: [
'**/dist',
'**/*.d.ts',
'**/package-lock.json',
'**/tsconfig.json',
'**/cypress/fixtures',
],
},
{
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
},
},
]

View File

@@ -1 +0,0 @@
module.exports = require('./lib/electron')

View File

@@ -1,194 +0,0 @@
const cp = require('child_process')
const os = require('os')
const path = require('path')
const debugElectron = require('debug')('cypress:electron')
const Promise = require('bluebird')
const minimist = require('minimist')
const inspector = require('inspector')
const execa = require('execa')
const paths = require('./paths')
const install = require('./install')
let fs = require('fs-extra')
const debugStderr = require('debug')('cypress:internal-stderr')
fs = Promise.promisifyAll(fs)
const { filter, DEBUG_PREFIX } = require('@packages/stderr-filtering')
/**
* If running as root on Linux, no-sandbox must be passed or Chrome will not start
*/
const isSandboxNeeded = () => {
// eslint-disable-next-line no-restricted-properties
return (os.platform() === 'linux') && (process.geteuid() === 0)
}
module.exports = {
installIfNeeded () {
return install.check()
},
install (...args) {
debugElectron('installing %o', { args })
return install.package.apply(install, args)
},
getElectronVersion () {
return install.getElectronVersion()
},
/**
* Returns the Node version bundled inside Electron.
*/
getElectronNodeVersion () {
debugElectron('getting Electron Node version')
const args = []
if (isSandboxNeeded()) {
args.push('--no-sandbox')
}
// runs locally installed "electron" bin alias
const localScript = path.join(__dirname, 'print-node-version.js')
debugElectron('local script that prints Node version %s', localScript)
args.push(localScript)
const options = {
preferLocal: true, // finds the "node_modules/.bin/electron"
timeout: 10000, // prevents hanging Electron if there is an error for some reason
}
debugElectron('Running Electron with %o %o', args, options)
return execa('electron', args, options)
.then((result) => result.stdout)
},
icons () {
return install.icons()
},
cli (argv = []) {
const opts = minimist(argv)
debugElectron('cli options %j', opts)
const pathToApp = argv[0]
if (opts.install) {
return this.installIfNeeded()
}
if (pathToApp) {
return this.open(pathToApp, argv)
}
throw new Error('No path to your app was provided.')
},
open (appPath, argv, cb) {
debugElectron('opening %s', appPath)
appPath = path.resolve(appPath)
const dest = paths.getPathToResources('app')
debugElectron('appPath %s', appPath)
debugElectron('dest path %s', dest)
// make sure this path exists!
return fs.accessAsync(appPath)
.then(() => {
debugElectron('appPath exists %s', appPath)
// clear out the existing symlink
return fs.removeAsync(dest)
}).then(() => {
const symlinkType = paths.getSymlinkType()
debugElectron('making symlink from %s to %s of type %s', appPath, dest, symlinkType)
return fs.ensureSymlinkAsync(appPath, dest, symlinkType)
}).then(() => {
const execPath = paths.getPathToExec()
if (isSandboxNeeded()) {
argv.unshift('--no-sandbox')
}
// we have an active debugger session
if (inspector.url()) {
const dp = process.debugPort + 1
const inspectFlag = process.execArgv.some((f) => f === '--inspect' || f.startsWith('--inspect=')) ? '--inspect' : '--inspect-brk'
argv.unshift(`${inspectFlag}=${dp}`)
} else {
const opts = minimist(argv)
if (opts.inspectBrk) {
if (process.env.CYPRESS_DOCKER_DEV_INSPECT_OVERRIDE) {
argv.unshift(`--inspect-brk=${process.env.CYPRESS_DOCKER_DEV_INSPECT_OVERRIDE}`)
} else {
argv.unshift('--inspect-brk=5566')
}
}
}
debugElectron('spawning %s with args', execPath, argv)
if (debugElectron.enabled) {
// enable the internal chromium logger
argv.push('--enable-logging')
}
const spawned = cp.spawn(execPath, argv, { stdio: 'pipe' })
.on('error', (err) => {
// If electron is throwing an error event, we need to ensure it's
// printed to console.
// eslint-disable-next-line no-console
console.error(err)
return process.exit(1)
})
.on('close', (code, signal) => {
debugElectron('electron closing %o', { code, signal })
if (signal) {
debugElectron('electron exited with a signal, forcing code = 1 %o', { signal })
code = 1
}
if (cb) {
debugElectron('calling callback with code', code)
return cb(code)
}
debugElectron('process.exit with code', code)
return process.exit(code)
})
if ([1, '1'].includes(process.env.ELECTRON_ENABLE_LOGGING)) {
spawned.stderr.pipe(process.stderr)
} else {
spawned.stderr.pipe(filter(process.stderr, debugStderr, DEBUG_PREFIX))
}
spawned.stdout.pipe(process.stdout)
process.stdin.pipe(spawned.stdin)
return spawned
}).catch((err) => {
// eslint-disable-next-line no-console
console.debug(err.stack)
return process.exit(1)
})
},
}

View File

@@ -0,0 +1,197 @@
/* eslint-disable no-console */
/*
* ^- disabled because even though the eslint config for this pkg disables
* 'no-console', certain IDEs will still show errors.
*/
import cp from 'child_process'
import os from 'os'
import path from 'path'
import Debug from 'debug'
import minimist from 'minimist'
import inspector from 'inspector'
import execa from 'execa'
import * as paths from './paths'
import * as _install from './install'
import { ensureSymlink, access, remove } from 'fs-extra'
import { filter, DEBUG_PREFIX } from '@packages/stderr-filtering'
const debugElectron = Debug('cypress:electron:electron')
const debugStderr = Debug('cypress:internal-stderr')
/**
* If running as root on Linux, no-sandbox must be passed or Chrome will not start
*/
const isSandboxNeeded = () => {
// eslint-disable-next-line no-restricted-properties
return (os.platform() === 'linux') && (process.geteuid?.() === 0)
}
export function installIfNeeded () {
return _install.check()
}
export function install (...args: Parameters<typeof _install.packageAndExit>) {
debugElectron('installing %o', { args })
return _install.packageAndExit(...args)
}
export function getElectronVersion () {
return _install.getElectronVersion()
}
/**
* Returns the Node version bundled inside Electron.
*/
export function getElectronNodeVersion () {
debugElectron('getting Electron Node version')
const args = []
if (isSandboxNeeded()) {
args.push('--no-sandbox')
}
// runs locally installed "electron" bin alias
const localScript = path.join(__dirname, 'print-node-version.js')
debugElectron('local script that prints Node version %s', localScript)
args.push(localScript)
const options = {
preferLocal: true, // finds the "node_modules/.bin/electron"
timeout: 10000, // prevents hanging Electron if there is an error for some reason
}
debugElectron('Running Electron with %o %o', args, options)
return execa('electron', args, options)
.then((result) => result.stdout)
}
export function icons () {
return _install.icons()
}
export function cli (argv = []) {
const opts = minimist(argv)
debugElectron('cli options %j', opts)
if (opts.install) {
return installIfNeeded()
}
if (opts.help || opts.h) {
console.log(`
Usage: cypress-electron [options] [app-path]
Options:
--install Install/build the Electron binary
--help, -h Show this help message
Examples:
cypress-electron --install
cypress-electron /path/to/your/app
`)
return
}
const pathToApp = argv[0]
if (pathToApp) {
return open(pathToApp, argv)
}
throw new Error('No path to your app was provided.')
}
export async function open (appPath: string, argv: string[]) {
debugElectron('opening %s', appPath)
appPath = path.resolve(appPath)
const dest = paths.getPathToResources('app')
debugElectron('appPath %s', appPath)
debugElectron('dest path %s', dest)
try {
await access(appPath)
debugElectron('appPath is accessible %s', appPath)
await remove(dest)
const symlinkType = paths.getSymlinkType()
debugElectron('making symlink from %s to %s of type %s', appPath, dest, symlinkType)
await ensureSymlink(appPath, dest, symlinkType)
const execPath = paths.getPathToExec()
if (isSandboxNeeded()) {
argv.unshift('--no-sandbox')
}
// we have an active debugger session
if (inspector.url()) {
const dp = process.debugPort + 1
const inspectFlag = process.execArgv.some((f) => f === '--inspect' || f.startsWith('--inspect=')) ? '--inspect' : '--inspect-brk'
argv.unshift(`${inspectFlag}=${dp}`)
} else {
const opts = minimist(argv)
if (opts.inspectBrk) {
if (process.env.CYPRESS_DOCKER_DEV_INSPECT_OVERRIDE) {
argv.unshift(`--inspect-brk=${process.env.CYPRESS_DOCKER_DEV_INSPECT_OVERRIDE}`)
} else {
argv.unshift('--inspect-brk=5566')
}
}
}
debugElectron('spawning %s with args', execPath, argv)
if (debugElectron.enabled) {
argv.push('--enable-logging')
}
const spawned = cp.spawn(execPath, argv, { stdio: 'pipe' })
spawned.on('error', (err) => {
console.error(err)
return process.exit(1)
})
spawned.on('close', (code, signal) => {
debugElectron('electron closing %o', { code, signal })
if (signal) {
debugElectron('electron exited with a signal, forcing code = 1 %o', { signal })
code = 1
}
process.exit(code)
})
if ([1, '1'].includes(process.env.ELECTRON_ENABLE_LOGGING ?? '')) {
spawned.stderr.pipe(process.stderr)
} else {
spawned.stderr.pipe(filter(process.stderr, debugStderr, DEBUG_PREFIX))
}
spawned.stdout.pipe(process.stdout)
process.stdin.pipe(spawned.stdin)
return spawned
} catch (err) {
console.debug((err as Error).stack)
process.exit(1)
}
}

View File

@@ -1,233 +0,0 @@
/* eslint-disable no-console */
const _ = require('lodash')
const os = require('os')
const path = require('path')
const systeminformation = require('systeminformation')
const execa = require('execa')
const paths = require('./paths')
const debug = require('debug')('cypress:electron')
const fs = require('fs-extra')
const crypto = require('crypto')
const { flipFuses, FuseVersion, FuseV1Options } = require('@electron/fuses')
const pkg = require('@packages/root')
let electronVersion
// ensure we have an electronVersion set in package.json
if (!(electronVersion = pkg.devDependencies.electron)) {
throw new Error('Missing \'electron\' devDependency in root package.json')
}
module.exports = {
getElectronVersion () {
return electronVersion
},
// returns icons package so that the caller code can find
// paths to the icons without hard-coding them
icons () {
return require('@packages/icons')
},
checkCurrentVersion (pathToVersion) {
// read in the version file
return fs.readFile(pathToVersion, 'utf8')
.then((str) => {
const version = str.replace('v', '')
// and if it doesn't match the electron version
// throw an error
if (version !== electronVersion) {
throw new Error(`Currently installed version: '${version}' does not match electronVersion: '${electronVersion}`)
}
})
},
getFileHash (filePath) {
return fs.readFile(filePath).then((buf) => {
const hashSum = crypto.createHash('sha1')
hashSum.update(buf)
const hash = hashSum.digest('hex')
return hash
})
},
checkIconVersion () {
// TODO: this seems wrong, it's hard coding the check only for OSX and not windows or linux (!?)
const mainIconsPath = this.icons().getPathToIcon('cypress.icns')
const cachedIconsPath = path.join(__dirname, '../dist/Cypress/Cypress.app/Contents/Resources/electron.icns')
return Promise.all([this.getFileHash(mainIconsPath), this.getFileHash(cachedIconsPath)])
.then(([mainHash, cachedHash]) => {
if (mainHash !== cachedHash) {
throw new Error('Icon mismatch')
}
})
},
checkExecExistence (pathToExec) {
return fs.stat(pathToExec)
},
async checkBinaryArchCpuArch (pathToExec, platform, arch) {
if (platform === 'darwin' && arch === 'x64') {
return Promise.all([
// get the current arch of the binary
execa('lipo', ['-archs', pathToExec])
.then(({ stdout }) => {
return stdout
}),
// get the real arch of the system
this.getRealArch(platform, arch),
])
.then(([binaryArch, cpuArch]) => {
debug('archs detected %o', { binaryArch, cpuArch })
if (binaryArch !== cpuArch) {
throw new Error(`built binary arch: '${binaryArch}' does not match system CPU arch: '${cpuArch}', binary needs rebuilding`)
}
})
}
},
move (src, dest) {
// src is ./tmp/Cypress-darwin-x64
// dest is ./dist/Cypress
return fs.move(src, dest, { overwrite: true })
.then(() => {
// remove the tmp folder now
return fs.remove(path.dirname(src))
})
},
removeEmptyApp () {
// nuke the temporary blank /app
return fs.remove(paths.getPathToResources('app'))
},
packageAndExit () {
return this.package()
.then(() => {
return this.removeEmptyApp()
}).then(() => {
return process.exit()
})
},
async getRealArch (platform, arch) {
if (platform === 'darwin' && arch === 'x64') {
// see this comment for explanation of x64 -> arm64 translation
// https://github.com/cypress-io/cypress/pull/25014/files#diff-85c4db7620ed2731baf5669a9c9993e61e620693a008199ca7c584e621b6a1fdR11
return systeminformation.cpu()
.then(({ manufacturer }) => {
// if the cpu is apple then return arm64 as the arch
return manufacturer === 'Apple' ? 'arm64' : arch
})
}
return arch
},
package (options = {}) {
/**
* NOTE: electron-packager as of v16.0.0 does not play well with
* our mksnapshot. Requiring the package in this way, dynamically, will
* make it undiscoverable by mksnapshot, which is OK since electron-packager
* is a build dependency.
* Converted to use @electron/packager for >= v18.x.x.
* This is the renamed electron-packager.
*/
const e = 'electron'
const p = 'packager'
const pkgr = require(`@${e}/${p}`)
const icons = require('@packages/icons')
const iconPath = icons.getPathToIcon('cypress')
debug('package icon', iconPath)
const platform = os.platform()
const arch = os.arch()
return this.getRealArch(platform, arch)
.then((arch) => {
_.defaults(options, {
dist: paths.getPathToDist(),
dir: 'app',
out: 'tmp',
name: 'Cypress',
platform,
arch,
asar: false,
prune: true,
overwrite: true,
electronVersion,
icon: iconPath,
})
debug('packager options %j', options)
return pkgr(options)
})
.then((appPaths) => {
return appPaths[0]
})
// Promise.resolve("tmp\\Cypress-win32-x64")
.then((appPath) => {
const { dist } = options
// and now move the tmp into dist
debug('moving created file %o', { from: appPath, to: dist })
return this.move(appPath, dist)
})
.then(() => {
return !['1', 'true'].includes(process.env.DISABLE_SNAPSHOT_REQUIRE) ? flipFuses(
paths.getPathToExec(),
{
version: FuseVersion.V1,
resetAdHocDarwinSignature: platform === 'darwin' && arch === 'arm64',
[FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: true,
},
) : Promise.resolve()
}).catch((err) => {
console.log(err.stack)
return process.exit(1)
})
},
ensure () {
const arch = os.arch()
const platform = os.platform()
const pathToExec = paths.getPathToExec()
const pathToVersion = paths.getPathToVersion()
return Promise.all([
// check the version of electron and re-build if updated
this.checkCurrentVersion(pathToVersion),
// check if the dist folder exist and re-build if not
this.checkExecExistence(pathToExec),
// Compare the icon in dist with the one in the icons
// package. If different, force the re-build.
this.checkIconVersion(),
])
.then(() => {
// check that the arch of the built binary matches our CPU
return this.checkBinaryArchCpuArch(pathToExec, platform, arch)
})
// if all is good, then return without packaging a new electron app
},
check () {
return this.ensure()
.catch((err) => {
this.packageAndExit()
})
},
}

View File

@@ -0,0 +1,239 @@
import os from 'os'
import path from 'path'
import systeminformation from 'systeminformation'
import execa from 'execa'
import {
getPathToDist,
getPathToExec,
getPathToVersion,
getPathToResources,
} from './paths'
import Debug from 'debug'
import fs from 'fs/promises'
import { createReadStream } from 'fs'
import { pipeline } from 'stream/promises'
import crypto from 'crypto'
import { flipFuses, FuseVersion, FuseV1Options } from '@electron/fuses'
import { move, remove } from 'fs-extra'
// @ts-ignore
import pkg from '@packages/root'
const debug = Debug('cypress:electron:install')
let electronVersion: string | undefined
// ensure we have an electronVersion set in package.json
if (!(electronVersion = pkg.devDependencies.electron)) {
throw new Error(`Missing 'electron' devDependency in root package.json`)
}
export function getElectronVersion () {
return electronVersion
}
// returns icons package so that the caller code can find
// paths to the icons without hard-coding them
export const icons = () => {
return require('@packages/icons')
}
export function checkCurrentVersion (pathToVersion: string) {
// read in the version file
return fs.readFile(pathToVersion, 'utf8').then((str) => {
const version = str.replace('v', '')
// and if it doesn't match the electron version
// throw an error
if (version !== electronVersion) {
throw new Error(
`Currently installed version: '${version}' does not match electronVersion: '${electronVersion}`,
)
}
})
}
export async function getFileHash (filePath: string): Promise<string> {
const hash = crypto.createHash('sha1')
const stream = createReadStream(filePath)
await pipeline(stream, hash)
return hash.digest('hex')
}
export async function checkIconVersion () {
// TODO: this seems wrong, it's hard coding the check only for OSX and not windows or linux (!?)
const mainIconsPath = icons().getPathToIcon('cypress.icns')
const cachedIconsPath = path.join(
__dirname,
'../Cypress/Cypress.app/Contents/Resources/electron.icns',
)
const [mainHash, cachedHash] = await Promise.all(
[mainIconsPath, cachedIconsPath].map(getFileHash),
)
if (mainHash !== cachedHash) {
throw new Error('Icon mismatch')
}
}
export function checkExecExistence (pathToExec: string) {
return fs.stat(pathToExec)
}
export async function checkBinaryArchCpuArch (
pathToExec: string,
platform: string,
arch: string,
) {
if (platform === 'darwin' && arch === 'x64') {
return Promise.all([
// get the current arch of the binary
execa('lipo', ['-archs', pathToExec]).then(({ stdout }) => {
return stdout
}),
// get the real arch of the system
getRealArch(platform, arch),
]).then(([binaryArch, cpuArch]) => {
debug('archs detected %o', { binaryArch, cpuArch })
if (binaryArch !== cpuArch) {
throw new Error(
`built binary arch: '${binaryArch}' does not match system CPU arch: '${cpuArch}', binary needs rebuilding`,
)
}
})
}
}
export async function packageAndExit () {
await pkgElectronApp()
await remove(getPathToResources('app'))
process.exit()
}
export async function getRealArch (platform: string, arch: string) {
if (platform === 'darwin' && arch === 'x64') {
// see this comment for explanation of x64 -> arm64 translation
// https://github.com/cypress-io/cypress/pull/25014/files#diff-85c4db7620ed2731baf5669a9c9993e61e620693a008199ca7c584e621b6a1fdR11
return systeminformation.cpu().then(({ manufacturer }) => {
// if the cpu is apple then return arm64 as the arch
return manufacturer === 'Apple' ? 'arm64' : arch
})
}
return arch
}
interface PkgElectronAppOptions {
dist: string
dir: string
out: string
name: string
platform: string
arch: string
asar: boolean
prune: boolean
overwrite: boolean
electronVersion: string
icon: string
}
export async function pkgElectronApp (
options: Partial<PkgElectronAppOptions> = {},
) {
/**
* NOTE: electron-packager as of v16.0.0 does not play well with
* our mksnapshot. Requiring the package in this way, dynamically, will
* make it undiscoverable by mksnapshot, which is OK since electron-packager
* is a build dependency.
* Converted to use @electron/packager for >= v18.x.x.
* This is the renamed electron-packager.
*
* TODO: split this into two libs; one being the build tool, and the other as
* the runtime lib for opening Electron. This will allow us to import these
* as normal.
*/
const e = 'electron'
const p = 'packager'
const pkgr = require(`@${e}/${p}`)
const icons = require('@packages/icons')
const iconPath = icons.getPathToIcon('cypress')
debug('package icon', iconPath)
const platform = os.platform()
const arch = os.arch()
const resolvedOptions: PkgElectronAppOptions = {
dist: getPathToDist(),
dir: 'app',
out: 'tmp',
name: 'Cypress',
platform,
arch: await getRealArch(platform, arch),
asar: false,
prune: true,
overwrite: true,
electronVersion: electronVersion ?? '',
icon: iconPath,
...options,
}
debug('packager options %j', resolvedOptions)
const [appPath] = await pkgr(resolvedOptions)
if (appPath && resolvedOptions.dist && (await fs.stat(appPath))) {
debug('moving app to dist', appPath, resolvedOptions.dist)
await move(appPath, resolvedOptions.dist)
debug('removed app', path.dirname(appPath))
await remove(path.dirname(appPath))
}
try {
if (!['1', 'true'].includes(process.env.DISABLE_SNAPSHOT_REQUIRE ?? '')) {
await flipFuses(getPathToExec(), {
version: FuseVersion.V1,
resetAdHocDarwinSignature: platform === 'darwin' && arch === 'arm64',
[FuseV1Options.LoadBrowserProcessSpecificV8Snapshot]: true,
})
}
} catch (err) {
// eslint-disable-next-line no-console
console.log((err as Error).stack)
return process.exit(1)
}
}
export function ensure () {
const arch = os.arch()
const platform = os.platform()
const pathToExec = getPathToExec()
const pathToVersion = getPathToVersion()
return Promise.all([
// check the version of electron and re-build if updated
checkCurrentVersion(pathToVersion),
// check if the dist folder exist and re-build if not
fs.stat(pathToExec),
// Compare the icon in dist with the one in the icons
// package. If different, force the re-build.
checkIconVersion(),
]).then(() => {
// check that the arch of the built binary matches our CPU
return checkBinaryArchCpuArch(pathToExec, platform, arch)
})
// if all is good, then return without packaging a new electron app
}
export function check () {
return ensure().catch((err) => {
packageAndExit()
})
}

View File

@@ -1,60 +0,0 @@
const os = require('os')
const path = require('path')
const distPath = 'dist/Cypress'
const execPath = {
darwin: 'Cypress.app/Contents/MacOS/Cypress',
freebsd: 'Cypress',
linux: 'Cypress',
win32: 'Cypress.exe',
}
const resourcesPath = {
darwin: 'Cypress.app/Contents/Resources',
freebsd: 'resources',
linux: 'resources',
win32: 'resources',
}
const unknownPlatformErr = function () {
throw new Error(`Unknown platform: '${os.platform()}'`)
}
const normalize = (...paths) => {
return path.join(__dirname, '..', ...paths)
}
module.exports = {
getPathToDist (...paths) {
paths = [distPath].concat(paths)
return normalize(...paths)
},
getPathToExec () {
const p = execPath[os.platform()] || unknownPlatformErr()
return this.getPathToDist(p)
},
getPathToResources (...paths) {
let p = resourcesPath[os.platform()] || unknownPlatformErr()
p = [].concat(p, paths)
return this.getPathToDist(...p)
},
getPathToVersion () {
return this.getPathToDist('version')
},
getSymlinkType () {
if (os.platform() === 'win32') {
return 'junction'
}
return 'dir'
},
}

View File

@@ -0,0 +1,58 @@
import os from 'os'
import path from 'path'
const distPath = 'dist/Cypress'
type OSLookup = Record<string, string>
const execPath: OSLookup = {
darwin: 'Cypress.app/Contents/MacOS/Cypress',
freebsd: 'Cypress',
linux: 'Cypress',
win32: 'Cypress.exe',
}
const resourcesPath: OSLookup = {
darwin: 'Cypress.app/Contents/Resources',
freebsd: 'resources',
linux: 'resources',
win32: 'resources',
}
const unknownPlatformErr = function () {
throw new Error(`Unknown platform: '${os.platform()}'`)
}
const normalize = (...paths: string[]) => {
return path.join(__dirname, '..', '..', ...paths)
}
export const getPathToDist = (...paths: string[]) => {
paths = [distPath].concat(paths)
return normalize(...paths)
}
export const getPathToExec = () => {
const p = execPath[os.platform()] || unknownPlatformErr()
return getPathToDist(p)
}
export const getPathToResources = (...paths: string[]) => {
const p = resourcesPath[os.platform()] || unknownPlatformErr()
return getPathToDist(...[p, ...paths])
}
export const getPathToVersion = () => {
return getPathToDist('version')
}
export const getSymlinkType = () => {
if (os.platform() === 'win32') {
return 'junction'
}
return 'dir'
}

View File

@@ -1,3 +1,2 @@
/* eslint-disable-next-line no-console */
console.log(process.version.replace('v', ''))
process.exit(0)

View File

@@ -2,13 +2,15 @@
"name": "@packages/electron",
"version": "0.0.0-development",
"private": true,
"main": "index.js",
"main": "dist/src/index.js",
"scripts": {
"build": "echo 'electron package build: no build necessary'",
"build": "rimraf dist && yarn build:esm && yarn build:cjs",
"build-binary": "node ./bin/cypress-electron --install",
"build:cjs": "tsc -p tsconfig.cjs.json",
"build:esm": "tsc -p tsconfig.esm.json",
"clean-deps": "rimraf node_modules",
"postinstall": "echo '@packages/electron needs: yarn build'",
"lint": "eslint --ext .js,.jsx,.ts,.tsx,.json, .",
"lint": "eslint",
"start": "./bin/cypress-electron",
"test": "yarn test-unit",
"test-debug": "yarn test-unit --inspect-brk=5566",
@@ -38,5 +40,9 @@
"bin": {
"cypress-electron": "./bin/cypress-electron"
},
"types": "dist/cjs/src/index.d.ts",
"lint-staged": {
"**/*.{js,jsx,ts,tsx}": "eslint --fix"
},
"nx": {}
}

View File

@@ -0,0 +1,7 @@
// Re-export the main electron functionality
export * from '../lib/electron'
// Default export for CommonJS compatibility
import * as electron from '../lib/electron'
export default electron

View File

@@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"removeComments": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
},
"include": [
"src/**/*",
"lib/**/*",
"bin/**/*"
],
"exclude": [
"node_modules",
"dist",
"test",
"**/*.test.js",
"**/*.spec.js"
]
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist/esm",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noEmit": true
}
}

View File

@@ -0,0 +1,6 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"noEmit": true
}
}