Files
orca/config/scripts/electron-builder-runtime-resources.test.mjs
T
Neil e86cba888b build: reduce native dependency installs to the host platform (#20420)
* Reduce native dependency installs to the host platform

* Remove install policy documentation

* Guard cross-arch packaging and scope release installs to the runner

electron-builder only logs a warning for a missing extraResources source,
so a host-only install silently shipped a foreign-arch slice without its
natives — `pnpm build:mac` on Apple Silicon produced an x64 DMG with no
sherpa-onnx-darwin-x64 and no @parcel/watcher-darwin-x64. The previous
beforePack hook covered only win32.

- Add assertPackagedNativeVariantsInstalled, an arch-aware check over the
  target's sherpa-onnx, @parcel/watcher, and (on Windows) node-gyp addons.
  beforePack now runs it for every platform, with remedies split: another
  architecture comes from install:release, the os:win32 addons need a
  Windows host.
- Drop --os from the release installs. Every packaging job already runs on
  a runner whose OS matches its target, so only the macOS lanes need extra
  breadth, and only on CPU for their x64+arm64 config. Windows and Linux
  packaging return to a plain host-only install.
- Add --frozen-lockfile to install:release so a bare run cannot rewrite
  the lockfile.
- Restore the install policy reference doc and the CONTRIBUTING note, plus
  the rationale comments dropped from the runtime contract test.
- Gate the packaging-closure assertions on whether the Windows addons are
  installed rather than on the host OS, so a cross-arch install exercises
  them off Windows too.
- Make the workflow contract test read `run:` steps as well as retry-action
  commands, and enforce host-only scoping on the non-macOS packaging lanes.
- Remove the unreferenced install measurement script; its numbers live in
  the policy doc.

* Track the install policy doc and index it from AGENTS.md

docs/** is ignored behind a per-file allow-list, so the new reference doc
was only committed via git add -f and future edits would be skipped. Add
it to the allow-list and give it an AGENTS.md entry like every other
tracked reference doc, so the host-only install rule is discoverable
before someone packages a second architecture.

* Route Windows-lane removals through the retrying helper

Adding these four specs to the PR Windows lane pulled them into the
windows-lane-tree-removal-boundary ratchet, which failed on 20 raw
recursive removals. On Windows a bare rmSync races a handle the OS has
not released, throwing EPERM after the assertions already passed and
reporting a green test as a lane failure.

* Adapt the packaging guard to the vendored Windows registry addon

main vendored windows-native-registry as the workspace package
@orca/windows-registry (#20438). A workspace link resolves on every
host, so including it in the installed-Windows-addons checks proved
nothing. @vscode/windows-process-tree is the only os: win32 npm addon
left, so it alone decides whether the win32 resource plan resolves.
2026-09-12 21:25:03 -07:00

617 lines
26 KiB
JavaScript

import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { cp, mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join, relative, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { removeTree } from '../../src/shared/windows-transient-lock-removal.ts'
const require = createRequire(import.meta.url)
const projectRoot = resolve(import.meta.dirname, '..', '..')
const electronBuilderConfig = require('../electron-builder.config.cjs')
const {
createPackagedRuntimeNodeModuleResources,
findAsarEntry,
isPackagedExternalSpecifier,
packageNameFromSpecifier,
prunePackagedNodePty,
prunePackagedParcelWatcher,
prunePackagedSherpaOnnx,
prunePackagedRuntimeTypeAndSourceMapArtifacts,
prunePackagedZodSources,
verifyPackagedMainRuntimeDeps
} = require('../packaged-runtime-node-modules.cjs')
// Why this and not process.platform: @vscode/windows-process-tree is the only os: win32 npm
// addon left, so its presence is what decides whether the win32 plan resolves.
// @orca/windows-registry is a workspace link present on every host, so it proves nothing.
const windowsAddonsInstalled = existsSync(
join(projectRoot, 'node_modules', '@vscode', 'windows-process-tree', 'package.json')
)
describe('packaged runtime resources', () => {
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 removeTree(resourcesDir)
}
})
it('verifies literal dynamic imports from the packaged main bundle', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-dynamic-imports-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// The first is the exact shape oxc emits for the memoized SDK import in a
// shipped build; the second is the spaced variant the pattern also accepts.
const sources = new Map([
[
'out/main/index.js',
'let p=null;function q(){return p??=import(`@anthropic-ai/claude-agent-sdk`),p}'
],
[
'out/main/agent-hooks/managed-agent-hook-controls.js',
'import (`@anthropic-ai/claude-agent-sdk`)'
]
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
/@anthropic-ai\/claude-agent-sdk/
)
await mkdir(join(resourcesDir, 'node_modules', '@anthropic-ai', 'claude-agent-sdk'), {
recursive: true
})
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await removeTree(resourcesDir)
}
})
it('still fails when a required packaged main entry is missing entirely', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-missing-entry-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
const asar = {
listPackage: () => ['/out/main/index.js'],
extractFile: () => Buffer.from('', 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(
/managed-agent-hook-controls\.js was not found/
)
} finally {
await removeTree(resourcesDir)
}
})
it('verifies bare imports that rolldown hoisted into a shared main chunk', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-chunk-imports-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// The entry points themselves carry no specifier; only the shared chunk does.
const sources = new Map([
['out/main/index.js', ''],
['out/main/agent-hooks/managed-agent-hook-controls.js', ''],
['out/main/chunks/managed-agent-hook-controls-CWf8D-KR.js', 'require(`jsonc-parser`)']
])
// Real listPackage emits directory nodes too, and extractFile throws on them,
// so the `.js` anchor is load-bearing -- keep the mock able to catch that.
const directories = ['/out', '/out/main', '/out/main/chunks']
const asar = {
listPackage: () => [...directories, ...[...sources.keys()].map((entry) => `/${entry}`)],
extractFile: (_asarPath, internalPath) => {
const source = sources.get(internalPath)
if (source === undefined) {
throw new Error(`Expected to find file at: ${internalPath} but found a directory`)
}
return Buffer.from(source, 'utf8')
}
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
await mkdir(join(resourcesDir, 'node_modules', 'jsonc-parser'), { recursive: true })
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).not.toThrow()
} finally {
await removeTree(resourcesDir)
}
})
it('reads a spread require, whose leading dots are not member access', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-spread-require-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
const sources = new Map([
['out/main/index.js', 'const all=[...require("jsonc-parser")]'],
['out/main/agent-hooks/managed-agent-hook-controls.js', '']
])
const asar = {
listPackage: () => [...sources.keys()].map((entry) => `/${entry}`),
extractFile: (_asarPath, internalPath) => Buffer.from(sources.get(internalPath), 'utf8')
}
expect(() => verifyPackagedMainRuntimeDeps(resourcesDir, asar)).toThrow(/jsonc-parser/)
} finally {
await removeTree(resourcesDir)
}
})
it('ignores member calls onto Orca methods that are themselves named require', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-runtime-member-require-'))
try {
await writeFile(join(resourcesDir, 'app.asar'), '', 'utf8')
// electron-sidecar-tab-registry and browser-execution-host-grant-registry both
// expose require(key); a literal key must never read as a packaged specifier.
const sources = new Map([
['out/main/index.js', 'registry.require("public-a");grants.require(`host-key`)'],
['out/main/agent-hooks/managed-agent-hook-controls.js', 'state.import("android-sdk")']
])
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 removeTree(resourcesDir)
}
})
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 architecture outputs from packaged runtime resources', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-node-pty-prune-'))
try {
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
const prebuildsDir = join(nodePtyDir, 'prebuilds')
const binDir = join(nodePtyDir, 'bin')
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(binDir, 'darwin-arm64-148'), { recursive: true })
await mkdir(join(binDir, 'darwin-x64-148'), { recursive: true })
await mkdir(join(nodePtyDir, 'third_party', 'conpty'), {
recursive: true
})
await mkdir(join(nodePtyDir, 'deps', 'winpty'), { recursive: true })
prunePackagedNodePty(resourcesDir, 'darwin', 3)
await expect(readdir(prebuildsDir)).resolves.toEqual(['darwin-arm64'])
await expect(readdir(binDir)).resolves.toEqual(['darwin-arm64-148'])
await expect(readdir(join(nodePtyDir, 'third_party'))).resolves.toEqual([])
await expect(readdir(join(nodePtyDir, 'deps'))).resolves.toEqual([])
expect(() => prunePackagedNodePty(resourcesDir, 'darwin', 4)).toThrow(
'Unsupported packaged runtime architecture: 4'
)
} finally {
await removeTree(resourcesDir)
}
})
it('copies the Windows node-pty ConPTY runtime beside the rebuilt addon', async () => {
for (const [arch, electronArch] of [
['x64', 1],
['arm64', 3]
]) {
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', electronArch)
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 removeTree(resourcesDir)
}
}
})
it('includes external main dependencies 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)
expect(packagedTargets).toContain(join('node_modules', 'proper-lockfile'))
})
it('includes the Claude agent SDK in every desktop package plan', () => {
for (const platform of windowsAddonsInstalled
? ['darwin', 'linux', 'win32']
: ['darwin', 'linux']) {
const packagedTargets = createPackagedRuntimeNodeModuleResources(platform).map(
(resource) => resource.to
)
expect(packagedTargets).toContain(join('node_modules', '@anthropic-ai', 'claude-agent-sdk'))
}
})
it('prunes non-target @parcel/watcher architecture subpackages', 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', 'arm64')
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'watcher',
'watcher-linux-arm64-glibc'
])
expect(() => prunePackagedParcelWatcher(resourcesDir, 'linux', 'universal')).toThrow(
'Unsupported packaged runtime architecture: universal'
)
} finally {
await removeTree(resourcesDir)
}
})
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', 1)
await expect(readdir(parcelDir).then((entries) => entries.sort())).resolves.toEqual([
'transformer-js',
'watcher',
'watcher-linux-x64-glibc'
])
} finally {
await removeTree(resourcesDir)
}
})
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')
prunePackagedRuntimeTypeAndSourceMapArtifacts(resourcesDir)
await expect(readdir(join(packageDir, 'dist'))).resolves.toEqual(['index.cjs'])
} finally {
await removeTree(resourcesDir)
}
})
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 removeTree(resourcesDir)
}
})
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 removeTree(resourcesDir)
}
})
it('fails when the packaged resources directory is missing', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-electron-builder-config-'))
try {
await expect(
electronBuilderConfig.afterPack({
appOutDir: root,
electronPlatformName: 'win32'
})
).rejects.toThrow(/Missing packaged resources directory/)
} finally {
await removeTree(root)
}
})
it.skipIf(process.platform === 'win32')(
'prunes non-target native packages before the Linux glibc gate',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-after-pack-prune-order-'))
const previousPath = process.env.PATH
try {
const appOutDir = join(root, 'linux-unpacked')
const resourcesDir = join(appOutDir, 'resources')
await cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ recursive: true }
)
const unpackedMainDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'main')
await mkdir(unpackedMainDir, { recursive: true })
await writeFile(join(unpackedMainDir, 'daemon-entry.js'), '', 'utf8')
await writeFile(
join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
`${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(join(unpackedCliDir, 'index.js'), '', 'utf8')
const target =
process.arch === 'x64'
? { electronArch: 3, machine: 0xb7, nonTarget: 'x64' }
: { electronArch: 1, machine: 0x3e, nonTarget: 'arm64' }
const wrongArchPackage = join(
resourcesDir,
'node_modules',
'@parcel',
`watcher-linux-${target.nonTarget}-glibc`
)
await mkdir(wrongArchPackage, { recursive: true })
const wrongArchElf = Buffer.alloc(20)
wrongArchElf.set([0x7f, 0x45, 0x4c, 0x46])
wrongArchElf[5] = 1
wrongArchElf.writeUInt16LE(target.machine, 18)
await writeFile(join(wrongArchPackage, 'watcher.node'), wrongArchElf)
const stubBinDir = join(root, 'bin')
await mkdir(stubBinDir)
await writeFile(join(stubBinDir, 'objdump'), '#!/bin/sh\nexit 0\n', { mode: 0o755 })
process.env.PATH = `${stubBinDir}${delimiter}${previousPath ?? ''}`
await expect(
electronBuilderConfig.afterPack({
appOutDir,
electronPlatformName: 'linux',
arch: target.electronArch,
packager: { appInfo: { version: '9.9.9' } }
})
).resolves.toBeUndefined()
await expect(stat(wrongArchPackage)).rejects.toMatchObject({ code: 'ENOENT' })
} finally {
process.env.PATH = previousPath
await removeTree(root)
}
}
)
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 cp(
join(process.cwd(), 'resources', 'plugins', 'launch'),
join(resourcesDir, 'plugins', 'launch'),
{ 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(
join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'),
`${JSON.stringify({ name: 'orca-compiled-output', type: 'commonjs', private: true })}\n`,
'utf8'
)
const unpackedCliDir = join(resourcesDir, 'app.asar.unpacked', 'out', 'cli')
await mkdir(join(unpackedCliDir, 'handlers'), { recursive: true })
await writeFile(join(unpackedCliDir, 'handlers', 'skills.js'), '', 'utf8')
await writeFile(
join(unpackedCliDir, 'index.js'),
[
'const args = process.argv.slice(2)',
"if (args[1] === 'list') console.log(JSON.stringify({ topics: [{ name: 'orca-cli' }, { name: 'computer-use' }] }))",
"else if (args[1] === 'get') console.log(`---\\nname: ${args[2]}\\n---`)",
'else console.log(JSON.stringify({ executed: false }))'
].join('\n'),
'utf8'
)
await writeFile(launcherPath, '#!/usr/bin/env bash\n', { encoding: 'utf8', mode: 0o644 })
await electronBuilderConfig.afterPack({
appOutDir: join(root, 'linux-unpacked'),
electronPlatformName: 'linux',
arch: 1,
packager: { appInfo: { version: '9.9.9' } }
})
expect((await stat(launcherPath)).mode & 0o111).not.toBe(0)
await expect(
readFile(join(resourcesDir, 'app.asar.unpacked', 'out', 'package.json'), 'utf8')
).resolves.toContain('"version": "9.9.9"')
await expect(readFile(join(resourcesDir, 'package-type'), 'utf8')).resolves.toBe('AppImage')
} finally {
await removeTree(root)
}
}
)
})
// Why source-anchored: the bundler renames a createRequire()'d require, so
// verifyPackagedMainRuntimeDeps' `require("x")` scan cannot see these specifiers — packaging
// stays green while the packaged app throws MODULE_NOT_FOUND the first time the path runs.
function collectLazyRequireSpecifiers(directory, found = new Map()) {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
collectLazyRequireSpecifiers(entryPath, found)
continue
}
if (!entry.isFile() || !entry.name.endsWith('.ts') || entry.name.includes('.test.')) {
continue
}
const source = readFileSync(entryPath, 'utf8')
if (!source.includes('createRequire(')) {
continue
}
for (const match of source.matchAll(/\brequire[A-Za-z0-9_]*\(\s*'([^']+)'\s*\)/g)) {
if (isPackagedExternalSpecifier(match[1])) {
found.set(match[1], relative(projectRoot, entryPath).replaceAll('\\', '/'))
}
}
}
return found
}
function packagedResourceDestinations(platform) {
return new Set(
(electronBuilderConfig[platform].extraResources ?? []).map((resource) =>
String(resource.to).replaceAll('\\', '/')
)
)
}
describe('lazily required packages reach Resources/node_modules', () => {
it('copies every createRequire specifier main uses into the packaged resource plan', () => {
const specifiers = collectLazyRequireSpecifiers(join(projectRoot, 'src', 'main'))
expect(specifiers.size).toBeGreaterThan(0)
const destinations = {
win: packagedResourceDestinations('win'),
mac: packagedResourceDestinations('mac'),
linux: packagedResourceDestinations('linux')
}
for (const [specifier, source] of specifiers) {
const packageName = packageNameFromSpecifier(specifier)
const covered = (platform) =>
destinations[platform].has(`node_modules/${packageName}`) ||
destinations[platform].has(`node_modules/${specifier}`)
// The Windows CI lane checks the full closure with its native addons installed.
if (windowsAddonsInstalled) {
expect(
covered('win'),
`${source} lazily requires '${specifier}', but nothing copies it to Resources/node_modules`
).toBe(true)
}
if (covered('mac') && covered('linux')) {
continue
}
// Only the Windows-native loaders may be absent from the mac/linux plans.
expect(source, `'${specifier}' is packaged for Windows only`).toContain('windows')
}
})
it('resolves the copied emoji dataset the way the packaged main bundle does', async () => {
const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-lazy-require-'))
try {
const datasetPath = 'node_modules/emojibase-data/en/shortcodes/emojibase.json'
const entry = electronBuilderConfig.mac.extraResources.find(
(resource) => String(resource.to) === datasetPath
)
expect(entry).toBeDefined()
const destination = join(resourcesDir, ...datasetPath.split('/'))
await mkdir(dirname(destination), { recursive: true })
await cp(join(projectRoot, ...String(entry.from).split('/')), destination)
// app.asar's parent is Resources, so main's bare require walks into Resources/node_modules.
const packagedMainDir = join(resourcesDir, 'app.asar', 'out', 'main')
await mkdir(packagedMainDir, { recursive: true })
const probe = join(packagedMainDir, 'probe.cjs')
await writeFile(probe, 'module.exports = require', 'utf8')
const dataset = require(probe)('emojibase-data/en/shortcodes/emojibase.json')
expect(Object.keys(dataset).length).toBeGreaterThan(1000)
} finally {
await removeTree(resourcesDir)
}
})
})