Files
orca/config/scripts/electron-builder-config.test.mjs
T
Brennan Benson 68fca0b076 Add safe skill freshness detection and update rail (#8637)
* Add safe skill freshness detection

* Accept observed copy-mode rail outcomes

* chore(skills): regenerate snapshot artifacts for the merged guide content

The rebase onto main picked up the reviewed guide fixes (#8624), so the
current manifest hashes and a new appended snapshot generation must
match those bytes; the registry keeps all prior snapshots so existing
installs classify as outdated rather than unrecognized.

* fix(skills): canonicalize snapshot file order and guard released history

Historical snapshots kept git ls-tree byte-order while the working-tree
walk and runtime observation use the sorted depth-first order, so any
future multi-file skill would misclassify older installs as unrecognized
and churn spurious registry revisions; all producers now share one
canonical order (no digest changes for today's single-file packages).
Also rejects executable files from shipped skills (Windows observation
cannot see execute bits, which would misclassify pristine Windows
installs) and adds an explicit append-only invariant for released
snapshots so a generation-logic change cannot rewrite them silently.

* fix(skills): throttle focus rescans and correct self-blocked placement copy

Every window focus re-read and re-hashed all installed packages, and the
nudge and panel each forced their own trailing rescan for one event; a
15s cooldown plus a shared invalidation latch keep one bounded scan per
event while install-change events stay immediate. Bundle artifacts are
now loaded once per run instead of re-parsed on every scan. A read-only
or otherwise unsupported outdated placement now explains that it blocks
itself instead of blaming a phantom sibling placement; the supported
topology set moved to shared so eligibility and copy cannot drift.

* feat(skills): move freshness surfacing to a lingering toast and update modal

The Skills page has been unreachable since its toolbox menu entry was
removed (#4535), so surfacing freshness there buried the feature behind
its own nudge. The nudge now lingers until acted on (ignoring it records
nothing; only the explicit close persists dismissal keys) and opens an
update modal hosting the pre-filled editable terminal, an honest
current/blocked summary, and the per-placement rows in a collapsed
Details section. A compact 'Check for skill updates' row in CLI settings
is the manual re-entry point. Skills page restored to main; design-doc
surfacing section records the venue decision.

* fix(skills): mount update dialog inside the link-routing provider and fold freshness into the setup rails

The dialog hosts a live terminal pane that requires the link-routing
preference context; mounted outside the provider it crashed the renderer
the moment an eligible update existed (caught by live QA — unit tests
mock the terminal). It now mounts inside the provider behind its own
recoverable boundary.

The separate 'Check for skill updates' settings button is gone: the
setup rails' own pill now carries freshness (Update available / Up to
date, falling back to Installed for blocked or unrecognized copies and
for non-local runtimes the local-only scan cannot vouch for), and
Re-check refreshes both installation detection and the freshness
inventory. Wired for the CLI, Orchestration, Computer Use, and
Per-Workspace Environments rails.

* fix(skills): use the sleek scrollbar style in the update dialog

* chore(skills): regenerate manifest for merged main (v1.4.142-rc.1)

Main advanced to 1.4.142-rc.1 with a v1.4.141 release, so the embedded
appVersion and release mapping were stale on the PR's merged tree. Only
appVersion and the new release entry change; no snapshot digests move
(released history preserved).

* fix(skills): bound and batch freshness work

* fix(skills): harden freshness integrity checks

* fix(skills): accept observed copy topology outcomes

* chore(skills): regenerate manifest for current main

* fix(skills): preserve update terminal lifecycle

* chore(skills): regenerate manifest for current main

* fix(skills): fail closed on stale freshness scans

* chore(skills): regenerate manifest for current main

* fix(skills): preserve freshness safety under focus churn

* feat(skills): group the update modal by skill with plain-language status

The Update skills modal now lists only skills that will update or that can't
(with why), grouped by skill with their install locations nested underneath —
no more one row per placement.

- Statuses collapse to "Update available" / "Can't update" at the skill level.
- A location's problem is a chip (Duplicate, Unrecognized, Inaccessible, Read
  only, In a repo, External/Broken link, Plugin cache) with a hover tooltip
  that explains what it means for the user and what to do.
- Up-to-date, unrecognized-only, and unreadable-only skills are hidden; a
  current/unrecognized/etc. location only appears when it explains a shown skill.
- Copy is de-jargoned (drops "copy"/"placement"/"snapshot"/"official copy") and
  names the mechanism as the npx skills update command, not "Orca's update".
- Rename the section to "Update details"; drop the unreachable newer-known state.

Renderer-only: derivation is a pure module (groupSkillFreshness) with unit tests;
no IPC or main-process change. Locales updated for all five languages.

* chore(skills): regenerate manifest for current main (v1.4.143-rc.0)

* feat(skills): don't let a duplicate block the update; clearer skipped copy

- Eligibility: a clean standalone duplicate no longer poisons the whole
  name — the canonical copy still updates and the duplicate is flagged;
  a duplicate-only skill stays unoffered.
- Update modal: "Can't update" -> "Skipped" with a reason-specific
  sentence (edited/read-only/in-a-repo/plugin/link); chips describe only
  the location state; footer "Check now" -> "Re-check".
- Settings sidebar nav pills go amber "Update available" when a skill is
  updatable, matching the setup cards.
- Localized new strings across en/es/ja/ko/zh.

* chore(skills): regenerate manifest for merged main (v1.4.144-rc.1)
2026-07-16 14:47:28 -07:00

392 lines
16 KiB
JavaScript

import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeDeclarations,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
describe('electron-builder config', () => {
it('excludes repo-only source trees from app.asar', () => {
expect(electronBuilderConfig.files).toEqual(
expect.arrayContaining([
'!src{,/**/*}',
'!config{,/**/*}',
'!docs{,/**/*}',
'!mobile{,/**/*}',
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
])
)
})
it('keeps runtime resources available through extraResources', () => {
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
}
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'
})
])
)
})
// 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('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
)
})
// 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('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 AppImage and deb as local Linux targets without changing existing artifact names', () => {
expect(electronBuilderConfig.linux.target).toEqual(['AppImage', 'deb'])
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('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('uses Orca native rebuild hook instead of electron-builder default rebuild', () => {
expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild)
expect(electronBuilderConfig.npmRebuild).toBe(true)
})
it('verifies packaged main runtime deps from Windows-style asar entries', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-deps-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
await mkdir(join(resourcesDir, 'node_modules', 'yaml'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod'), { recursive: true })
const sources = new Map([
['out\\main\\index.js', 'const z = require("zod")'],
['out\\main\\agent-hooks\\managed-agent-hook-controls.js', 'const YAML = require("yaml")']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `\\${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('normalizes host-specific asar entry separators', () => {
expect(findAsarEntry(['\\out\\main\\index.js'], 'out/main/index.js')).toBe(
'\\out\\main\\index.js'
)
expect(findAsarEntry(['/out/main/index.js'], 'out/main/index.js')).toBe('/out/main/index.js')
})
it('prunes non-target node-pty prebuilds from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const prebuildsDir = join(resourcesDir, 'node_modules', 'node-pty', 'prebuilds')
await mkdir(join(prebuildsDir, 'darwin-arm64'), { recursive: true })
await mkdir(join(prebuildsDir, 'darwin-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'linux-x64'), { recursive: true })
await mkdir(join(prebuildsDir, 'win32-x64'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps', 'winpty'), {
recursive: true
})
prunePackagedNodePty(resourcesDir, 'darwin')
await expect(readdir(prebuildsDir).then((entries) => entries.sort())).resolves.toEqual([
'darwin-arm64',
'darwin-x64'
])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'third_party'))
).resolves.toEqual([])
await expect(
readdir(join(resourcesDir, 'node_modules', 'node-pty', 'deps'))
).resolves.toEqual([])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const arch of ['x64', 'arm64']) {
const resourcesDir = await mkdtemp(join(tmpdir(), `orca-node-pty-conpty-${arch}-`))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const releaseDir = join(nodePtyDir, 'build', 'Release')
const conptyRoot = join(nodePtyDir, 'third_party', 'conpty', '0.1.0')
await mkdir(releaseDir, { recursive: true })
await writeFile(join(releaseDir, 'conpty.node'), 'native addon placeholder', 'utf8')
for (const sourceArch of ['x64', 'arm64']) {
const sourceDir = join(conptyRoot, `win10-${sourceArch}`)
await mkdir(sourceDir, { recursive: true })
await writeFile(join(sourceDir, 'conpty.dll'), `dll payload ${sourceArch}`, 'utf8')
await writeFile(
join(sourceDir, 'OpenConsole.exe'),
`console payload ${sourceArch}`,
'utf8'
)
}
prunePackagedNodePty(resourcesDir, 'win32', arch)
await expect(readFile(join(releaseDir, 'conpty', 'conpty.dll'), 'utf8')).resolves.toBe(
`dll payload ${arch}`
)
await expect(readFile(join(releaseDir, 'conpty', 'OpenConsole.exe'), 'utf8')).resolves.toBe(
`console payload ${arch}`
)
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
}
})
it('includes @parcel/watcher in the packaged runtime closure', () => {
// Why: the main process imports '@parcel/watcher' for filesystem change
// events; if it is absent from the packaged closure the serve host silently
// stops propagating file changes to clients (regression guard for #4851).
const packaged = createPackagedRuntimeNodeModuleResources()
const packagedTargets = packaged.map((resource) => resource.to)
expect(packagedTargets).toContain(join('node_modules', '@parcel', 'watcher'))
expect(
packagedTargets.some((target) =>
target.startsWith(join('node_modules', '@parcel', 'watcher-'))
)
).toBe(true)
})
it('prunes non-target @parcel/watcher platform subpackages from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-x64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-arm64-glibc'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-win32-x64'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('leaves unrelated @parcel/* runtime deps untouched when pruning the watcher', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-parcel-watcher-prune-unrelated-'))
try {
const parcelDir = join(resourcesDir, 'node_modules', '@parcel')
await mkdir(join(parcelDir, 'watcher'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-darwin-arm64'), { recursive: true })
await mkdir(join(parcelDir, 'watcher-linux-x64-glibc'), { recursive: true })
// A hypothetical future @parcel/* runtime dep that is NOT a watcher subpackage.
await mkdir(join(parcelDir, 'transformer-js'), { recursive: true })
prunePackagedParcelWatcher(resourcesDir, 'linux')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
'watcher',
'watcher-linux-x64-glibc'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes type declaration artifacts from packaged runtime node_modules', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-type-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'example-package')
await mkdir(join(packageDir, 'dist'), { recursive: true })
await writeFile(join(packageDir, 'dist', 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.ts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.cts'), 'export type Value = string', 'utf8')
await writeFile(join(packageDir, 'dist', 'index.d.mts.map'), '{}', 'utf8')
prunePackagedRuntimeTypeDeclarations(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes duplicate darwin sherpa-onnx runtime dylib aliases', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-sherpa-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'sherpa-onnx-darwin-arm64')
await mkdir(packageDir, { recursive: true })
await writeFile(join(packageDir, 'sherpa-onnx.node'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.1.23.2.dylib'), '', 'utf8')
await writeFile(join(packageDir, 'libonnxruntime.dylib'), '', 'utf8')
prunePackagedSherpaOnnx(resourcesDir, 'darwin')
await expect(readdir(packageDir).then((entries) => entries.sort())).resolves.toEqual([
'libonnxruntime.1.23.2.dylib',
'sherpa-onnx.node'
])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it('prunes zod TypeScript sources from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-zod-prune-'))
try {
const packageDir = join(resourcesDir, 'node_modules', 'zod')
await mkdir(join(packageDir, 'src'), { recursive: true })
await writeFile(join(packageDir, 'index.cjs'), 'module.exports = {}', 'utf8')
await writeFile(join(packageDir, 'src', 'index.ts'), 'export const value = true', 'utf8')
prunePackagedZodSources(resourcesDir)
await expect(readdir(packageDir)).resolves.toEqual(['index.cjs'])
} finally {
await rm(resourcesDir, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'marks packaged Unix CLI launchers executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
const resourcesDir = join(root, 'linux-unpacked', 'resources')
const launcherPath = join(resourcesDir, 'bin', 'orca-ide')
await mkdir(join(resourcesDir, 'bin'), { recursive: true })
await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true })
// Why: afterPack now fails hard when the unpacked daemon entry is
// missing, so the fixture must carry one like a real package layout.
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(
join(unpackedMainDir, 'daemon-entry.js'),
'console.error("Usage: daemon-entry <socket>"); process.exit(1)\n',
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux'
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})