feat: self-heal Electron installs before dev and postinstall
Adds an Electron install check that repairs incomplete or wrong-architecture binaries Runs the check during root postinstall and before electron dev startup Adds tests and docs for the new ensure:electron workflow
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
---
|
||||
mode: subagent
|
||||
mode: all
|
||||
description: Simplifies recently modified OpenChamber code for clarity and maintainability while preserving exact behavior. Use after implementation with a concrete scope or a request to simplify current worktree changes.
|
||||
permission:
|
||||
edit: allow
|
||||
@@ -16,13 +16,13 @@ permission:
|
||||
"*.env.example": allow
|
||||
bash:
|
||||
"*": ask
|
||||
"bun test*": allow
|
||||
"bun run type-check*": allow
|
||||
"bun run lint*": allow
|
||||
"bun run build*": allow
|
||||
"bun run docs:validate": allow
|
||||
"bun run dead-code": allow
|
||||
"git *": allow
|
||||
bun test*: allow
|
||||
bun run type-check*: allow
|
||||
bun run lint*: allow
|
||||
bun run build*: allow
|
||||
bun run docs:validate: allow
|
||||
bun run dead-code: allow
|
||||
git *: allow
|
||||
---
|
||||
|
||||
You are an expert code simplification specialist for OpenChamber. Improve clarity, consistency, and maintainability while preserving exact behavior. Prefer readable, explicit code over compact or clever code.
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
"lint:mobile": "bun run --cwd packages/mobile lint",
|
||||
"clean": "bun run --filter '*' clean",
|
||||
"changelog-card": "node scripts/changelog-card/generate.mjs",
|
||||
"postinstall": "node ./fix-deprecation.js && patch-package",
|
||||
"postinstall": "node ./fix-deprecation.js && patch-package && node ./packages/electron/scripts/ensure-electron.mjs --best-effort",
|
||||
"dev:web": "bun run --cwd packages/web build:watch",
|
||||
"dev:web:server": "bun run --cwd packages/web dev:server:watch",
|
||||
"dev:web:full": "node ./scripts/dev-web-full.mjs",
|
||||
|
||||
@@ -23,6 +23,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE
|
||||
| `preload.mjs` | Safe bridge from the rendered UI to Electron IPC |
|
||||
| `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers |
|
||||
| `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support |
|
||||
| `scripts/ensure-electron.mjs` | Verifies the installed Electron binary is complete and repairs it via the postinstall under Bun |
|
||||
| `scripts/build-web-assets.mjs` | Builds `packages/web` and stages UI assets into `resources/web-dist` |
|
||||
| `scripts/prepare-opencode-cli.mjs` | Downloads and stages the pinned OpenCode CLI into `resources/opencode-cli` |
|
||||
| `scripts/bundle-main.mjs` | Bundles Electron main code into `dist-bundle/main.mjs` for packaging |
|
||||
@@ -43,10 +44,18 @@ bun run electron:dev
|
||||
|
||||
The Electron workspace package trusts Electron's install script so `bun install` downloads the platform runtime in fresh checkouts and worktrees.
|
||||
|
||||
Electron's postinstall (`node install.js`) is run by `bun install` with the system Node. Under Node 24, the bundled `extract-zip@2.0.1` silently unpacks only the first entry of the Electron zip, leaving `dist/` without the binary and `path.txt` missing. To keep this from blocking desktop work:
|
||||
|
||||
- The root `postinstall` runs `ensure-electron.mjs --best-effort`, which detects an incomplete Electron install (missing binary, stale `dist/version`/`path.txt`, or a binary of the wrong architecture) and repairs it by re-running the postinstall under Bun (which extracts correctly), falling back to Node.
|
||||
- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted.
|
||||
- The check can be run on demand with `bun run --cwd packages/electron ensure:electron`; set `ELECTRON_SKIP_BINARY_DOWNLOAD=1` to skip repair (e.g. CI without a network).
|
||||
- Unit tests in `scripts/ensure-electron.test.mjs` (run via `bun run --cwd packages/electron test:architecture`) cover healthy/missing/stale installs, wrong-architecture binaries, repair fallback, and `--best-effort`.
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
bun run electron:dev:bundled
|
||||
bun run --cwd packages/electron ensure:electron
|
||||
bun run type-check:electron
|
||||
bun run lint:electron
|
||||
```
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"dev": "node ./scripts/electron-dev.mjs",
|
||||
"build:web-assets": "node ./scripts/build-web-assets.mjs",
|
||||
"build": "bun -e \"process.exit(0)\"",
|
||||
"ensure:electron": "node ./scripts/ensure-electron.mjs",
|
||||
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
|
||||
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
|
||||
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
|
||||
@@ -38,7 +39,7 @@
|
||||
"bundle:main": "bun ./scripts/bundle-main.mjs",
|
||||
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
|
||||
"test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs ./scripts/ensure-electron.test.mjs",
|
||||
"test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
|
||||
"test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs",
|
||||
"updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
|
||||
|
||||
@@ -45,6 +45,25 @@ function spawnProcess(command, args, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function ensureElectronInstalled() {
|
||||
// Electron's postinstall can silently fail to extract the binary under
|
||||
// Node 24 (see ensure-electron.mjs). Fail fast with a repair attempt
|
||||
// before wiring up the dev servers so the error is actionable.
|
||||
const result = spawnSync('node', [path.join(__dirname, 'ensure-electron.mjs')], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
'[electron:dev] electron binary is missing or incomplete and could not be repaired. ' +
|
||||
'Run `bun run --cwd packages/electron ensure:electron` (or `bun install`) with a network connection.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runProcess(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
@@ -183,6 +202,8 @@ async function main() {
|
||||
let hmrApiPort = '';
|
||||
let hmrUiPort = '';
|
||||
|
||||
ensureElectronInstalled();
|
||||
|
||||
if (useBundledUi) {
|
||||
await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ensure the installed `electron` package has its binary fully installed and
|
||||
* matches the host architecture.
|
||||
*
|
||||
* Why this exists: `bun install` runs the electron package's postinstall
|
||||
* (`node install.js`) with the system Node. Under Node 24,
|
||||
* `extract-zip@2.0.1` silently unpacks only the first entry of the electron
|
||||
* zip and then resolves without error, leaving `dist/` without the binary
|
||||
* and `path.txt` missing. Running the same postinstall with Bun extracts
|
||||
* correctly. This script detects an incomplete (or wrong-architecture)
|
||||
* install and repairs it by re-running the postinstall under Bun (falling
|
||||
* back to Node).
|
||||
*
|
||||
* Test hooks (env, never used in normal operation):
|
||||
* OPENCHAMBER_ELECTRON_PKG_DIR - resolve the electron package here.
|
||||
* OPENCHAMBER_ELECTRON_INSTALL_COMMANDS - JSON array of [bin, args] repair
|
||||
* commands, e.g.
|
||||
* `[["bun",["install.js"]],["node",["install.js"]]]`.
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 - electron is complete (or was repaired; or `--best-effort` and repair
|
||||
* was not possible but should not block the caller).
|
||||
* 1 - electron is incomplete and could not be repaired.
|
||||
*/
|
||||
import { spawnSync, execSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoElectronDir = path.resolve(__dirname, '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// cputype / e_machine / PE machine values -> Node-style architecture.
|
||||
const MACHO_CPU_TO_ARCH = {
|
||||
0x00000007: 'ia32',
|
||||
0x01000007: 'x64',
|
||||
0x0000000c: 'arm',
|
||||
0x0100000c: 'arm64',
|
||||
};
|
||||
const ELF_MACHINE_TO_ARCH = {
|
||||
3: 'ia32',
|
||||
40: 'arm',
|
||||
62: 'x64',
|
||||
183: 'arm64',
|
||||
};
|
||||
const PE_MACHINE_TO_ARCH = {
|
||||
0x014c: 'ia32',
|
||||
0x8664: 'x64',
|
||||
0xaa64: 'arm64',
|
||||
};
|
||||
|
||||
export function platformPath() {
|
||||
const platform = process.env.npm_config_platform || process.platform;
|
||||
switch (platform) {
|
||||
case 'mas':
|
||||
case 'darwin':
|
||||
return 'Electron.app/Contents/MacOS/Electron';
|
||||
case 'freebsd':
|
||||
case 'openbsd':
|
||||
case 'linux':
|
||||
return 'electron';
|
||||
case 'win32':
|
||||
return 'electron.exe';
|
||||
default:
|
||||
throw new Error(`Electron builds are not available on platform: ${platform}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Architecture that the installed Electron binary should match, mirroring the
|
||||
* logic in electron's own install.js (including the macOS Rosetta fallback).
|
||||
*/
|
||||
export function expectedArch() {
|
||||
const platform = process.env.npm_config_platform || process.platform;
|
||||
let arch = process.env.npm_config_arch || process.arch;
|
||||
if (
|
||||
platform === 'darwin' &&
|
||||
process.platform === 'darwin' &&
|
||||
arch === 'x64' &&
|
||||
process.env.npm_config_arch === undefined
|
||||
) {
|
||||
try {
|
||||
const out = execSync('sysctl -in sysctl.proc_translated', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
if (String(out).trim() === '1') {
|
||||
arch = 'arm64';
|
||||
}
|
||||
} catch {
|
||||
// Ignore failure: treat as a native x64 host.
|
||||
}
|
||||
}
|
||||
return arch;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the executable header of a Mach-O (macOS), ELF (Linux), or PE
|
||||
* (Windows) binary and return its architecture as a Node-style string
|
||||
* ('x64', 'arm64', 'ia32', 'arm') or null when it cannot be determined.
|
||||
*/
|
||||
export function detectExecutableArch(executablePath) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(executablePath, 'r');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const header = Buffer.alloc(512);
|
||||
const bytesRead = fs.readSync(fd, header, 0, header.length, 0);
|
||||
return archFromHeader(header.subarray(0, bytesRead));
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (fd !== undefined) {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function archFromHeader(buf) {
|
||||
if (buf.length < 4) return null;
|
||||
|
||||
// ELF: e_machine at offset 18.
|
||||
if (buf[0] === 0x7f && buf[1] === 0x45 && buf[2] === 0x4c && buf[3] === 0x46) {
|
||||
if (buf.length < 20) return null;
|
||||
return ELF_MACHINE_TO_ARCH[buf.readUInt16LE(18)] ?? null;
|
||||
}
|
||||
|
||||
// Thin Mach-O: magic (LE) then cputype at offset 4.
|
||||
const magicLE = buf.readUInt32LE(0);
|
||||
if (magicLE === 0xfeedface || magicLE === 0xfeedfacf) {
|
||||
if (buf.length < 8) return null;
|
||||
return MACHO_CPU_TO_ARCH[buf.readUInt32LE(4)] ?? null;
|
||||
}
|
||||
|
||||
// Fat/universal Mach-O: magic (BE) then a list of fat_arch entries.
|
||||
const magicBE = buf.readUInt32BE(0);
|
||||
if (magicBE === 0xcafebabe || magicBE === 0xbebafeca) {
|
||||
if (buf.length < 8) return null;
|
||||
const count = buf.readUInt32BE(4);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const offset = 8 + i * 20;
|
||||
if (buf.length < offset + 4) break;
|
||||
const arch = MACHO_CPU_TO_ARCH[buf.readUInt32BE(offset)];
|
||||
if (arch) return arch;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// PE: e_lfanew at offset 0x3c, machine at PE header + 4.
|
||||
if (buf[0] === 0x4d && buf[1] === 0x5a) {
|
||||
if (buf.length < 0x40) return null;
|
||||
const peOffset = buf.readUInt32LE(0x3c);
|
||||
if (buf.length < peOffset + 6) return null;
|
||||
if (buf.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') return null;
|
||||
return PE_MACHINE_TO_ARCH[buf.readUInt16LE(peOffset + 4)] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveElectronPackageDir(baseDir = repoElectronDir) {
|
||||
try {
|
||||
const pkgJson = require.resolve('electron/package.json', { paths: [baseDir] });
|
||||
return path.dirname(pkgJson);
|
||||
} catch {
|
||||
// Fall back to the standard monorepo layout (Bun and npm hoist electron
|
||||
// somewhere under <repo>/node_modules).
|
||||
const candidates = [
|
||||
path.resolve(baseDir, 'node_modules/electron'),
|
||||
path.resolve(baseDir, '../../node_modules/electron'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(path.join(candidate, 'package.json'))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isComplete(electronDir, expected = expectedArch()) {
|
||||
const pkg = readJson(path.join(electronDir, 'package.json'));
|
||||
if (!pkg || !pkg.version) return false;
|
||||
|
||||
try {
|
||||
const distVersion = fs
|
||||
.readFileSync(path.join(electronDir, 'dist', 'version'), 'utf8')
|
||||
.trim()
|
||||
.replace(/^v/, '');
|
||||
if (distVersion !== pkg.version) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
let executablePath;
|
||||
try {
|
||||
executablePath = fs.readFileSync(path.join(electronDir, 'path.txt'), 'utf8').trim();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (executablePath !== platformPath()) return false;
|
||||
|
||||
const executable = path.join(electronDir, 'dist', executablePath);
|
||||
if (!fs.existsSync(executable)) return false;
|
||||
|
||||
// A same-version binary built for another architecture satisfies all of the
|
||||
// checks above but still fails at launch, so verify the real header.
|
||||
const detected = detectExecutableArch(executable);
|
||||
if (detected === null || detected !== expected) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveInstallCommands(env) {
|
||||
if (env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS) {
|
||||
try {
|
||||
const parsed = JSON.parse(env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS);
|
||||
if (
|
||||
Array.isArray(parsed) &&
|
||||
parsed.every((command) => Array.isArray(command) && typeof command[0] === 'string')
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the default commands.
|
||||
}
|
||||
}
|
||||
return [
|
||||
['bun', ['install.js']],
|
||||
['node', ['install.js']],
|
||||
];
|
||||
}
|
||||
|
||||
export function repair(electronDir, options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const runner = options.runner ?? spawnSync;
|
||||
const commands = options.commands ?? resolveInstallCommands(env);
|
||||
|
||||
// A partial extraction can leave stale entries (and a stale path.txt) that
|
||||
// a re-run would merge with. Start clean so the repair is deterministic.
|
||||
fs.rmSync(path.join(electronDir, 'dist'), { recursive: true, force: true });
|
||||
fs.rmSync(path.join(electronDir, 'path.txt'), { force: true });
|
||||
|
||||
// Running the postinstall under Bun extracts the full zip on every Node
|
||||
// version, including Node 24 where the Node-based extract-zip is broken.
|
||||
// Fall back to `node install.js` only when Bun is unavailable (older Node
|
||||
// versions extract fine with Node).
|
||||
for (const [bin, args] of commands) {
|
||||
const label = `${bin} ${args.join(' ')}`;
|
||||
const result = runner(bin, args, {
|
||||
cwd: electronDir,
|
||||
stdio: options.stdio ?? 'inherit',
|
||||
env: { ...env, ELECTRON_SKIP_BINARY_DOWNLOAD: undefined },
|
||||
});
|
||||
if (result.error) {
|
||||
console.warn(`[electron:ensure] could not run \`${label}\`: ${result.error.message}`);
|
||||
continue;
|
||||
}
|
||||
if (result.status === 0 && isComplete(electronDir)) {
|
||||
console.log(`[electron:ensure] repaired electron install at ${electronDir}`);
|
||||
return true;
|
||||
}
|
||||
console.warn(`[electron:ensure] \`${label}\` exited with code ${result.status ?? 'null'}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
const bestEffort = argv.includes('--best-effort');
|
||||
|
||||
const overrideDir = env.OPENCHAMBER_ELECTRON_PKG_DIR;
|
||||
const electronDir = overrideDir
|
||||
? fs.existsSync(path.join(overrideDir, 'package.json'))
|
||||
? overrideDir
|
||||
: null
|
||||
: resolveElectronPackageDir();
|
||||
|
||||
if (!electronDir) {
|
||||
const message = '[electron:ensure] could not locate the installed `electron` package';
|
||||
if (bestEffort) {
|
||||
console.warn(message);
|
||||
return 0;
|
||||
}
|
||||
console.error(message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (isComplete(electronDir)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (env.ELECTRON_SKIP_BINARY_DOWNLOAD) {
|
||||
console.warn(
|
||||
'[electron:ensure] electron binary is missing but ELECTRON_SKIP_BINARY_DOWNLOAD is set; skipping repair.',
|
||||
);
|
||||
return bestEffort ? 0 : 1;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[electron:ensure] electron install at ${electronDir} is incomplete ` +
|
||||
'(missing binary, path.txt, version, or architecture mismatch); repairing…',
|
||||
);
|
||||
|
||||
if (repair(electronDir, { env })) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const message =
|
||||
'[electron:ensure] electron is still incomplete after repair; ' +
|
||||
'run `bun run --cwd packages/electron ensure:electron` with a network connection.';
|
||||
if (bestEffort) {
|
||||
console.warn(message);
|
||||
return 0;
|
||||
}
|
||||
console.error(message);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
try {
|
||||
process.exitCode = await main();
|
||||
} catch (error) {
|
||||
console.error('[electron:ensure] unexpected error:', error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
detectExecutableArch,
|
||||
expectedArch,
|
||||
isComplete,
|
||||
main,
|
||||
platformPath,
|
||||
repair,
|
||||
resolveElectronPackageDir,
|
||||
} from './ensure-electron.mjs';
|
||||
|
||||
const FAIL_SCRIPT = 'process.exit(1);\n';
|
||||
|
||||
function headerBytesForArch(arch) {
|
||||
const platform = process.platform;
|
||||
if (platform === 'darwin') {
|
||||
const cputype = { arm64: 0x0100000c, x64: 0x01000007, ia32: 0x00000007, arm: 0x0000000c }[arch];
|
||||
const buf = Buffer.alloc(8);
|
||||
buf.writeUInt32LE(0xfeedfacf, 0);
|
||||
buf.writeUInt32LE(cputype, 4);
|
||||
return buf;
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
const machine = { arm64: 183, x64: 62, ia32: 3, arm: 40 }[arch];
|
||||
const buf = Buffer.alloc(20);
|
||||
buf[0] = 0x7f;
|
||||
buf[1] = 0x45;
|
||||
buf[2] = 0x4c;
|
||||
buf[3] = 0x46;
|
||||
buf.writeUInt16LE(machine, 18);
|
||||
return buf;
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
const peOffset = 0x80;
|
||||
const machine = { arm64: 0xaa64, x64: 0x8664, ia32: 0x014c }[arch];
|
||||
const buf = Buffer.alloc(peOffset + 6);
|
||||
buf.write('MZ', 0, 'latin1');
|
||||
buf.writeUInt32LE(peOffset, 0x3c);
|
||||
buf.write('PE\0\0', peOffset, 'latin1');
|
||||
buf.writeUInt16LE(machine, peOffset + 4);
|
||||
return buf;
|
||||
}
|
||||
throw new Error(`unsupported test platform: ${platform}`);
|
||||
}
|
||||
|
||||
function installScriptContent({
|
||||
arch = expectedArch(),
|
||||
platform = platformPath(),
|
||||
version = '41.2.1',
|
||||
exitCode = 0,
|
||||
} = {}) {
|
||||
const headerHex = headerBytesForArch(arch).toString('hex');
|
||||
return [
|
||||
"const fs = require('node:fs');",
|
||||
"const path = require('node:path');",
|
||||
`const platformPath = ${JSON.stringify(platform)};`,
|
||||
`const header = Buffer.from('${headerHex}', 'hex');`,
|
||||
"const exe = path.join(__dirname, 'dist', platformPath);",
|
||||
'fs.mkdirSync(path.dirname(exe), { recursive: true });',
|
||||
`fs.writeFileSync(path.join(__dirname, 'dist', 'version'), ${JSON.stringify(version)});`,
|
||||
"fs.writeFileSync(path.join(__dirname, 'path.txt'), platformPath);",
|
||||
'fs.writeFileSync(exe, header);',
|
||||
`process.exit(${exitCode});`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function makeFixture({
|
||||
version = '41.2.1',
|
||||
complete = false,
|
||||
distVersion,
|
||||
arch,
|
||||
installScripts = {},
|
||||
} = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'electron-ensure-'));
|
||||
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'electron', version }));
|
||||
if (complete) {
|
||||
const platform = platformPath();
|
||||
fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'dist', 'version'), distVersion ?? version);
|
||||
fs.writeFileSync(path.join(dir, 'path.txt'), platform);
|
||||
fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(arch ?? expectedArch()));
|
||||
}
|
||||
for (const [name, content] of Object.entries(installScripts)) {
|
||||
fs.writeFileSync(path.join(dir, name), content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function withFixture(t, options) {
|
||||
const dir = makeFixture(options);
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('isComplete accepts a healthy same-version, same-arch install', (t) => {
|
||||
const dir = withFixture(t, { complete: true });
|
||||
assert.equal(isComplete(dir), true);
|
||||
});
|
||||
|
||||
test('isComplete rejects a missing dist directory', (t) => {
|
||||
const dir = withFixture(t);
|
||||
assert.equal(isComplete(dir), false);
|
||||
});
|
||||
|
||||
test('isComplete rejects a stale/mismatched version', (t) => {
|
||||
const dir = withFixture(t, { complete: true, distVersion: '41.1.0' });
|
||||
assert.equal(isComplete(dir), false);
|
||||
});
|
||||
|
||||
test('isComplete rejects a missing path.txt', (t) => {
|
||||
const dir = withFixture(t, { complete: true });
|
||||
fs.rmSync(path.join(dir, 'path.txt'));
|
||||
assert.equal(isComplete(dir), false);
|
||||
});
|
||||
|
||||
test('isComplete rejects a binary of the wrong architecture', (t) => {
|
||||
const hostArch = expectedArch();
|
||||
const otherArch = hostArch === 'arm64' ? 'x64' : 'arm64';
|
||||
const dir = withFixture(t, { complete: true, arch: otherArch });
|
||||
assert.equal(isComplete(dir), false);
|
||||
// With an expected arch matching the fixture the same install passes.
|
||||
assert.equal(isComplete(dir, otherArch), true);
|
||||
});
|
||||
|
||||
test('detectExecutableArch reads the header for both architectures', (t) => {
|
||||
const hostArch = expectedArch();
|
||||
const otherArch = hostArch === 'arm64' ? 'x64' : 'arm64';
|
||||
|
||||
const hostDir = withFixture(t, { complete: true });
|
||||
assert.equal(detectExecutableArch(path.join(hostDir, 'dist', platformPath())), hostArch);
|
||||
|
||||
const otherDir = withFixture(t, { complete: true, arch: otherArch });
|
||||
assert.equal(detectExecutableArch(path.join(otherDir, 'dist', platformPath())), otherArch);
|
||||
});
|
||||
|
||||
test('detectExecutableArch returns null for a non-executable file', (t) => {
|
||||
const dir = withFixture(t);
|
||||
const junk = path.join(dir, 'junk.bin');
|
||||
fs.writeFileSync(junk, 'not a binary');
|
||||
assert.equal(detectExecutableArch(junk), null);
|
||||
assert.equal(detectExecutableArch(path.join(dir, 'missing')), null);
|
||||
});
|
||||
|
||||
test('resolveElectronPackageDir locates the electron package in the monorepo', () => {
|
||||
const dir = resolveElectronPackageDir();
|
||||
assert.ok(dir);
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
||||
assert.equal(pkg.name, 'electron');
|
||||
});
|
||||
|
||||
test('main returns 0 for a healthy install without attempting repair', async (t) => {
|
||||
const dir = withFixture(t, { complete: true, installScripts: { 'install.js': FAIL_SCRIPT } });
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]',
|
||||
};
|
||||
assert.equal(await main([], env), 0);
|
||||
// The failing install script never ran: dist was not rewritten.
|
||||
assert.equal(fs.readFileSync(path.join(dir, 'path.txt'), 'utf8').trim(), platformPath());
|
||||
});
|
||||
|
||||
test('main repairs an incomplete install via the injected command', async (t) => {
|
||||
const dir = withFixture(t, { installScripts: { 'install.js': installScriptContent() } });
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]',
|
||||
};
|
||||
assert.equal(await main([], env), 0);
|
||||
assert.equal(isComplete(dir), true);
|
||||
});
|
||||
|
||||
test('main falls back from a failing first command to a succeeding second', async (t) => {
|
||||
const dir = withFixture(t, {
|
||||
installScripts: {
|
||||
'install-fail.js': FAIL_SCRIPT,
|
||||
'install-ok.js': installScriptContent(),
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS:
|
||||
'[["bun",["install-fail.js"]],["node",["install-ok.js"]]]',
|
||||
};
|
||||
assert.equal(await main([], env), 0);
|
||||
assert.equal(isComplete(dir), true);
|
||||
});
|
||||
|
||||
test('main returns 1 when every repair command fails', async (t) => {
|
||||
const dir = withFixture(t, {
|
||||
installScripts: {
|
||||
'install-fail-1.js': FAIL_SCRIPT,
|
||||
'install-fail-2.js': FAIL_SCRIPT,
|
||||
},
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS:
|
||||
'[["node",["install-fail-1.js"]],["node",["install-fail-2.js"]]]',
|
||||
};
|
||||
assert.equal(await main([], env), 1);
|
||||
});
|
||||
|
||||
test('main --best-effort returns 0 when repair is impossible', async (t) => {
|
||||
const dir = withFixture(t, { installScripts: { 'install-fail.js': FAIL_SCRIPT } });
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install-fail.js"]]]',
|
||||
};
|
||||
assert.equal(await main(['--best-effort'], env), 0);
|
||||
});
|
||||
|
||||
test('main honors ELECTRON_SKIP_BINARY_DOWNLOAD and skips repair', async (t) => {
|
||||
const dir = withFixture(t, { installScripts: { 'install.js': installScriptContent() } });
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: dir,
|
||||
OPENCHAMBER_ELECTRON_INSTALL_COMMANDS: '[["node",["install.js"]]]',
|
||||
ELECTRON_SKIP_BINARY_DOWNLOAD: '1',
|
||||
};
|
||||
assert.equal(await main([], env), 1);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'dist')), false);
|
||||
|
||||
assert.equal(await main(['--best-effort'], env), 0);
|
||||
assert.equal(fs.existsSync(path.join(dir, 'dist')), false);
|
||||
});
|
||||
|
||||
test('main reports a missing electron package', async () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCHAMBER_ELECTRON_PKG_DIR: path.join(os.tmpdir(), 'does-not-exist-electron-pkg'),
|
||||
};
|
||||
assert.equal(await main([], env), 1);
|
||||
assert.equal(await main(['--best-effort'], env), 0);
|
||||
});
|
||||
|
||||
test('repair skips a command whose runner errors and keeps trying the rest', (t) => {
|
||||
const dir = withFixture(t);
|
||||
const calls = [];
|
||||
const commands = [['bun', ['install.js']], ['node', ['install.js']]];
|
||||
const result = repair(dir, {
|
||||
runner: (bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
return { error: new Error('spawn failed') };
|
||||
},
|
||||
commands,
|
||||
});
|
||||
assert.equal(result, false);
|
||||
// Exactly the injected commands were invoked, in order.
|
||||
assert.deepEqual(calls, commands);
|
||||
});
|
||||
|
||||
test('repair returns false when the runner reports a failure status for every command', (t) => {
|
||||
const dir = withFixture(t);
|
||||
const calls = [];
|
||||
const commands = [['bun', ['install.js']], ['node', ['install.js']]];
|
||||
const result = repair(dir, {
|
||||
runner: (bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
return { status: 1 };
|
||||
},
|
||||
commands,
|
||||
});
|
||||
assert.equal(result, false);
|
||||
assert.deepEqual(calls, commands);
|
||||
});
|
||||
|
||||
test('repair runs injected commands in order and stops after the first success', (t) => {
|
||||
const dir = withFixture(t);
|
||||
const calls = [];
|
||||
const commands = [
|
||||
['bun', ['install-fail.js']],
|
||||
['node', ['install-ok.js']],
|
||||
['node', ['install-never.js']],
|
||||
];
|
||||
const result = repair(dir, {
|
||||
runner: (bin, args) => {
|
||||
calls.push([bin, args]);
|
||||
if (args[0] === 'install-ok.js') {
|
||||
// Simulate a successful postinstall: materialize a complete install so
|
||||
// isComplete() sees a healthy dist right after the command.
|
||||
const platform = platformPath();
|
||||
fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'dist', 'version'), '41.2.1');
|
||||
fs.writeFileSync(path.join(dir, 'path.txt'), platform);
|
||||
fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(expectedArch()));
|
||||
return { status: 0 };
|
||||
}
|
||||
return { status: 1 };
|
||||
},
|
||||
commands,
|
||||
});
|
||||
assert.equal(result, true);
|
||||
// Only the failing and succeeding commands ran; the trailing one was skipped.
|
||||
assert.deepEqual(calls, commands.slice(0, 2));
|
||||
assert.equal(isComplete(dir), true);
|
||||
});
|
||||
Reference in New Issue
Block a user