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:
Bohdan Triapitsyn
2026-08-04 00:58:10 +03:00
parent 4773db83c5
commit 0939b21d3c
7 changed files with 689 additions and 11 deletions
+9 -9
View File
@@ -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. 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: permission:
edit: allow edit: allow
@@ -16,13 +16,13 @@ permission:
"*.env.example": allow "*.env.example": allow
bash: bash:
"*": ask "*": ask
"bun test*": allow bun test*: allow
"bun run type-check*": allow bun run type-check*: allow
"bun run lint*": allow bun run lint*: allow
"bun run build*": allow bun run build*: allow
"bun run docs:validate": allow bun run docs:validate: allow
"bun run dead-code": allow bun run dead-code: allow
"git *": 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. 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.
@@ -77,4 +77,4 @@ Do not edit until the required project guidance and local context have been read
3. Re-read the edited code and verify that the observable contract is unchanged. 3. Re-read the edited code and verify that the observable contract is unchanged.
4. Run the narrowest validation required by the repository guidance and actual risk. Use package-scoped checks for local executable changes and broader checks only for genuinely shared contracts. 4. Run the narrowest validation required by the repository guidance and actual risk. Use package-scoped checks for local executable changes and broader checks only for genuinely shared contracts.
5. Run `bun run dead-code` only when files, exports, types, entrypoints, or import shapes changed, and inspect its non-blocking report. 5. Run `bun run dead-code` only when files, exports, types, entrypoints, or import shapes changed, and inspect its non-blocking report.
6. Summarize meaningful clarity improvements and report exactly what was and was not validated. 6. Summarize meaningful clarity improvements and report exactly what was and was not validated.
+1 -1
View File
@@ -40,7 +40,7 @@
"lint:mobile": "bun run --cwd packages/mobile lint", "lint:mobile": "bun run --cwd packages/mobile lint",
"clean": "bun run --filter '*' clean", "clean": "bun run --filter '*' clean",
"changelog-card": "node scripts/changelog-card/generate.mjs", "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": "bun run --cwd packages/web build:watch",
"dev:web:server": "bun run --cwd packages/web dev:server:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch",
"dev:web:full": "node ./scripts/dev-web-full.mjs", "dev:web:full": "node ./scripts/dev-web-full.mjs",
+9
View File
@@ -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 | | `preload.mjs` | Safe bridge from the rendered UI to Electron IPC |
| `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers | | `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers |
| `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support | | `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/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/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 | | `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. 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: Useful variants:
```bash ```bash
bun run electron:dev:bundled bun run electron:dev:bundled
bun run --cwd packages/electron ensure:electron
bun run type-check:electron bun run type-check:electron
bun run lint:electron bun run lint:electron
``` ```
+2 -1
View File
@@ -31,6 +31,7 @@
"dev": "node ./scripts/electron-dev.mjs", "dev": "node ./scripts/electron-dev.mjs",
"build:web-assets": "node ./scripts/build-web-assets.mjs", "build:web-assets": "node ./scripts/build-web-assets.mjs",
"build": "bun -e \"process.exit(0)\"", "build": "bun -e \"process.exit(0)\"",
"ensure:electron": "node ./scripts/ensure-electron.mjs",
"prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs", "prepare:opencode-cli": "node ./scripts/prepare-opencode-cli.mjs",
"verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged", "verify:opencode-cli": "node ./scripts/verify-opencode-cli.mjs --staged",
"verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged", "verify:opencode-cli:packaged": "node ./scripts/verify-opencode-cli.mjs --packaged",
@@ -38,7 +39,7 @@
"bundle:main": "bun ./scripts/bundle-main.mjs", "bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs", "generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs", "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: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", "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", "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 = {}) { function runProcess(command, args, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(command, args, { const child = spawn(command, args, {
@@ -183,6 +202,8 @@ async function main() {
let hmrApiPort = ''; let hmrApiPort = '';
let hmrUiPort = ''; let hmrUiPort = '';
ensureElectronInstalled();
if (useBundledUi) { if (useBundledUi) {
await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']); await runProcess('bun', ['run', '--cwd', 'packages/electron', 'build:web-assets']);
} else { } 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);
});