diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index b2985a7aaad..72bd00aec0b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -89,9 +89,7 @@ jobs: # Why: install intentionally blocks Electron's package postinstall, but # some unit tests import `electron` under Node and require path.txt. - name: Install Electron package binary for tests - run: | - pnpm rebuild electron --pending - node -e "require('electron')" + run: node config/scripts/install-electron-package-binary.mjs - name: Test run: pnpm test diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs new file mode 100644 index 00000000000..d21186ccefc --- /dev/null +++ b/config/scripts/install-electron-package-binary.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto' +import { + createReadStream, + createWriteStream, + existsSync, + mkdtempSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync +} from 'node:fs' +import { createRequire } from 'node:module' +import { platform as osPlatform, tmpdir } from 'node:os' +import { dirname, resolve } from 'node:path' +import { Readable } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const projectDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..') +const electronPackageDir = resolve(projectDir, 'node_modules/electron') +const electronRequire = createRequire(resolve(electronPackageDir, 'package.json')) +const { version: electronVersion } = electronRequire('./package.json') +const extract = electronRequire('extract-zip') +const platformPath = getElectronPlatformPath() + +if (electronPackageLoads()) { + process.exit(0) +} + +// Why: PR tests run under system Node after native modules are rebuilt for +// 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.') +await installElectronPackageBinary() + +repairElectronPathFile() + +if (!electronPackageLoads()) { + logElectronInstallDiagnostics() + console.error('[electron-package] Electron package is still unavailable after install.') + process.exit(1) +} + +function electronPackageLoads() { + try { + require('electron') + return true + } catch { + return false + } +} + +function repairElectronPathFile() { + const electronExecutable = resolve(electronPackageDir, 'dist', platformPath) + if (!existsSync(electronExecutable)) { + return + } + + 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(`[electron-package] Repaired Electron path.txt -> ${platformPath}`) + } +} + +async function installElectronPackageBinary() { + const electronDistDir = resolve(electronPackageDir, 'dist') + const artifactName = getElectronArtifactName() + const tempDir = mkdtempSync(resolve(tmpdir(), 'orca-electron-')) + const zipPath = resolve(tempDir, artifactName) + + try { + await downloadElectronArtifact(artifactName, zipPath) + await verifyElectronArtifactChecksum(artifactName, zipPath) + + rmSync(electronDistDir, { recursive: true, force: true }) + await extract(zipPath, { dir: 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 }) + } +} + +async function downloadElectronArtifact(artifactName, zipPath) { + const artifactUrl = new URL(`v${electronVersion}/${artifactName}`, getElectronReleaseBaseUrl()) + console.log(`[electron-package] Downloading ${artifactUrl}`) + + const response = await fetch(artifactUrl) + if (!response.ok) { + throw new Error(`Failed to download ${artifactName}: ${response.status} ${response.statusText}`) + } + if (!response.body) { + throw new Error(`Failed to download ${artifactName}: empty response body`) + } + + await pipeline(Readable.fromWeb(response.body), createWriteStream(zipPath)) +} + +async function verifyElectronArtifactChecksum(artifactName, zipPath) { + if ( + process.env.electron_use_remote_checksums || + process.env.npm_config_electron_use_remote_checksums + ) { + return + } + + const expected = electronRequire('./checksums.json')[artifactName] + if (!expected) { + throw new Error(`Missing Electron checksum for ${artifactName}`) + } + + const hash = createHash('sha256') + for await (const chunk of createReadStream(zipPath)) { + hash.update(chunk) + } + const actual = hash.digest('hex') + if (actual !== expected) { + throw new Error(`Checksum mismatch for ${artifactName}: expected ${expected}, got ${actual}`) + } +} + +function getElectronArtifactName() { + return `electron-v${electronVersion}-${process.env.npm_config_platform || osPlatform()}-${ + process.env.npm_config_arch || process.arch + }.zip` +} + +function getElectronReleaseBaseUrl() { + const configuredMirror = process.env.ELECTRON_MIRROR || process.env.npm_config_electron_mirror + const baseUrl = configuredMirror || 'https://github.com/electron/electron/releases/download/' + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` +} + +function logElectronInstallDiagnostics() { + const electronDistDir = resolve(electronPackageDir, 'dist') + const pathFile = resolve(electronPackageDir, 'path.txt') + console.error('[electron-package] Electron install diagnostics:') + console.error(` packageDir=${electronPackageDir} exists=${existsSync(electronPackageDir)}`) + console.error(` distDir=${electronDistDir} exists=${existsSync(electronDistDir)}`) + console.error(` pathFile=${pathFile} exists=${existsSync(pathFile)}`) + console.error(` platformPath=${platformPath}`) + if (existsSync(electronDistDir)) { + console.error(` distEntries=${safeReaddir(electronDistDir).join(', ')}`) + } +} + +function safeReaddir(targetPath) { + try { + return readdirSync(targetPath).slice(0, 40) + } catch { + return [] + } +} + +function getElectronPlatformPath() { + const targetPlatform = process.env.npm_config_platform || osPlatform() + switch (targetPlatform) { + 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: ${targetPlatform}`) + } +} diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 8a52e7bb406..178c8706a8c 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -34,7 +34,10 @@ describe('Electron runtime package contract', () => { }) it('guards release publishing before electron-builder runs', () => { - const releaseWorkflow = readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8') + const releaseWorkflow = readFileSync( + join(projectDir, '.github/workflows/release-cut.yml'), + 'utf8' + ) const parsedWorkflow = parse(releaseWorkflow) const releaseCommands = new Map( parsedWorkflow.jobs.build.strategy.matrix.include.map(({ platform, release_command }) => [ @@ -47,7 +50,9 @@ describe('Electron runtime package contract', () => { for (const command of releaseCommands.values()) { expect(command).toContain('node config/scripts/ensure-native-runtime.mjs --runtime=electron') expect(command).toContain('electron-builder') - expect(command.indexOf('ensure-native-runtime')).toBeLessThan(command.indexOf('electron-builder')) + expect(command.indexOf('ensure-native-runtime')).toBeLessThan( + command.indexOf('electron-builder') + ) } expect(releaseCommands.get('mac')).toContain(' && ORCA_MAC_RELEASE=1 ') expect(releaseCommands.get('linux')).toContain(' && pnpm exec electron-builder ') @@ -55,4 +60,14 @@ describe('Electron runtime package contract', () => { '; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; pnpm exec electron-builder ' ) }) + + it('installs the Electron package binary in PR checks without changing native module ABI', () => { + const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') + const parsedWorkflow = parse(prWorkflow) + const installStep = parsedWorkflow.jobs.verify.steps.find( + (step) => step.name === 'Install Electron package binary for tests' + ) + + expect(installStep.run).toBe('node config/scripts/install-electron-package-binary.mjs') + }) }) diff --git a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx index 085612f6626..ab144841679 100644 --- a/src/renderer/src/components/right-sidebar/CommitArea.test.tsx +++ b/src/renderer/src/components/right-sidebar/CommitArea.test.tsx @@ -359,4 +359,20 @@ describe('ConflictSummaryCard', () => { expect(mergeMarkup).toContain('Abort merge') expect(rebaseMarkup).not.toContain('Abort merge') }) + + it('renders the Sparkles icon on the idle Resolve with AI button', () => { + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('Resolve with AI') + expect(markup).toContain('lucide-sparkles') + expect(markup).not.toMatch(/\blucide-sparkle(?!s)\b/) + }) }) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 8792fe14e33..66409f6c6fa 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -5811,7 +5811,7 @@ export function ConflictSummaryCard({ {isResolvingWithAI ? ( ) : ( - + )} Resolve with AI diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx index 90b847f2a5e..234a6b79e2e 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.test.tsx @@ -56,4 +56,12 @@ describe('MergeConflictNotice', () => { expect(markup).toBe('') }) + + it('renders the Sparkles icon on the idle Resolve with AI button', () => { + const markup = renderNotice(makePR()) + + expect(markup).toContain('Resolve with AI') + expect(markup).toContain('lucide-sparkles') + expect(markup).not.toMatch(/\blucide-sparkle(?!s)\b/) + }) }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx index a3f522843f1..8f64b1ab9cb 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel-content.tsx @@ -13,7 +13,7 @@ import { Check, MessageSquare, ChevronDown, - Sparkle, + Sparkles, RefreshCw, Wrench } from 'lucide-react' @@ -95,7 +95,7 @@ function ResolveConflictsWithAIButton({ {isResolvingWithAI ? ( ) : ( - + )} Resolve with AI