diff --git a/.opencode/agent/simplifier.md b/.opencode/agent/simplifier.md index 08506418..d88cdb0e 100644 --- a/.opencode/agent/simplifier.md +++ b/.opencode/agent/simplifier.md @@ -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. @@ -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. 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. -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. \ No newline at end of file diff --git a/package.json b/package.json index 1971ea11..24d0232c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/electron/README.md b/packages/electron/README.md index a2d4924e..9b971a2b 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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 ``` diff --git a/packages/electron/package.json b/packages/electron/package.json index 28755e6d..fc51d295 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index 8269c31d..a4ee60e4 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.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 { diff --git a/packages/electron/scripts/ensure-electron.mjs b/packages/electron/scripts/ensure-electron.mjs new file mode 100644 index 00000000..57a12cda --- /dev/null +++ b/packages/electron/scripts/ensure-electron.mjs @@ -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 /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; + } +} diff --git a/packages/electron/scripts/ensure-electron.test.mjs b/packages/electron/scripts/ensure-electron.test.mjs new file mode 100644 index 00000000..0bc3aa08 --- /dev/null +++ b/packages/electron/scripts/ensure-electron.test.mjs @@ -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); +});