Files
hermes-agent/apps/desktop/e2e/electron-binary.ts
Jack Lau 7a1fca6676 fix(desktop): resolve the e2e Electron binary per platform and layout
findElectron() probed exactly one path, and got three things wrong at
once for anyone not on a hoisted POSIX install:

* It looked only under the REPO ROOT. This is an npm workspaces repo and
  npm hoists a dependency only when nothing conflicts, so `electron`
  installing into apps/desktop/node_modules is an ordinary outcome, not a
  broken tree.
* It joined a bare `electron`. On Windows the dist file is
  `electron.exe`, so the probe could never match there.
* Its PATH fallback spawned `which`, which is not a command on Windows,
  so the fallback failed for a reason unrelated to whether electron is on
  PATH.

The three combine into a misleading error: the suite refuses to start
with 'Run "npm install" from the repo root' on a tree that has electron
installed. Reproduced on Windows 11 against this repo, where
apps/desktop/node_modules/electron/dist/electron.exe exists and the old
body throws that message; the reporter on #88036 hit the same thing on
Linux and had to hand-symlink the package before the suite would run.

Resolution now asks the installed `electron` package for its own path
first (its main export IS the absolute executable, resolved from
path.txt and honouring ELECTRON_OVERRIDE_DIST_PATH), then falls back to
explicit dist probes for each root, then to PATH with the platform's
lookup command. The error message lists what was searched.

The rules live in e2e/electron-binary.ts so they can be unit-tested
without importing the Playwright runner, with the platform passed in
rather than read from process.platform: reading it would leave every
Windows rule untested on the Linux CI runner.

Wiring: the vitest `electron` project picks up e2e/**/*.unit.test.ts and
Playwright ignores the same pattern, so helper unit tests run in exactly
one runner and the specs are untouched.

Verified: 5 unit tests pass; mutation-checked one rule at a time
(hardcoding the binary name fails 2, reversing the probe order fails 1,
hardcoding `which` fails 1). tsc -p . and tsc -p tsconfig.e2e.json
clean.

This is the environment blocker called out in #88036, not its rendering
bug, so it is deliberately a subset.

Refs #88036
2026-08-31 11:56:42 -07:00

101 lines
3.8 KiB
TypeScript

/**
* Locating the dev Electron binary for the e2e fixtures.
*
* Kept in its own module so the resolution rules can be unit-tested without
* importing the Playwright runner (fixtures.ts pulls in `_electron`, the mock
* server and the error-banner guard).
*
* Three rules the previous single-path probe got wrong:
*
* 1. The binary is not always under the REPO ROOT. This is an npm workspaces
* repo, and npm only hoists a dependency to the root when nothing conflicts
* — otherwise `electron` installs into `apps/desktop/node_modules`. Both
* layouts are normal, so both have to be searched, nearest package first.
* 2. The binary is `electron.exe` on Windows. A bare `electron` never exists
* there, so the probe could only ever miss.
* 3. `which` is not a command on Windows. The PATH fallback spawned it
* unconditionally, so on Windows the fallback failed for the wrong reason
* and the error message blamed a missing `npm install`.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import { createRequire } from 'node:module'
import * as path from 'node:path'
/** The dist file name: `electron.exe` on Windows, `electron` elsewhere. */
export function electronBinaryName(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? 'electron.exe' : 'electron'
}
/**
* Where an npm install can leave the binary, in probe order: nearest package
* first, so a workspace-local install wins over a stale hoisted one.
*/
export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] {
return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform)))
}
/** The PATH-lookup command for this platform. Windows has `where`, not `which`. */
export function pathLookupCommand(platform: NodeJS.Platform = process.platform): string {
return platform === 'win32' ? 'where' : 'which'
}
/**
* Ask the installed `electron` package where its own binary is.
*
* Its main export IS the absolute executable path, resolved from `path.txt`
* and honouring `ELECTRON_OVERRIDE_DIST_PATH`, so this covers layouts and
* overrides a hand-built path cannot know about. Returns null when the package
* is not resolvable from `from`, or when it does not hand back a path (the
* export is the Electron API object, not a path, when required from inside
* Electron itself).
*/
export function electronPackagePath(from: string): null | string {
try {
const resolved = createRequire(path.join(from, 'package.json'))('electron') as unknown
return typeof resolved === 'string' && resolved ? resolved : null
} catch {
return null
}
}
/**
* Resolve the Electron binary, or throw with the layouts that were searched.
*
* `roots` are searched in order; pass the desktop package before the repo root.
*/
export function resolveElectronBinary(roots: string[]): string {
for (const root of roots) {
const declared = electronPackagePath(root)
if (declared && fs.existsSync(declared)) {
return declared
}
}
for (const candidate of electronDistCandidates(roots)) {
if (fs.existsSync(candidate)) {
return candidate
}
}
// Nix devshells put `electron` on PATH with no node_modules copy at all.
const lookup = spawnSync(pathLookupCommand(), ['electron'], { encoding: 'utf8' })
if (lookup.status === 0 && lookup.stdout.trim()) {
// `where` reports every match, one per line; take the first.
const first = lookup.stdout.trim().split(/\r?\n/)[0].trim()
if (first) {
return first
}
}
throw new Error(
`Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` +
'Run "npm install" from the repo root to install devDependencies.',
)
}