mirror of
https://github.com/stablyai/orca.git
synced 2026-11-04 02:57:36 +00:00
* fix(rate-limits): spawn the Codex usage probe off the main thread On Windows, creating a cold standalone codex.exe can spend seconds in code integrity checks on the calling thread. The usage probe runs on window focus, so that froze the UI. The probe's child is now created and piped on a worker thread and proxied back to the main thread. Fixes #27362 * refactor(rate-limits): type the worker's children through @orca/process-host * fix(rate-limits): kill an orphaned Codex probe by pid when its worker dies Once the worker thread that owns the probe's child is gone, kill() can no longer reach the child through it. The proxy now falls back to a pid-addressed kill; the Codex probe uses the app-server tree kill so a node launcher's codex.exe child goes too, while SIGTERM stays a graceful root signal. * refactor(build): move the main build's entry map out of electron.vite.config.ts The new worker entry pushed electron.vite.config.ts past the 300-line limit. The Rollup input map for the main build now lives in its own module. * refactor(rate-limits): drop the orphan pid kill and trim the worker child-process stand-in A worker thread's death closes its children's pipes, so an abandoned Codex probe already gets stdin EOF, its graceful stop. The pid-addressed orphan kill was redundant with that and with terminateCodexProbeChild's own gated Windows tree kill. Its hard kill also bypassed the tree-kill gate's recycled-pid refusal by turning the handle-addressed fallback into a pid kill. - Remove orphan-pid-kill, killOrphanedCodexProbe, the injected killOrphan, and the orphaned flag. kill() now reports false once a child is no longer tracked. - Process-creation failure is one 'spawn-error' event that the stand-in expands into error/exit/close, replacing the duplicated triple and the exported exit code. Stdin errors arrive as child errors, as the probe already treated them. - Fold both suites into one over a real MessageChannel and real children. Add a real-worker test showing that terminating or crashing the worker lets the child exit on stdin EOF. * test(rate-limits): run the worker child-process suite under Node The suite asserts Node behavior that Electron main relies on: a failed spawn closes with a null signal, and a dead worker thread closes its children's pipes. Bun reports an undefined signal and, on Linux, leaves the pipes open, so the Bun lane failed on runtime differences, not on the product code. * test(rate-limits): count a reaped-by-nobody zombie as an exited child On Linux and macOS a child whose worker thread died gets stdin EOF and exits, but libuv reaps a child only through the handle on the loop that spawned it. Once that loop is gone, nothing waits on the pid, so it stays a zombie and kill(pid, 0) still succeeds. The real-worker tests now treat a zombie as exited, and still require the child's EOF marker. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
659 lines
26 KiB
JavaScript
659 lines
26 KiB
JavaScript
import { existsSync } from 'node:fs'
|
|
import { chmod, lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join } from 'node:path'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { writeMobileWebBundleFixtureTree } from './mobile-web-bundle-fixture-tree.mjs'
|
|
|
|
const REPO_ROOT = join(import.meta.dirname, '..', '..')
|
|
const SRC_MAIN_DIR = join(REPO_ROOT, 'src', 'main')
|
|
const MAIN_BUILD_ENTRY_POINTS_PATH = join(
|
|
REPO_ROOT,
|
|
'config',
|
|
'build-plugins',
|
|
'main-build-entry-points.ts'
|
|
)
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const electronBuilderConfig = require('../electron-builder.config.cjs')
|
|
const { copyFiles, FileMatcher } = require('app-builder-lib/out/fileMatcher')
|
|
const FpmTarget = require('app-builder-lib/out/targets/FpmTarget').default
|
|
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
|
|
|
|
describe('electron-builder config', () => {
|
|
it('keeps the packaged app identity aligned with local-build validation', () => {
|
|
expect(electronBuilderConfig.appId).toBe(
|
|
require('../../src/shared/local-build-compatibility-contract.json').appId
|
|
)
|
|
})
|
|
|
|
it('excludes repo-only source trees from app.asar', () => {
|
|
expect(electronBuilderConfig.files).toEqual(
|
|
expect.arrayContaining([
|
|
'!src{,/**/*}',
|
|
'!config{,/**/*}',
|
|
'!docs{,/**/*}',
|
|
'!mobile{,/**/*}',
|
|
'!native{,/**/*}',
|
|
'!skills{,/**/*}',
|
|
'!skill-guides{,/**/*}',
|
|
'!skill-stubs{,/**/*}',
|
|
'!resources/skills/**',
|
|
'!tests{,/**/*}',
|
|
'!examples{,/**/*}',
|
|
'!pr-evidence{,/**/*}',
|
|
'!notes{,/**/*}',
|
|
'!{.claude,.grok,.agents,.codex}{,/**/*}',
|
|
'!Casks{,/**/*}',
|
|
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
|
|
'!out/**/*.test.js',
|
|
'!resources/plugins/launch/**'
|
|
])
|
|
)
|
|
})
|
|
|
|
it('keeps release build staging out of app.asar while preserving runtime output', () => {
|
|
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
|
matcher.prependPattern('**/*')
|
|
const isPacked = matcher.createFilter()
|
|
expect(isPacked(join('/app', '.build'), { isDirectory: () => true })).toBe(false)
|
|
for (const stagingPath of [
|
|
'.build/release-javascript/release-javascript.tar.gz',
|
|
'.build/release-javascript/manifest.json',
|
|
'.build/release-javascript-123/out/main/index.js'
|
|
]) {
|
|
expect(isPacked(join('/app', stagingPath), { isDirectory: () => false })).toBe(false)
|
|
}
|
|
for (const runtimePath of [
|
|
'out/main/index.js',
|
|
'out/cli/index.js',
|
|
'out/renderer/index.html'
|
|
]) {
|
|
expect(isPacked(join('/app', runtimePath), { isDirectory: () => false })).toBe(true)
|
|
}
|
|
})
|
|
|
|
it('keeps local agent tooling out of app.asar', () => {
|
|
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
|
matcher.prependPattern('**/*')
|
|
const isPacked = matcher.createFilter()
|
|
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
|
|
|
for (const toolingPath of [
|
|
'.grok/skills/review-and-submit/review-and-submit/SKILL.md',
|
|
'.claude/skills/review-and-submit/review-and-submit/SKILL.md',
|
|
'.agents/skills/electron/SKILL.md',
|
|
'.codex/sessions/session.json'
|
|
]) {
|
|
expect(packs(toolingPath)).toBe(false)
|
|
}
|
|
expect(packs('out/main/index.js')).toBe(true)
|
|
})
|
|
|
|
it.each(['file', 'directory'])('keeps a root notes %s out of app.asar', async (kind) => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-packaging-notes-'))
|
|
const source = join(root, 'app')
|
|
const destination = join(root, 'selected')
|
|
const runtimePaths = [
|
|
'package.json',
|
|
'out/main/index.js',
|
|
'out/renderer/index.html',
|
|
'out/cli/index.js',
|
|
'out/shared/index.js',
|
|
'out/main/notes/index.js',
|
|
'out/renderer/assets/notes/help.md',
|
|
'resources/notes/help.md',
|
|
'notes.txt'
|
|
]
|
|
const notesPaths =
|
|
kind === 'file'
|
|
? ['notes']
|
|
: [
|
|
'notes/build.log',
|
|
'notes/installed-orca-backup/Orca.exe',
|
|
'notes/installed-orca-backup/resources/app.asar',
|
|
'notes/orca-windows-setup.exe',
|
|
'notes/.recovery/state.json'
|
|
]
|
|
try {
|
|
for (const fixturePath of [...runtimePaths, ...notesPaths]) {
|
|
const file = join(source, fixturePath)
|
|
await mkdir(dirname(file), { recursive: true })
|
|
await writeFile(file, 'synthetic fixture\n')
|
|
}
|
|
if (kind === 'directory') {
|
|
await mkdir(join(source, 'notes', 'empty'))
|
|
}
|
|
const matcher = new FileMatcher(
|
|
source,
|
|
destination,
|
|
(value) => value,
|
|
electronBuilderConfig.files
|
|
)
|
|
// copyFiles adds the default include and prunes excluded directories during traversal.
|
|
await copyFiles([matcher])
|
|
for (const runtimePath of runtimePaths) {
|
|
expect(await readFile(join(destination, runtimePath), 'utf8')).toBe('synthetic fixture\n')
|
|
}
|
|
const isPacked = matcher.createFilter()
|
|
for (const notesPath of new Set(['notes', ...notesPaths])) {
|
|
const file = join(source, notesPath)
|
|
expect(isPacked(file, await lstat(file)), notesPath).toBe(false)
|
|
}
|
|
expect(existsSync(join(destination, 'notes'))).toBe(false)
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
|
|
// anything without an explicit `!` entry — examples/ landed without one and shipped
|
|
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
|
|
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
|
|
it('keeps plugin authoring examples out of app.asar', () => {
|
|
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
|
// copyFiles() prepends this itself once the pattern list is all-negation.
|
|
matcher.prependPattern('**/*')
|
|
const isPacked = matcher.createFilter()
|
|
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
|
|
|
for (const authoringOnly of [
|
|
'examples/plugins/hostile-panel/panel.html',
|
|
'examples/plugins/hostile-panel/orca-plugin.json',
|
|
'examples/plugins/hello-orca/main.mjs',
|
|
'examples/plugins/hello-orca/orca-plugin.json'
|
|
]) {
|
|
expect(packs(authoringOnly)).toBe(false)
|
|
}
|
|
// The negation stays anchored at the app root, so nested `examples` segments still ship.
|
|
expect(packs('out/main/examples/index.js')).toBe(true)
|
|
})
|
|
|
|
// Why: out/electron-dev holds `pnpm dev`'s cached Electron.app copies (~270MB per branch).
|
|
// CI never creates it, so only a local package would have hit this -- silently, as bulk.
|
|
it('keeps cached dev Electron bundles out of app.asar', () => {
|
|
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
|
matcher.prependPattern('**/*')
|
|
const isPacked = matcher.createFilter()
|
|
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
|
|
|
for (const devBundlePath of [
|
|
'out/electron-dev/1a2b3c4d5e6f/Orca: dev.app/Contents/MacOS/Electron',
|
|
'out/electron-dev/1a2b3c4d5e6f/orca-dev-electron-app.json'
|
|
]) {
|
|
expect(packs(devBundlePath)).toBe(false)
|
|
}
|
|
// The real build outputs sit beside it under out/ and must still ship.
|
|
expect(packs('out/main/index.js')).toBe(true)
|
|
expect(packs('out/renderer/index.html')).toBe(true)
|
|
})
|
|
|
|
// Why: an AV verdict on the bundled relay.js used to take app.asar with it as a
|
|
// compound object, gutting the install (#20966). resources/relay is the only copy
|
|
// a packaged build resolves, so the asar copy was 14MB of pure blast radius.
|
|
it('keeps the relay bundles out of app.asar and ships them only through extraResources', () => {
|
|
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
|
|
matcher.prependPattern('**/*')
|
|
const isPacked = matcher.createFilter()
|
|
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
|
|
|
|
for (const relayPath of [
|
|
'out/relay/linux-x64/relay.js',
|
|
'out/relay/win32-x64/relay.js',
|
|
'out/relay/darwin-arm64/relay-watcher.js',
|
|
'out/relay/wsl/wsl-agent-hook-relay.js'
|
|
]) {
|
|
expect(packs(relayPath)).toBe(false)
|
|
}
|
|
|
|
for (const platform of ['mac', 'linux', 'win']) {
|
|
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
|
|
from: 'out/relay',
|
|
to: 'relay'
|
|
})
|
|
}
|
|
})
|
|
|
|
it('keeps runtime resources available through extraResources', () => {
|
|
const bundledPluginResources = expect.objectContaining({
|
|
from: 'resources/plugins/launch',
|
|
to: 'plugins/launch'
|
|
})
|
|
for (const platform of ['mac', 'linux', 'win']) {
|
|
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
|
|
from: 'resources/skills',
|
|
to: 'skills'
|
|
})
|
|
expect(electronBuilderConfig[platform].extraResources).toEqual(
|
|
expect.arrayContaining([bundledPluginResources])
|
|
)
|
|
}
|
|
expect(electronBuilderConfig.mac.extraResources).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'native/computer-use-macos/.build/release/Orca Computer Use.app',
|
|
to: 'Orca Computer Use.app'
|
|
})
|
|
])
|
|
)
|
|
expect(electronBuilderConfig.linux.extraResources).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'native/computer-use-linux/runtime.py',
|
|
to: 'computer-use-linux/runtime.py'
|
|
})
|
|
])
|
|
)
|
|
expect(electronBuilderConfig.win.extraResources).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'native/computer-use-windows/runtime.ps1',
|
|
to: 'computer-use-windows/runtime.ps1'
|
|
}),
|
|
expect.objectContaining({
|
|
from: 'native/windows-cli-launcher/.build/orca.exe',
|
|
to: 'bin/orca.exe'
|
|
})
|
|
])
|
|
)
|
|
})
|
|
|
|
it('ships one macOS serve-sim package through the runtime closure', () => {
|
|
const serveSimResources = electronBuilderConfig.mac.extraResources.filter((resource) =>
|
|
[join('node_modules', 'serve-sim'), 'serve-sim'].includes(resource.to)
|
|
)
|
|
|
|
expect(serveSimResources).toEqual([
|
|
expect.objectContaining({ to: join('node_modules', 'serve-sim') })
|
|
])
|
|
})
|
|
|
|
// Why: serve-sim's addon is a Mach-O, and Windows signing rejects every *.node that is not PE.
|
|
it('keeps serve-sim out of the Windows and Linux runtime closures', () => {
|
|
const {
|
|
PACKAGED_RUNTIME_PACKAGE_ROOTS,
|
|
createPackagedRuntimeNodeModuleResources
|
|
} = require('../packaged-runtime-node-modules.cjs')
|
|
expect(PACKAGED_RUNTIME_PACKAGE_ROOTS).not.toContain('serve-sim')
|
|
const serveSimTarget = join('node_modules', 'serve-sim')
|
|
expect(createPackagedRuntimeNodeModuleResources('linux').map((r) => r.to)).not.toContain(
|
|
serveSimTarget
|
|
)
|
|
expect(electronBuilderConfig.linux.extraResources.map((r) => r.to)).not.toContain(
|
|
serveSimTarget
|
|
)
|
|
expect(electronBuilderConfig.win.extraResources.map((r) => r.to)).not.toContain(serveSimTarget)
|
|
})
|
|
|
|
// Why: the Windows CLI shim is delivered only via extraResources to
|
|
// resources/bin/orca.cmd (beside the native resources/bin/orca.exe). If the
|
|
// source tree is also packed into app.asar it gets extracted by
|
|
// asarUnpack:['resources/**'] to app.asar.unpacked/resources/win32/bin/orca.cmd,
|
|
// a duplicate with no adjacent orca.exe that fails to launch (#7351).
|
|
it('keeps the Windows CLI shim source tree out of app.asar', () => {
|
|
expect(electronBuilderConfig.files).toEqual(
|
|
expect.arrayContaining(['!resources/win32{,/**/*}'])
|
|
)
|
|
// Regression guard: the working shim must still ship via extraResources.
|
|
expect(electronBuilderConfig.win.extraResources).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'resources/win32/bin/orca.cmd',
|
|
to: 'bin/orca.cmd'
|
|
})
|
|
])
|
|
)
|
|
})
|
|
|
|
// Why: on macOS 26 UNUserNotificationCenter aborts for executables launched
|
|
// from Contents/Resources, so the helper must ship in Contents/MacOS (#7929).
|
|
it('ships the mac notification-status helper in Contents/MacOS, not Resources', () => {
|
|
expect(electronBuilderConfig.mac.extraFiles).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'native/notification-status-macos/.build/release/orca-notification-status',
|
|
to: 'MacOS/orca-notification-status'
|
|
})
|
|
])
|
|
)
|
|
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ to: 'orca-notification-status' })])
|
|
)
|
|
})
|
|
|
|
it('ships the mac keyboard-layout helper in Contents/MacOS, not Resources', () => {
|
|
expect(electronBuilderConfig.mac.extraFiles).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
from: 'native/keyboard-layout-macos/.build/release/orca-keyboard-layout',
|
|
to: 'MacOS/orca-keyboard-layout'
|
|
})
|
|
])
|
|
)
|
|
expect(electronBuilderConfig.mac.extraResources).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ to: 'orca-keyboard-layout' })])
|
|
)
|
|
})
|
|
|
|
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
|
|
expect(electronBuilderConfig.asarUnpack).toEqual(
|
|
expect.arrayContaining([
|
|
'out/package.json',
|
|
'out/cli/**',
|
|
'out/shared/**',
|
|
'out/main/claude-accounts/keychain.js'
|
|
])
|
|
)
|
|
})
|
|
|
|
// Why: without the unpacked entry the watcher client silently falls back to
|
|
// in-process @parcel/watcher, reintroducing the #7547 main-process crash.
|
|
it('unpacks the forked parcel-watcher process entry', () => {
|
|
expect(electronBuilderConfig.asarUnpack).toEqual(
|
|
expect.arrayContaining(['out/main/parcel-watcher-process-entry.js'])
|
|
)
|
|
})
|
|
|
|
it('unpacks the replaceable WSL transcript filesystem process entry', async () => {
|
|
const entryFilename = 'wsl-transcript-fs-process-entry.js'
|
|
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
|
|
|
|
const entryPoints = await readFile(MAIN_BUILD_ENTRY_POINTS_PATH, 'utf8')
|
|
expect(entryPoints).toMatch(
|
|
new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)
|
|
)
|
|
})
|
|
|
|
// Why: the CLI runs this entry under ELECTRON_RUN_AS_NODE, which cannot read
|
|
// app.asar, so a packed copy sends every `orca serve` to Electron (#27307).
|
|
it('unpacks the orcad serve-selection entry the CLI runs', async () => {
|
|
const sharedSource = await readFile(
|
|
join(REPO_ROOT, 'src', 'shared', 'orcad-local-serve-selection.ts'),
|
|
'utf8'
|
|
)
|
|
const entryName = sharedSource.match(/ORCAD_LOCAL_SERVE_SELECTION_ENTRY = '([^']+)'/)?.[1]
|
|
expect(entryName).toBeDefined()
|
|
|
|
const matcher = new FileMatcher(
|
|
'/app',
|
|
'/dest',
|
|
(value) => value,
|
|
electronBuilderConfig.asarUnpack
|
|
)
|
|
const isUnpacked = matcher.createFilter()
|
|
expect(
|
|
isUnpacked(join('/app', 'out', 'main', `${entryName}.js`), { isDirectory: () => false })
|
|
).toBe(true)
|
|
})
|
|
|
|
// Why: the scanner service is forked with ELECTRON_RUN_AS_NODE, so asar is
|
|
// invisible to it and a packed worker entry fails closed — dropping every
|
|
// OpenCode session in packaged builds while dev stays green. Three legs must
|
|
// agree on the filename, so all three are read rather than hardcoded.
|
|
it('unpacks the foreign SQLite reader entry the scanner service runs OpenCode reads on', async () => {
|
|
const entryPathSource = await readFile(
|
|
join(SRC_MAIN_DIR, 'foreign-sqlite-readers', 'foreign-sqlite-reader-entry-path.ts'),
|
|
'utf8'
|
|
)
|
|
const entryFilename = entryPathSource.match(
|
|
/FOREIGN_SQLITE_READER_ENTRY_FILENAME = '([^']+)'/
|
|
)?.[1]
|
|
|
|
expect(entryFilename).toBeDefined()
|
|
expect(electronBuilderConfig.asarUnpack).toContain(`out/main/${entryFilename}`)
|
|
|
|
// Why: the emitted path comes from the rollup input key under
|
|
// entryFileNames '[name].js', not from the source filename — renaming the
|
|
// key alone would leave the other two legs agreeing on a file that no
|
|
// longer exists.
|
|
const viteConfig = await readFile(join(REPO_ROOT, 'electron.vite.config.ts'), 'utf8')
|
|
expect(viteConfig).toContain("entryFileNames: '[name].js'")
|
|
const entryPoints = await readFile(MAIN_BUILD_ENTRY_POINTS_PATH, 'utf8')
|
|
expect(entryPoints).toMatch(
|
|
new RegExp(`'${entryFilename.replace(/\.js$/, '')}':\\s*resolve\\(`)
|
|
)
|
|
})
|
|
|
|
it('keeps the worker-thread hang watchdog inside app.asar', () => {
|
|
expect(electronBuilderConfig.asarUnpack).not.toContain(
|
|
'out/main/main-thread-hang-watchdog-entry.js'
|
|
)
|
|
})
|
|
|
|
it('uses the multi-size icon source for Linux packages', () => {
|
|
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
|
|
})
|
|
|
|
it('matches the Linux desktop entry to Electron window class', () => {
|
|
expect(electronBuilderConfig.linux.desktop.entry.StartupWMClass).toBe('orca')
|
|
})
|
|
|
|
it('uses the release artifact set as local Linux targets without changing existing names', () => {
|
|
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb', 'rpm'])
|
|
expect(electronBuilderConfig.toolsets).toEqual({ appimage: '1.0.3' })
|
|
expect(electronBuilderConfig.appImage.artifactName).toBe('orca-linux.${ext}')
|
|
expect(electronBuilderConfig.deb.artifactName).toBe('orca-ide_${version}_${arch}.${ext}')
|
|
expect(electronBuilderConfig.rpm).toMatchObject({
|
|
packageName: 'orca-ide',
|
|
artifactName: 'orca-ide-${version}.${arch}.${ext}'
|
|
})
|
|
})
|
|
|
|
it('retains electron-builder runtime dependencies in deb and rpm packages', () => {
|
|
for (const target of ['deb', 'rpm']) {
|
|
const dependencies = electronBuilderConfig[target].depends
|
|
expect(dependencies).toEqual(
|
|
expect.arrayContaining(FpmTarget.prototype.getDefaultDepends(target))
|
|
)
|
|
expect(new Set(dependencies).size).toBe(dependencies.length)
|
|
}
|
|
})
|
|
|
|
it('validates each AppImage before electron-builder publishes it', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-appimage-'))
|
|
try {
|
|
const appImage = join(root, 'orca-linux.AppImage')
|
|
await writeFile(appImage, 'not an ELF')
|
|
await chmod(appImage, 0o755)
|
|
|
|
expect(() =>
|
|
electronBuilderConfig.artifactBuildCompleted({ file: appImage, arch: 1 })
|
|
).toThrow(/ELF header is outside/)
|
|
expect(() =>
|
|
electronBuilderConfig.artifactBuildCompleted({ file: join(root, 'orca-ide.deb') })
|
|
).not.toThrow()
|
|
} finally {
|
|
await rm(root, { recursive: true, force: true })
|
|
}
|
|
})
|
|
it('uses a distinct AppImage name for Linux arm64 release uploads', () => {
|
|
const configPath = require.resolve('../electron-builder.config.cjs')
|
|
const original = process.env.ORCA_LINUX_ARM64_RELEASE
|
|
try {
|
|
delete require.cache[configPath]
|
|
process.env.ORCA_LINUX_ARM64_RELEASE = '1'
|
|
expect(require('../electron-builder.config.cjs').appImage.artifactName).toBe(
|
|
'orca-linux-arm64.${ext}'
|
|
)
|
|
} finally {
|
|
if (original === undefined) {
|
|
delete process.env.ORCA_LINUX_ARM64_RELEASE
|
|
} else {
|
|
process.env.ORCA_LINUX_ARM64_RELEASE = original
|
|
}
|
|
delete require.cache[configPath]
|
|
require('../electron-builder.config.cjs')
|
|
}
|
|
})
|
|
|
|
it('overrides packaged semver only for local macOS builds', () => {
|
|
const configPath = require.resolve('../electron-builder.config.cjs')
|
|
const original = process.env.ORCA_LOCAL_BUILD_VERSION
|
|
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
|
try {
|
|
delete require.cache[configPath]
|
|
delete process.env.ORCA_MAC_RELEASE
|
|
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc'
|
|
expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({
|
|
version: '1.4.159-rc.0.local.123.abc'
|
|
})
|
|
} finally {
|
|
if (originalMacRelease === undefined) {
|
|
delete process.env.ORCA_MAC_RELEASE
|
|
} else {
|
|
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
|
}
|
|
if (original === undefined) {
|
|
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
|
} else {
|
|
process.env.ORCA_LOCAL_BUILD_VERSION = original
|
|
}
|
|
delete require.cache[configPath]
|
|
require('../electron-builder.config.cjs')
|
|
}
|
|
})
|
|
|
|
it('never applies local semver to release packaging', () => {
|
|
const configPath = require.resolve('../electron-builder.config.cjs')
|
|
const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION
|
|
const originalMacRelease = process.env.ORCA_MAC_RELEASE
|
|
try {
|
|
delete require.cache[configPath]
|
|
process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc'
|
|
process.env.ORCA_MAC_RELEASE = '1'
|
|
expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined()
|
|
} finally {
|
|
if (originalLocalVersion === undefined) {
|
|
delete process.env.ORCA_LOCAL_BUILD_VERSION
|
|
} else {
|
|
process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion
|
|
}
|
|
if (originalMacRelease === undefined) {
|
|
delete process.env.ORCA_MAC_RELEASE
|
|
} else {
|
|
process.env.ORCA_MAC_RELEASE = originalMacRelease
|
|
}
|
|
delete require.cache[configPath]
|
|
require('../electron-builder.config.cjs')
|
|
}
|
|
})
|
|
|
|
it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
|
|
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
|
|
expect(electronBuilderConfig.npmRebuild).toBe(true)
|
|
})
|
|
|
|
// Why: the .deb/.rpm update-recovery path keys entirely off the resources/package-type marker that
|
|
// app-builder-lib's FpmTarget writes. If packaging silently stops shipping an fpm target, or adds
|
|
// one the recovery path does not cover, getLinuxRootPackageType() returns null, autoInstallOnAppQuit
|
|
// quietly goes back to true, and no unit test notices.
|
|
describe('linux root-package update recovery contract', () => {
|
|
// FpmTarget writes resources/package-type only for targets it supports auto-update for.
|
|
const MARKER_TARGETS = new Set(['deb', 'rpm', 'pacman'])
|
|
const RECOVERABLE_TARGETS = new Set(['deb', 'rpm'])
|
|
const linuxTargets = electronBuilderConfig.linux.target.map((entry) =>
|
|
typeof entry === 'string' ? entry : entry.target
|
|
)
|
|
|
|
it('still ships an AppImage plus at least one root-package target', () => {
|
|
expect(linuxTargets).toContain('AppImage')
|
|
expect(linuxTargets.some((target) => MARKER_TARGETS.has(target))).toBe(true)
|
|
})
|
|
|
|
it('ships no root-package target the recovery path cannot recover', () => {
|
|
const unrecoverable = linuxTargets.filter(
|
|
(target) => MARKER_TARGETS.has(target) && !RECOVERABLE_TARGETS.has(target)
|
|
)
|
|
expect(unrecoverable).toEqual([])
|
|
})
|
|
|
|
it('accepts exactly the markers electron-updater maps to a root-package updater', async () => {
|
|
const source = await readFile(
|
|
new URL('../../src/main/linux-update-package-type.ts', import.meta.url),
|
|
'utf8'
|
|
)
|
|
for (const target of linuxTargets.filter((entry) => RECOVERABLE_TARGETS.has(entry))) {
|
|
expect(source).toContain(`value === '${target}'`)
|
|
}
|
|
})
|
|
|
|
it('keeps the pinned FpmTarget overwrite for configured deb and rpm artifacts', async () => {
|
|
const source = await readFile(
|
|
require.resolve('app-builder-lib/out/targets/FpmTarget'),
|
|
'utf8'
|
|
)
|
|
|
|
expect(source).toContain('path.join(resourceDir, "package-type"), target')
|
|
for (const target of RECOVERABLE_TARGETS) {
|
|
expect(electronBuilderConfig[target]).toBeDefined()
|
|
}
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('arch-aware packaging guard', () => {
|
|
// electron-builder Arch enum: ia32=0, x64=1, armv7l=2, arm64=3.
|
|
const HOST_ARCH = process.arch === 'arm64' ? 3 : 1
|
|
const OTHER_ARCH = process.arch === 'arm64' ? 1 : 3
|
|
const OTHER_ARCH_NAME = process.arch === 'arm64' ? 'x64' : 'arm64'
|
|
const SHERPA_PLATFORM = process.platform === 'win32' ? 'win' : process.platform
|
|
const otherSherpa = `sherpa-onnx-${SHERPA_PLATFORM}-${OTHER_ARCH_NAME}`
|
|
|
|
// beforePack also hash-verifies the mobile web bundle, which the unit-test job never builds.
|
|
// Point it at a real bundle built into a temp dir: these tests are about the native-variant
|
|
// guard, and the bundle guard has its own suite.
|
|
let scratch
|
|
let bundleDir
|
|
beforeAll(async () => {
|
|
scratch = await mkdtemp(join(tmpdir(), 'orca-electron-builder-guard-'))
|
|
bundleDir = join(scratch, 'mobile-web')
|
|
await writeMobileWebBundleFixtureTree({ outDir: bundleDir })
|
|
})
|
|
afterAll(async () => {
|
|
await rm(scratch, { recursive: true, force: true })
|
|
})
|
|
|
|
const packHost = (arch) =>
|
|
electronBuilderConfig.beforePack(
|
|
{ electronPlatformName: process.platform, arch },
|
|
bundleDir,
|
|
() => undefined
|
|
)
|
|
|
|
it('allows packaging the host platform and architecture', () => {
|
|
expect(() => packHost(HOST_ARCH)).not.toThrow()
|
|
})
|
|
|
|
it('requires the other architecture natives to be installed', () => {
|
|
const otherSherpaInstalled = existsSync(
|
|
join(REPO_ROOT, 'node_modules', otherSherpa, 'package.json')
|
|
)
|
|
const otherSherpaExpected = Object.hasOwn(
|
|
require('../../package.json').optionalDependencies,
|
|
otherSherpa
|
|
)
|
|
if (otherSherpaExpected && !otherSherpaInstalled) {
|
|
expect(() => packHost(OTHER_ARCH)).toThrow(otherSherpa)
|
|
expect(() => packHost(OTHER_ARCH)).toThrow('pnpm install:release')
|
|
expect(() => packHost(HOST_ARCH)).not.toThrow()
|
|
} else {
|
|
expect(() => packHost(OTHER_ARCH)).not.toThrow()
|
|
}
|
|
})
|
|
|
|
it('requires installed Windows addons for Windows packaging', () => {
|
|
const windowsAddon = electronBuilderConfig.win.extraResources.some(
|
|
(resource) => resource.to === join('node_modules', '@vscode', 'windows-process-tree')
|
|
)
|
|
const packWindows = () =>
|
|
electronBuilderConfig.beforePack({ electronPlatformName: 'win32', arch: 1 }, bundleDir)
|
|
if (process.platform === 'win32' || windowsAddon) {
|
|
expect(packWindows).not.toThrow()
|
|
} else {
|
|
expect(packWindows).toThrow('@vscode/windows-process-tree')
|
|
expect(packWindows).toThrow('Windows packaging requires a Windows host')
|
|
}
|
|
})
|
|
})
|