diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index 314cd31710f..a4f6440b3c7 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -53,6 +53,7 @@ try { } async function main() { + repairElectronPathFile() if (electronPackageIsUsable()) { return } @@ -61,7 +62,6 @@ async function main() { // Node. Install only Electron's npm package binary here; do not run the full // Electron native-module rebuild path, which would undo the Node ABI rebuild. console.log('[electron-package] Electron package binary is missing; running Electron install.') - resetPartialElectronInstall() await installElectronPackageBinary() repairElectronPathFile() @@ -74,16 +74,23 @@ async function main() { } function electronPackageIsUsable() { + try { + const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') + return ( + electronDistMatchesPackage(getElectronExecutablePath()) && + installedPlatformPath === platformPath + ) + } catch { + return false + } +} + +function electronDistMatchesPackage(electronExecutable) { try { const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') .trim() .replace(/^v/, '') - const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') - return ( - installedVersion === electronVersion && - installedPlatformPath === platformPath && - existsSync(getElectronExecutablePath()) - ) + return installedVersion === electronVersion && existsSync(electronExecutable) } catch { return false } @@ -95,14 +102,9 @@ function getElectronExecutablePath() { : resolve(electronPackageDir, 'dist', platformPath) } -function resetPartialElectronInstall() { - rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) - rmSync(resolve(electronPackageDir, 'path.txt'), { force: true }) -} - function repairElectronPathFile() { const electronExecutable = resolve(electronPackageDir, 'dist', platformPath) - if (!existsSync(electronExecutable)) { + if (!electronDistMatchesPackage(electronExecutable)) { return } @@ -152,11 +154,6 @@ async function installElectronPackageBinary() { } moveExtractedElectronDist(extractDir, electronDistDir) - - const srcTypeDefPath = resolve(electronDistDir, 'electron.d.ts') - if (existsSync(srcTypeDefPath)) { - renameSync(srcTypeDefPath, resolve(electronPackageDir, 'electron.d.ts')) - } } finally { rmSync(tempDir, { recursive: true, force: true }) } @@ -266,16 +263,85 @@ function extractElectronArchive(zipPath, extractDir) { } function moveExtractedElectronDist(extractDir, electronDistDir) { - rmSync(electronDistDir, { recursive: true, force: true }) + const transactionDir = mkdtempSync(resolve(electronPackageDir, '.dist-install-')) + const nextDistDir = join(transactionDir, 'next') + const previousDistDir = join(transactionDir, 'previous') + const packageTypeDefPath = resolve(electronPackageDir, 'electron.d.ts') + const previousTypeDefPath = join(transactionDir, 'previous-electron.d.ts') + let previousMoved = false + let previousTypeDefMoved = false + let nextPublished = false + let cleanupTransaction = true + + try { + stageExtractedElectronDist(extractDir, nextDistDir) + const hasNextTypeDef = existsSync(resolve(nextDistDir, 'electron.d.ts')) + try { + if (existsSync(electronDistDir)) { + renameSync(electronDistDir, previousDistDir) + previousMoved = true + } + if (hasNextTypeDef && existsSync(packageTypeDefPath)) { + renameSync(packageTypeDefPath, previousTypeDefPath) + previousTypeDefMoved = true + } + renameSync(nextDistDir, electronDistDir) + nextPublished = true + if (hasNextTypeDef) { + renameSync(resolve(electronDistDir, 'electron.d.ts'), packageTypeDefPath) + } + } catch (publishError) { + const rollbackErrors = [] + for (const [shouldMove, source, target] of [ + [nextPublished, electronDistDir, nextDistDir], + [previousMoved, previousDistDir, electronDistDir], + [previousTypeDefMoved, previousTypeDefPath, packageTypeDefPath] + ]) { + if (!shouldMove) { + continue + } + try { + renameSync(source, target) + } catch (rollbackError) { + rollbackErrors.push(rollbackError) + } + } + if (rollbackErrors.length > 0) { + cleanupTransaction = false + throw new AggregateError( + [publishError, ...rollbackErrors], + `Electron install publish failed; previous files remain at ${transactionDir}` + ) + } + throw publishError + } + } finally { + if (cleanupTransaction) { + // Why: the discarded tree can hold an executable another process still has + // open on Windows. Never fail a published install, or mask a publish error, + // on leftover-temp cleanup. + try { + rmSync(transactionDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + } catch (cleanupError) { + console.warn( + `[electron-package] Could not remove install transaction dir ${transactionDir}: ` + + `${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` + ) + } + } + } +} + +function stageExtractedElectronDist(extractDir, nextDistDir) { try { // Why: macOS Electron archives rely on framework symlinks. Moving the // verified tree preserves them exactly; copying has broken them in CI. - renameSync(extractDir, electronDistDir) + renameSync(extractDir, nextDistDir) } catch (/** @type {any} */ err) { if (err?.code !== 'EXDEV') { throw err } - cpSync(extractDir, electronDistDir, { + cpSync(extractDir, nextDistDir, { recursive: true, dereference: false, verbatimSymlinks: true diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index ad04336e8e7..a1a42d26ae6 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -26,6 +26,11 @@ describe('install-electron-package-binary', () => { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir) writeFakeExtractor(projectDir, { createExecutable: true }) + writeFakeElectronDist(projectDir, { + version: 'v40.0.0', + executableContents: 'old executable', + pathContents: 'stale-path' + }) const result = runInstallScript(projectDir) @@ -36,6 +41,10 @@ describe('install-electron-package-binary', () => { expect(readFileSync(join(projectDir, 'node_modules', 'electron', 'path.txt'), 'utf8')).toBe( 'electron' ) + expect(readFileSync(join(projectDir, 'node_modules/electron/electron.d.ts'), 'utf8')).toBe( + 'replacement types' + ) + expect(existsSync(join(projectDir, 'node_modules/electron/dist/electron.d.ts'))).toBe(false) if (process.platform !== 'win32') { expect( lstatSync( @@ -49,6 +58,84 @@ describe('install-electron-package-binary', () => { } }) + it('repairs existing Electron path metadata without downloading', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeElectronDist(projectDir) + + const result = runInstallScript(projectDir) + + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'electron' + ) + expect(result.stdout).toContain('Repaired Electron path.txt -> electron') + expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('preserves an existing Electron distribution when replacement download fails', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { downloadFailures: 1, downloadErrorCode: 'EACCES' }) + writeFakeElectronDist(projectDir, { + version: 'v40.0.0', + executableContents: 'existing executable', + pathContents: 'electron' + }) + + const result = runInstallScript(projectDir) + const electronDir = join(projectDir, 'node_modules/electron') + + expect(result.status).toBe(1) + expect(readFileSync(join(electronDir, 'dist/version'), 'utf8')).toBe('v40.0.0') + expect(readFileSync(join(electronDir, 'dist/electron'), 'utf8')).toBe('existing executable') + expect(readFileSync(join(electronDir, 'path.txt'), 'utf8')).toBe('electron') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('restores an existing Electron distribution when publishing its type definitions fails', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: true }) + writeFakeElectronDist(projectDir, { + version: 'v40.0.0', + executableContents: 'existing executable', + pathContents: 'electron' + }) + writeFileSync(join(projectDir, 'node_modules/electron/electron.d.ts'), 'existing types') + const preloadPath = writeTypeDefPublishFailurePreload(projectDir) + + const result = runInstallScript(projectDir, { + NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath}`] + .filter(Boolean) + .join(' ') + }) + const electronDir = join(projectDir, 'node_modules/electron') + + expect(result.status).toBe(1) + expect(result.stderr).toContain('injected Electron type definition publish failure') + expect(readFileSync(join(electronDir, 'dist/version'), 'utf8')).toBe('v40.0.0') + expect(readFileSync(join(electronDir, 'dist/electron'), 'utf8')).toBe('existing executable') + expect(readFileSync(join(electronDir, 'path.txt'), 'utf8')).toBe('electron') + expect(readFileSync(join(electronDir, 'electron.d.ts'), 'utf8')).toBe('existing types') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('uses Electron 42 install env vars before npm config platform flags', () => { const projectDir = mkTempProject() @@ -329,6 +416,19 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) ) } +function writeFakeElectronDist( + projectDir, + { version = 'v41.5.0', executableContents = '', pathContents } = {} +) { + const electronDir = join(projectDir, 'node_modules', 'electron') + mkdirSync(join(electronDir, 'dist'), { recursive: true }) + writeFileSync(join(electronDir, 'dist/version'), version) + writeFileSync(join(electronDir, 'dist/electron'), executableContents) + if (pathContents !== undefined) { + writeFileSync(join(electronDir, 'path.txt'), pathContents) + } +} + function writeFakeElectronGet( projectDir, { @@ -386,6 +486,7 @@ mkdirSync(join(extractDir, 'locales'), { recursive: true }) if (${JSON.stringify(createExecutable)}) { writeFileSync(join(extractDir, 'electron'), '') writeFileSync(join(extractDir, 'electron.exe'), '') + writeFileSync(join(extractDir, 'electron.d.ts'), 'replacement types') writeFileSync(join(extractDir, 'version'), 'v41.5.0') if (process.platform !== 'win32') { symlinkSync('version', join(extractDir, 'version-link')) @@ -394,3 +495,26 @@ if (${JSON.stringify(createExecutable)}) { ` ) } + +function writeTypeDefPublishFailurePreload(projectDir) { + const preloadPath = join(projectDir, 'type-def-publish-failure.cjs') + writeFileSync( + preloadPath, + ` +const fs = require('node:fs') +const { syncBuiltinESMExports } = require('node:module') +const { basename, dirname } = require('node:path') +const renameSync = fs.renameSync +fs.renameSync = (source, target) => { + if (basename(source) === 'electron.d.ts' && basename(dirname(source)) === 'dist') { + const error = new Error('injected Electron type definition publish failure') + error.code = 'EACCES' + throw error + } + return renameSync(source, target) +} +syncBuiltinESMExports() +` + ) + return preloadPath +} diff --git a/config/scripts/rebuild-native-deps-node-pty.test.mjs b/config/scripts/rebuild-native-deps-node-pty.test.mjs new file mode 100644 index 00000000000..665ac0312ab --- /dev/null +++ b/config/scripts/rebuild-native-deps-node-pty.test.mjs @@ -0,0 +1,258 @@ +import { existsSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { + mkTempProject, + runRebuildScript, + writeFakeElectronRebuild, + writeFakeLoadableNodePty, + writeFakeNodePtyConptyPayload, + writeFakeUsableElectronPackage, + writeFakeWindowsProcessTree, + writeFakeWindowsProcessTreeWithNodeAddonApi, + writeFakeWindowsRegistry, + writeNodePtyPatchFile, + writePatchedNodePtyBuildArtifacts +} from './rebuild-native-deps-test-fixtures.mjs' + +describe('rebuild-native-deps patched node-pty rebuild', () => { + it.skipIf(process.platform !== 'win32')( + 'repairs a missing ConPTY runtime before probing without recompiling node-pty', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) + writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files') + expect(result.stdout).toContain( + 'Native modules already load in Electron; skipping rebuild.' + ) + expect(existsSync(rebuildLogPath)).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it('stages windows-process-tree node-addon-api headers before a Windows rebuild', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync( + join( + projectDir, + 'node_modules', + '@vscode', + 'windows-process-tree', + 'deps', + 'node-addon-api', + 'napi.h' + ), + 'utf8' + ) + ).toBe('// napi.h\n') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { + const projectDir = mkTempProject() + + try { + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir) + writeFakeNodePtyConptyPayload(projectDir, 'x64') + + const result = runRebuildScript( + projectDir, + { npm_config_platform: 'win32', npm_config_arch: 'x64' }, + ['--platform=win32', '--arch=x64', '--force'] + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64') + const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') + expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64') + expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform !== 'win32')( + 'does not rebuild a healthy node-pty when another Windows addon fails its probe', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir) + writeFakeWindowsProcessTree(projectDir) + writeFakeNodePtyConptyPayload(projectDir, process.arch) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: windows-native-registry') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['windows-native-registry']) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform !== 'win32')( + 'rebuilds a loadable ConPTY native that lacks Orca job ownership', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) + writeFakeWindowsRegistry(projectDir) + writeFakeWindowsProcessTree(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath, + npm_config_platform: 'win32', + npm_config_arch: process.arch + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') + expect(result.stdout).toContain('missing listJobProcessIds') + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rebuilds when Electron can load node-pty but patched build artifacts are missing', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir) + writeNodePtyPatchFile(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain( + 'Patched node-pty build artifacts are missing; rebuilding from source.' + ) + + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + expect(rebuildCall.ignoreModules).toEqual(['cpu-features']) + expect(rebuildCall.force).toBe(true) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'keeps the Electron load-probe fast path once patched node-pty artifacts exist', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) + writeNodePtyPatchFile(projectDir) + writePatchedNodePtyBuildArtifacts(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain( + 'Native modules already load in Electron; skipping rebuild.' + ) + expect(existsSync(rebuildLogPath)).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) + + it.skipIf(process.platform === 'win32')( + 'rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds', + () => { + const projectDir = mkTempProject() + + try { + const rebuildLogPath = join(projectDir, 'electron-rebuild.log') + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) + writeFakeLoadableNodePty(projectDir, { nativeDir: '../prebuilds/darwin-arm64/' }) + writeNodePtyPatchFile(projectDir) + writePatchedNodePtyBuildArtifacts(projectDir) + + const result = runRebuildScript(projectDir, { + ORCA_REBUILD_TEST_LOG: rebuildLogPath + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') + expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active") + + const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) + expect(rebuildCall.onlyModules).toEqual(['node-pty']) + expect(rebuildCall.force).toBe(true) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + } + ) +}) diff --git a/config/scripts/rebuild-native-deps-test-fixtures.mjs b/config/scripts/rebuild-native-deps-test-fixtures.mjs new file mode 100644 index 00000000000..43e3db7b810 --- /dev/null +++ b/config/scripts/rebuild-native-deps-test-fixtures.mjs @@ -0,0 +1,307 @@ +import { spawnSync } from 'node:child_process' +import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url)) +const sourceInstallScriptPath = fileURLToPath( + new URL('./install-electron-package-binary.mjs', import.meta.url) +) +const sourceNodePtyJobOwnershipPath = fileURLToPath( + new URL('./node-pty-job-ownership.cjs', import.meta.url) +) +const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath( + new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url) +) + +export function mkTempProject() { + const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) + mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) + copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) + copyFileSync( + sourceInstallScriptPath, + join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') + ) + copyFileSync( + sourceNodePtyJobOwnershipPath, + join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') + ) + copyFileSync( + sourceWindowsProcessTreeGypRebuildPath, + join(projectDir, 'config', 'scripts', 'windows-process-tree-gyp-rebuild.mjs') + ) + return projectDir +} + +export function runRebuildScript(projectDir, extraEnv = {}, args = []) { + const env = { + ...process.env, + npm_config_platform: 'linux', + npm_config_arch: 'x64', + ORCA_ELECTRON_PACKAGE_EXTRACTOR: join(projectDir, 'fake-extractor.cjs') + } + for (const key of Object.keys(env)) { + if ( + key.toLowerCase() === 'orca_strict_electron_install' || + key.toLowerCase() === 'npm_lifecycle_event' + ) { + delete env[key] + } + } + return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], { + cwd: projectDir, + encoding: 'utf8', + env: { + ...env, + ...extraEnv + } + }) +} + +export function writeFakeElectronPackage(projectDir) { + const electronDir = join(projectDir, 'node_modules', 'electron') + mkdirSync(electronDir, { recursive: true }) + writeFileSync( + join(electronDir, 'package.json'), + JSON.stringify({ name: 'electron', version: '41.5.0' }) + ) + writeFileSync(join(electronDir, 'checksums.json'), '{}') + writeFileSync( + join(electronDir, 'index.js'), + ` +const fs = require('node:fs') +const path = require('node:path') +const pathFile = path.join(__dirname, 'path.txt') +if (!fs.existsSync(pathFile)) { + throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') +} +const electronPath = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) +if (!fs.existsSync(electronPath)) { + throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') +} +module.exports = electronPath +` + ) +} + +export function writeFakeElectronGet( + projectDir, + { + downloadRejects = false, + logPartialStateBeforeInstall = false, + logTargetBeforeInstall = false + } = {} +) { + const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') + mkdirSync(getDir, { recursive: true }) + writeFileSync( + join(getDir, 'index.js'), + ` +const { appendFileSync, existsSync, mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +exports.downloadArtifact = async function downloadArtifact(details) { + if (${JSON.stringify(logTargetBeforeInstall)}) { + appendFileSync( + 'electron-get.log', + 'platform=' + details.platform + ' arch=' + details.arch + '\\n' + ) + } + if (${JSON.stringify(logPartialStateBeforeInstall)}) { + appendFileSync( + 'electron-get.log', + existsSync('node_modules/electron/dist') || existsSync('node_modules/electron/path.txt') + ? 'partial still present\\n' + : 'partial cleared\\n' + ) + } + appendFileSync('electron-get.log', 'download attempted\\n') + if (${JSON.stringify(downloadRejects)}) { + throw new Error('download failed') + } + mkdirSync(details.cacheRoot, { recursive: true }) + const artifactPath = join(details.cacheRoot, 'electron.zip') + writeFileSync(artifactPath, 'fake zip') + return artifactPath +} +` + ) +} + +export function writeFakeElectronExtractor(projectDir, { createExecutable }) { + writeFileSync( + join(projectDir, 'fake-extractor.cjs'), + ` +const { mkdirSync, writeFileSync } = require('node:fs') +const { join } = require('node:path') +const extractDir = process.argv[3] +mkdirSync(join(extractDir, 'locales'), { recursive: true }) +if (${JSON.stringify(createExecutable)}) { + writeFileSync(join(extractDir, 'electron'), '') + writeFileSync(join(extractDir, 'electron.exe'), '') + writeFileSync(join(extractDir, 'version'), 'v41.5.0') +} +` + ) +} + +export function writeFakeElectronRebuild(projectDir, { logPathEnv = null } = {}) { + const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') + mkdirSync(rebuildDir, { recursive: true }) + writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) + writeFileSync( + join(rebuildDir, 'index.js'), + logPathEnv + ? ` +import { appendFileSync } from 'node:fs' + +export async function rebuild(options) { + const logPath = process.env[${JSON.stringify(logPathEnv)}] + if (!logPath) { + return + } + appendFileSync( + logPath, + JSON.stringify({ + arch: options.arch, + electronVersion: options.electronVersion, + force: options.force, + ignoreModules: options.ignoreModules, + onlyModules: options.onlyModules, + platform: options.platform + }) + '\\n' + ) +} +` + : 'export async function rebuild() {}\n' + ) +} + +export function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) { + writeFakeElectronPackage(projectDir) + const electronDir = join(projectDir, 'node_modules', 'electron') + const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron' + const electronPath = join(electronDir, 'dist', platformExecutable) + mkdirSync(join(electronDir, 'dist'), { recursive: true }) + writeFileSync(join(electronDir, 'path.txt'), platformExecutable) + writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0') + if (platform === 'win32') { + copyFileSync(process.execPath, electronPath) + } else { + writeFileSync( + electronPath, + `#!/usr/bin/env node +const { spawnSync } = require('node:child_process') + +const result = spawnSync(process.execPath, process.argv.slice(2), { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit' +}) + +if (result.error) { + console.error(result.error.message) + process.exit(1) +} + +process.exit(result.status ?? 0) +` + ) + chmodSync(electronPath, 0o755) + } +} + +export function writeFakeNodePtyConptyPayload(projectDir, arch) { + const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(releaseDir, { recursive: true }) + writeFileSync(join(releaseDir, 'conpty.node'), 'native addon') + const sourceDir = join( + projectDir, + 'node_modules', + 'node-pty', + 'third_party', + 'conpty', + '0.1.0', + `win10-${arch}` + ) + mkdirSync(sourceDir, { recursive: true }) + writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`) + writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) +} + +export function writeFakeLoadableNodePty( + projectDir, + { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {} +) { + const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') + mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) + writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') + writeFileSync( + join(nodePtyDir, 'lib', 'utils.js'), + ` +exports.loadNativeModule = function loadNativeModule(nativeName) { + return { + dir: ${JSON.stringify(nativeDir)}, + module: { + nativeName, + ...(${JSON.stringify(ownsPtyJob)} + ? { + listJobProcessIds() {}, + terminateJob() {}, + assignCurrentProcessToJob() {} + } + : {}) + } + } +} +` + ) +} + +export function writeFakeWindowsRegistry(projectDir) { + const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') + mkdirSync(registryDir, { recursive: true }) + writeFileSync( + join(registryDir, 'index.js'), + 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' + ) +} + +export function writeFakeWindowsProcessTree(projectDir) { + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + mkdirSync(processTreeDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') +} + +export function writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) { + const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') + const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') + mkdirSync(nodeAddonApiDir, { recursive: true }) + writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') + writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') + writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') + writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') + writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') + writeFileSync(join(nodeAddonApiDir, 'napi-inl.deprecated.h'), '// napi-inl.deprecated.h\n') +} + +export function writeNodePtyPatchFile(projectDir) { + mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) + writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') +} + +export function writePatchedNodePtyBuildArtifacts(projectDir) { + const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') + mkdirSync(buildDir, { recursive: true }) + if (process.platform === 'win32') { + writeFileSync(join(buildDir, 'conpty.node'), '') + mkdirSync(join(buildDir, 'conpty'), { recursive: true }) + writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') + writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') + return + } + writeFileSync(join(buildDir, 'pty.node'), '') + if (process.platform === 'darwin') { + writeFileSync(join(buildDir, 'spawn-helper'), '') + } +} diff --git a/config/scripts/rebuild-native-deps.mjs b/config/scripts/rebuild-native-deps.mjs index 9bf61b679ed..3b17683e831 100644 --- a/config/scripts/rebuild-native-deps.mjs +++ b/config/scripts/rebuild-native-deps.mjs @@ -28,7 +28,6 @@ import { mkdirSync, readdirSync, readFileSync, - rmSync, writeFileSync } from 'node:fs' import { platform as osPlatform } from 'node:os' @@ -44,6 +43,15 @@ try { } const rebuildPlatform = cliOptions.platform ?? osPlatform() const rebuildArch = cliOptions.arch ?? process.arch +// Why: resolve the Electron download target once so the child installer and the +// usability check can never disagree about which binary should be on disk. +const electronInstallPlatform = + cliOptions.platform || + process.env.ELECTRON_INSTALL_PLATFORM || + process.env.npm_config_platform || + rebuildPlatform +const electronInstallArch = + cliOptions.arch || process.env.ELECTRON_INSTALL_ARCH || process.env.npm_config_arch || rebuildArch const electronPackageDir = resolve(projectDir, 'node_modules/electron') const electronVersion = JSON.parse( readFileSync(resolve(electronPackageDir, 'package.json'), 'utf8') @@ -208,6 +216,7 @@ function restoreNodePtyWindowsConptyRuntime() { } function ensureElectronPackageInstalled() { + repairElectronPathFile() if (electronPackageIsUsable()) { return } @@ -216,7 +225,6 @@ function ensureElectronPackageInstalled() { // writing path.txt. Electron 42's lazy require() would run install.js here, // so inspect dist/ directly and keep using our strict partial-extract checks. console.log('[rebuild] Electron package binary is missing; installing Electron package binary.') - resetPartialElectronInstall() try { runElectronPackageBinaryInstall() } catch (/** @type {any} */ err) { @@ -228,37 +236,46 @@ function ensureElectronPackageInstalled() { process.exit(1) } + repairElectronPathFile() if (!electronPackageIsUsable()) { - const repaired = repairElectronPathFile() - if (!repaired || !electronPackageIsUsable()) { - logElectronInstallDiagnostics() - if (continuePostinstallWithoutElectron()) { - process.exit(0) - } - console.error('[rebuild] Electron package is still unavailable after retry.') - process.exit(1) + logElectronInstallDiagnostics() + if (continuePostinstallWithoutElectron()) { + process.exit(0) } + console.error('[rebuild] Electron package is still unavailable after retry.') + process.exit(1) } } function electronPackageIsUsable() { try { - const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') - .trim() - .replace(/^v/, '') const installedPlatformPath = readFileSync(resolve(electronPackageDir, 'path.txt'), 'utf8') return ( - installedVersion === electronVersion && - installedPlatformPath === getElectronPlatformPath() && - existsSync(getElectronExecutablePath()) + electronDistMatchesPackage(getElectronExecutablePath()) && + installedPlatformPath === getElectronPlatformPath() ) } catch { return false } } +function electronDistMatchesPackage(electronExecutable) { + try { + const installedVersion = readFileSync(resolve(electronPackageDir, 'dist', 'version'), 'utf8') + .trim() + .replace(/^v/, '') + return installedVersion === electronVersion && existsSync(electronExecutable) + } catch { + return false + } +} + function runElectronPackageBinaryInstall() { - const env = { ...process.env } + const env = { + ...process.env, + ELECTRON_INSTALL_PLATFORM: electronInstallPlatform, + ELECTRON_INSTALL_ARCH: electronInstallArch + } delete env.ELECTRON_SKIP_BINARY_DOWNLOAD delete env.npm_config_electron_skip_binary_download @@ -282,13 +299,6 @@ function runElectronPackageBinaryInstall() { } } -function resetPartialElectronInstall() { - // Why: Electron's installer can leave a partial dist/ tree behind after - // skipped or interrupted postinstall runs; retry from a clean target. - rmSync(resolve(electronPackageDir, 'dist'), { recursive: true, force: true }) - rmSync(resolve(electronPackageDir, 'path.txt'), { force: true }) -} - function continuePostinstallWithoutElectron() { if (!isPostinstall() || process.env.ORCA_STRICT_ELECTRON_INSTALL === '1') { return false @@ -303,16 +313,22 @@ function continuePostinstallWithoutElectron() { function repairElectronPathFile() { const platformPath = getElectronPlatformPath() - if (!existsSync(getElectronExecutablePath())) { - return false + const electronExecutable = resolve(electronPackageDir, 'dist', platformPath) + if (!electronDistMatchesPackage(electronExecutable)) { + return } - // Why: Electron's install script has exited successfully in CI after - // extraction without leaving path.txt. The package main only needs this file - // to point at the already-extracted executable. - writeFileSync(resolve(electronPackageDir, 'path.txt'), platformPath) - console.log(`[rebuild] Repaired Electron path.txt -> ${platformPath}`) - return true + const pathFile = resolve(electronPackageDir, 'path.txt') + let currentPath = '' + try { + currentPath = readFileSync(pathFile, 'utf8') + } catch { + // Missing path.txt is the common CI failure this script repairs. + } + if (currentPath !== platformPath) { + writeFileSync(pathFile, platformPath) + console.log(`[rebuild] Repaired Electron path.txt -> ${platformPath}`) + } } function logElectronInstallDiagnostics() { @@ -336,9 +352,7 @@ function safeReaddir(targetPath) { } function getElectronPlatformPath() { - const targetPlatform = - process.env.ELECTRON_INSTALL_PLATFORM || process.env.npm_config_platform || rebuildPlatform - switch (targetPlatform) { + switch (electronInstallPlatform) { case 'mas': case 'darwin': return 'Electron.app/Contents/MacOS/Electron' @@ -349,7 +363,7 @@ function getElectronPlatformPath() { case 'win32': return 'electron.exe' default: - throw new Error(`Electron builds are not available on platform: ${targetPlatform}`) + throw new Error(`Electron builds are not available on platform: ${electronInstallPlatform}`) } } diff --git a/config/scripts/rebuild-native-deps.test.mjs b/config/scripts/rebuild-native-deps.test.mjs index 31676b92e55..cddab2ee9f0 100644 --- a/config/scripts/rebuild-native-deps.test.mjs +++ b/config/scripts/rebuild-native-deps.test.mjs @@ -1,29 +1,16 @@ -import { spawnSync } from 'node:child_process' -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync -} from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url)) -const sourceInstallScriptPath = fileURLToPath( - new URL('./install-electron-package-binary.mjs', import.meta.url) -) -const sourceNodePtyJobOwnershipPath = fileURLToPath( - new URL('./node-pty-job-ownership.cjs', import.meta.url) -) -const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath( - new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url) -) +import { + mkTempProject, + runRebuildScript, + writeFakeElectronExtractor, + writeFakeElectronGet, + writeFakeElectronPackage, + writeFakeElectronRebuild, + writeFakeUsableElectronPackage +} from './rebuild-native-deps-test-fixtures.mjs' describe('rebuild-native-deps Electron install fallback', () => { it('continues non-strict postinstall when Electron retry download fails', () => { @@ -32,7 +19,7 @@ describe('rebuild-native-deps Electron install fallback', () => { try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir, { downloadRejects: true }) - writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronExtractor(projectDir, { createExecutable: false }) writeFakeElectronRebuild(projectDir) const result = runRebuildScript(projectDir, { @@ -59,7 +46,7 @@ describe('rebuild-native-deps Electron install fallback', () => { try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir, { downloadRejects: true }) - writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronExtractor(projectDir, { createExecutable: false }) writeFakeElectronRebuild(projectDir) const result = runRebuildScript(projectDir, { @@ -83,7 +70,7 @@ describe('rebuild-native-deps Electron install fallback', () => { try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir, { downloadRejects: true }) - writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronExtractor(projectDir, { createExecutable: false }) writeFakeElectronRebuild(projectDir) const result = runRebuildScript(projectDir) @@ -98,13 +85,13 @@ describe('rebuild-native-deps Electron install fallback', () => { } }) - it('clears partial Electron package contents before retrying install', () => { + it('preserves partial Electron package contents while retrying install', () => { const projectDir = mkTempProject() try { writeFakeElectronPackage(projectDir) writeFakeElectronGet(projectDir, { logPartialStateBeforeInstall: true }) - writeFakeExtractZip(projectDir, { createExecutable: false }) + writeFakeElectronExtractor(projectDir, { createExecutable: false }) writeFakeElectronRebuild(projectDir) mkdirSync(join(projectDir, 'node_modules', 'electron', 'dist', 'locales'), { recursive: true @@ -121,534 +108,129 @@ describe('rebuild-native-deps Electron install fallback', () => { expect(result.status).toBe(1) expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe( - 'partial cleared\ndownload attempted\n' + 'partial still present\ndownload attempted\n' + ) + expect(existsSync(join(projectDir, 'node_modules/electron/dist/locales/stale.pak'))).toBe( + true + ) + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'stale-path' ) } finally { rmSync(projectDir, { recursive: true, force: true }) } }) -}) -describe('rebuild-native-deps patched node-pty rebuild', () => { - it.skipIf(process.platform !== 'win32')( - 'repairs a missing ConPTY runtime before probing without recompiling node-pty', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) - writeFakeWindowsRegistry(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Restored node-pty ConPTY runtime files') - expect(result.stdout).toContain( - 'Native modules already load in Electron; skipping rebuild.' - ) - expect(existsSync(rebuildLogPath)).toBe(false) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - - it('stages windows-process-tree node-addon-api headers before a Windows rebuild', () => { + it('passes the rebuild target to the Electron binary installer', () => { const projectDir = mkTempProject() try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { logTargetBeforeInstall: true }) + writeFakeElectronExtractor(projectDir, { createExecutable: true }) writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') - writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) const result = runRebuildScript( projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] + { npm_config_platform: '', npm_config_arch: '' }, + ['--platform=linux', '--arch=arm64', '--force'] ) expect(result.status, result.stderr).toBe(0) - expect( - readFileSync( - join( - projectDir, - 'node_modules', - '@vscode', - 'windows-process-tree', - 'deps', - 'node-addon-api', - 'napi.h' - ), - 'utf8' - ) - ).toBe('// napi.h\n') + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toBe( + 'platform=linux arch=arm64\ndownload attempted\n' + ) } finally { rmSync(projectDir, { recursive: true, force: true }) } }) - it('restores the ConPTY runtime payload after a Windows Electron rebuild', () => { + it('lets an explicit rebuild target win over inherited installer variables', () => { const projectDir = mkTempProject() try { - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) + writeFakeUsableElectronPackage(projectDir, { platform: 'linux' }) + writeFakeElectronGet(projectDir, { logTargetBeforeInstall: true }) writeFakeElectronRebuild(projectDir) - writeFakeNodePtyConptyPayload(projectDir, 'x64') const result = runRebuildScript( projectDir, - { npm_config_platform: 'win32', npm_config_arch: 'x64' }, - ['--platform=win32', '--arch=x64', '--force'] + { ELECTRON_INSTALL_PLATFORM: 'win32', ELECTRON_INSTALL_ARCH: 'arm64' }, + ['--platform=linux', '--arch=x64', '--force'] ) expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Restored node-pty ConPTY runtime files for win10-x64') - const runtimeDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty') - expect(readFileSync(join(runtimeDir, 'conpty.dll'), 'utf8')).toBe('conpty.dll x64') - expect(readFileSync(join(runtimeDir, 'OpenConsole.exe'), 'utf8')).toBe('OpenConsole.exe x64') + expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false) } finally { rmSync(projectDir, { recursive: true, force: true }) } }) - it.skipIf(process.platform !== 'win32')( - 'does not rebuild a healthy node-pty when another Windows addon fails its probe', - () => { - const projectDir = mkTempProject() + it('installs the inherited installer target when no rebuild target is passed', () => { + const projectDir = mkTempProject() - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir) - writeFakeWindowsProcessTree(projectDir) - writeFakeNodePtyConptyPayload(projectDir, process.arch) + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { logTargetBeforeInstall: true }) + writeFakeElectronExtractor(projectDir, { createExecutable: true }) + writeFakeElectronRebuild(projectDir) - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) + const result = runRebuildScript(projectDir, { + ELECTRON_INSTALL_PLATFORM: 'win32', + ELECTRON_INSTALL_ARCH: 'arm64' + }) - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: windows-native-registry') - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['windows-native-registry']) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform !== 'win32')( - 'rebuilds a loadable ConPTY native that lacks Orca job ownership', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir, { platform: 'win32' }) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false }) - writeFakeWindowsRegistry(projectDir) - writeFakeWindowsProcessTree(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath, - npm_config_platform: 'win32', - npm_config_arch: process.arch - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') - expect(result.stdout).toContain('missing listJobProcessIds') - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'rebuilds when Electron can load node-pty but patched build artifacts are missing', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir) - writeNodePtyPatchFile(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain( - 'Patched node-pty build artifacts are missing; rebuilding from source.' - ) - - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - expect(rebuildCall.ignoreModules).toEqual(['cpu-features']) - expect(rebuildCall.force).toBe(true) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'keeps the Electron load-probe fast path once patched node-pty artifacts exist', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../build/Release/' }) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain( - 'Native modules already load in Electron; skipping rebuild.' - ) - expect(existsSync(rebuildLogPath)).toBe(false) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) - - it.skipIf(process.platform === 'win32')( - 'rebuilds when patched artifacts exist but Electron falls back to node-pty prebuilds', - () => { - const projectDir = mkTempProject() - - try { - const rebuildLogPath = join(projectDir, 'electron-rebuild.log') - writeFakeUsableElectronPackage(projectDir) - writeFakeElectronRebuild(projectDir, { logPathEnv: 'ORCA_REBUILD_TEST_LOG' }) - writeFakeLoadableNodePty(projectDir, { nativeDir: '../prebuilds/darwin-arm64/' }) - writeNodePtyPatchFile(projectDir) - writePatchedNodePtyBuildArtifacts(projectDir) - - const result = runRebuildScript(projectDir, { - ORCA_REBUILD_TEST_LOG: rebuildLogPath - }) - - expect(result.status, result.stderr).toBe(0) - expect(result.stdout).toContain('Rebuilding failed native modules: node-pty') - expect(result.stdout).toContain("expected build/Release so Orca's node-pty patch is active") - - const rebuildCall = JSON.parse(readFileSync(rebuildLogPath, 'utf8').trim()) - expect(rebuildCall.onlyModules).toEqual(['node-pty']) - expect(rebuildCall.force).toBe(true) - } finally { - rmSync(projectDir, { recursive: true, force: true }) - } - } - ) -}) - -function mkTempProject() { - const projectDir = mkdtempSync(join(tmpdir(), 'orca-rebuild-native-deps-')) - mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true }) - copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs')) - copyFileSync( - sourceInstallScriptPath, - join(projectDir, 'config', 'scripts', 'install-electron-package-binary.mjs') - ) - copyFileSync( - sourceNodePtyJobOwnershipPath, - join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs') - ) - copyFileSync( - sourceWindowsProcessTreeGypRebuildPath, - join(projectDir, 'config', 'scripts', 'windows-process-tree-gyp-rebuild.mjs') - ) - return projectDir -} - -function runRebuildScript(projectDir, extraEnv = {}, args = []) { - const env = { - ...process.env, - npm_config_platform: 'linux', - npm_config_arch: 'x64' - } - for (const key of Object.keys(env)) { - if ( - key.toLowerCase() === 'orca_strict_electron_install' || - key.toLowerCase() === 'npm_lifecycle_event' - ) { - delete env[key] - } - } - return spawnSync(process.execPath, ['config/scripts/rebuild-native-deps.mjs', ...args], { - cwd: projectDir, - encoding: 'utf8', - env: { - ...env, - ...extraEnv + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain( + 'platform=win32 arch=arm64' + ) + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'electron.exe' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) } }) -} -function writeFakeElectronPackage(projectDir) { - const electronDir = join(projectDir, 'node_modules', 'electron') - mkdirSync(electronDir, { recursive: true }) - writeFileSync( - join(electronDir, 'package.json'), - JSON.stringify({ name: 'electron', version: '41.5.0' }) - ) - writeFileSync(join(electronDir, 'checksums.json'), '{}') - writeFileSync( - join(electronDir, 'index.js'), - ` -const fs = require('node:fs') -const path = require('node:path') -const pathFile = path.join(__dirname, 'path.txt') -if (!fs.existsSync(pathFile)) { - throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') -} -const electronPath = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) -if (!fs.existsSync(electronPath)) { - throw new Error('Electron failed to install correctly, please delete node_modules/electron and try installing again') -} -module.exports = electronPath -` - ) -} + it('falls back to npm config when no rebuild or installer target is set', () => { + const projectDir = mkTempProject() -function writeFakeElectronGet( - projectDir, - { downloadRejects = false, logPartialStateBeforeInstall = false } = {} -) { - const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') - mkdirSync(getDir, { recursive: true }) - writeFileSync( - join(getDir, 'index.js'), - ` -const { appendFileSync, existsSync, mkdirSync, writeFileSync } = require('node:fs') -const { join } = require('node:path') -exports.downloadArtifact = async function downloadArtifact(details) { - if (${JSON.stringify(logPartialStateBeforeInstall)}) { - appendFileSync( - 'electron-get.log', - existsSync('node_modules/electron/dist') || existsSync('node_modules/electron/path.txt') - ? 'partial still present\\n' - : 'partial cleared\\n' - ) - } - appendFileSync('electron-get.log', 'download attempted\\n') - if (${JSON.stringify(downloadRejects)}) { - throw new Error('download failed') - } - mkdirSync(details.cacheRoot, { recursive: true }) - const artifactPath = join(details.cacheRoot, 'electron.zip') - writeFileSync(artifactPath, 'fake zip') - return artifactPath -} -` - ) -} + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { logTargetBeforeInstall: true }) + writeFakeElectronExtractor(projectDir, { createExecutable: true }) + writeFakeElectronRebuild(projectDir) -function writeFakeExtractZip(projectDir, { createExecutable }) { - const extractDir = join(projectDir, 'node_modules', 'electron', 'node_modules', 'extract-zip') - mkdirSync(extractDir, { recursive: true }) - writeFileSync( - join(extractDir, 'index.js'), - ` -const { mkdirSync, writeFileSync } = require('node:fs') -const { join } = require('node:path') -module.exports = async function extract(_zipPath, options) { - mkdirSync(join(options.dir, 'locales'), { recursive: true }) - if (${JSON.stringify(createExecutable)}) { - writeFileSync(join(options.dir, 'electron'), '') - writeFileSync(join(options.dir, 'version'), 'v41.5.0') - } -} -` - ) - chmodSync(join(extractDir, 'index.js'), 0o755) -} + // runRebuildScript defaults npm_config_platform=linux / npm_config_arch=x64. + const result = runRebuildScript(projectDir) -function writeFakeElectronRebuild(projectDir, { logPathEnv = null } = {}) { - const rebuildDir = join(projectDir, 'node_modules', '@electron', 'rebuild') - mkdirSync(rebuildDir, { recursive: true }) - writeFileSync(join(rebuildDir, 'package.json'), JSON.stringify({ type: 'module' })) - writeFileSync( - join(rebuildDir, 'index.js'), - logPathEnv - ? ` -import { appendFileSync } from 'node:fs' - -export async function rebuild(options) { - const logPath = process.env[${JSON.stringify(logPathEnv)}] - if (!logPath) { - return - } - appendFileSync( - logPath, - JSON.stringify({ - arch: options.arch, - electronVersion: options.electronVersion, - force: options.force, - ignoreModules: options.ignoreModules, - onlyModules: options.onlyModules, - platform: options.platform - }) + '\\n' - ) -} -` - : 'export async function rebuild() {}\n' - ) -} - -function writeFakeUsableElectronPackage(projectDir, { platform = 'linux' } = {}) { - writeFakeElectronPackage(projectDir) - const electronDir = join(projectDir, 'node_modules', 'electron') - const platformExecutable = platform === 'win32' ? 'electron.exe' : 'electron' - const electronPath = join(electronDir, 'dist', platformExecutable) - mkdirSync(join(electronDir, 'dist'), { recursive: true }) - writeFileSync(join(electronDir, 'path.txt'), platformExecutable) - writeFileSync(join(electronDir, 'dist', 'version'), 'v41.5.0') - if (platform === 'win32') { - copyFileSync(process.execPath, electronPath) - } else { - writeFileSync( - electronPath, - `#!/usr/bin/env node -const { spawnSync } = require('node:child_process') - -const result = spawnSync(process.execPath, process.argv.slice(2), { - cwd: process.cwd(), - env: process.env, - stdio: 'inherit' -}) - -if (result.error) { - console.error(result.error.message) - process.exit(1) -} - -process.exit(result.status ?? 0) -` - ) - chmodSync(electronPath, 0o755) - } -} - -function writeFakeNodePtyConptyPayload(projectDir, arch) { - const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(releaseDir, { recursive: true }) - writeFileSync(join(releaseDir, 'conpty.node'), 'native addon') - const sourceDir = join( - projectDir, - 'node_modules', - 'node-pty', - 'third_party', - 'conpty', - '0.1.0', - `win10-${arch}` - ) - mkdirSync(sourceDir, { recursive: true }) - writeFileSync(join(sourceDir, 'conpty.dll'), `conpty.dll ${arch}`) - writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`) -} - -function writeFakeLoadableNodePty( - projectDir, - { nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {} -) { - const nodePtyDir = join(projectDir, 'node_modules', 'node-pty') - mkdirSync(join(nodePtyDir, 'lib'), { recursive: true }) - writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n') - writeFileSync( - join(nodePtyDir, 'lib', 'utils.js'), - ` -exports.loadNativeModule = function loadNativeModule(nativeName) { - return { - dir: ${JSON.stringify(nativeDir)}, - module: { - nativeName, - ...(${JSON.stringify(ownsPtyJob)} - ? { - listJobProcessIds() {}, - terminateJob() {}, - assignCurrentProcessToJob() {} - } - : {}) + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'electron-get.log'), 'utf8')).toContain( + 'platform=linux arch=x64' + ) + } finally { + rmSync(projectDir, { recursive: true, force: true }) } - } -} -` - ) -} + }) -function writeFakeWindowsRegistry(projectDir) { - const registryDir = join(projectDir, 'node_modules', 'windows-native-registry') - mkdirSync(registryDir, { recursive: true }) - writeFileSync( - join(registryDir, 'index.js'), - 'exports.HK = { CU: 0x80000001 }; exports.getRegistryKey = () => ({})\n' - ) -} + it('repairs existing Electron path metadata without invoking the installer', () => { + const projectDir = mkTempProject() -function writeFakeWindowsProcessTree(projectDir) { - const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') - mkdirSync(processTreeDir, { recursive: true }) - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') -} + try { + writeFakeUsableElectronPackage(projectDir) + writeFakeElectronRebuild(projectDir) + rmSync(join(projectDir, 'node_modules/electron/path.txt')) -function writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir) { - const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree') - const nodeAddonApiDir = join(processTreeDir, 'node_modules', 'node-addon-api') - mkdirSync(nodeAddonApiDir, { recursive: true }) - writeFileSync(join(processTreeDir, 'package.json'), '{"dependencies":{"node-addon-api":"*"}}\n') - writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n') - writeFileSync(join(nodeAddonApiDir, 'package.json'), '{"name":"node-addon-api"}\n') - writeFileSync(join(nodeAddonApiDir, 'napi.h'), '// napi.h\n') - writeFileSync(join(nodeAddonApiDir, 'napi-inl.h'), '// napi-inl.h\n') - writeFileSync(join(nodeAddonApiDir, 'napi-inl.deprecated.h'), '// napi-inl.deprecated.h\n') -} + const result = runRebuildScript(projectDir, {}, ['--platform=linux', '--force']) -function writeNodePtyPatchFile(projectDir) { - mkdirSync(join(projectDir, 'config', 'patches'), { recursive: true }) - writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n') -} - -function writePatchedNodePtyBuildArtifacts(projectDir) { - const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release') - mkdirSync(buildDir, { recursive: true }) - if (process.platform === 'win32') { - writeFileSync(join(buildDir, 'conpty.node'), '') - mkdirSync(join(buildDir, 'conpty'), { recursive: true }) - writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '') - writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '') - return - } - writeFileSync(join(buildDir, 'pty.node'), '') - if (process.platform === 'darwin') { - writeFileSync(join(buildDir, 'spawn-helper'), '') - } -} + expect(result.status, result.stderr).toBe(0) + expect(readFileSync(join(projectDir, 'node_modules/electron/path.txt'), 'utf8')).toBe( + 'electron' + ) + expect(result.stdout).toContain('Repaired Electron path.txt -> electron') + expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) +})