Files
orca/config/scripts/macos-terminal-host-bundle.test.mjs
T
Jinwoo HongandNeil 3f8d296b8d fix(macos): preserve terminal permissions across app updates with a small signed helper (#27021)
* fix(macos): preserve terminal privacy identity across app updates

* test(macos): make launchd fixtures portable across host platforms

* fix(macos): probe protected contents and close handles on denial

* fix(i18n): register terminal permission status labels

* fix(macos): fork the daemon when no stable-bundle job can claim the endpoint

A copy, signature, plist or deadline failure before bootstrap, a bootstrap
launchd refused with the job absent, a job whose daemon exited before
answering, and a foreign endpoint all threw out of the launcher. The app then
lost its persistent daemon and fell back to local PTYs, where main forks one.
These now fall through to the existing fork path, which also adopts a foreign
occupant normally instead of as a degraded endpoint. A timed-out bootstrap or
a job still running/unreadable stays fatal, so no second daemon races it.

* feat(telemetry): split daemon origin events by stable-copy launch method

daemon_adopted, daemon_pty_cwd_denied and daemon_pty_cwd_readable gain a
closed-enum launch_method (stable-copy / app-fork / unknown), read from the
spawner path the daemon recorded, so the update fix is measurable per daemon
even when an old forked daemon is adopted across updates. The reliability
gate is a test manifest with no runtime switch, so there is no gate state to
report; spawner_path_class reports the private copy as 'other' alongside
other non-/Applications installs.

* refactor(telemetry): report the stable copy as a spawner path class

The separate launch_method property duplicated what the recorded spawner
path already says. spawner_path_class gains `stable-copy`, classified from
the same path under the private runtime root, and launch_method, its enum
and classifier are removed from all three .strict() daemon origin events.
A deleted copy still reports `missing`.

* refactor(settings): gate terminal-host Full Disk Access on macOS once

The permissions handler already asks for the terminal-host status only on
macOS, so the status function's own platform check and the pane's
'unsupported' branch could never fire. The off-macOS guarantee is now
tested at the handler.

* fix(macos): keep the fork fallback inside the startup PTY gate

The stable launch could run until 55 s after the launch started, so a late
"unavailable" verdict left the fork fallback's own 10 s readiness wait to
finish after the 60 s gate had already moved the app to local PTYs.

The launcher now gives the stable attempt one handoff deadline: the gate
minus the fork's readiness timeout and the 5 s lease/adapter allowance
(45 s). Copy, signature check and plist conversion share one abort signal
that ends early enough for bootstrap and the job's own readiness wait to
meet that deadline; every command otherwise keeps a fixed timeout. An
unavailable verdict past the deadline is reported instead of forking a
daemon that cannot arrive in time. The per-step budget module is gone (its
deletion landed in the previous commit by a staging slip).

The launch body is now two blocks: preparation (plist, plutil, deadline
check) fails as unavailable after removing the copy and job file, and
bootstrap runs in its own try/finally that always deletes the job file.

Retirement is simpler and claims more orphans: one in-flight collection per
process instead of a per-root map and rotating 20-record scan, and the job
record (written before copying) no longer carries a bundle name because
lsof +D scans the whole runtime directory (measured equal cost, same
result on a live copy). A never-submitted copy is retired once its producer
has exited and its job is missing, so a bootstrap timeout or a crash
mid-copy no longer leaves a copy forever; the open-file check still guards
deletion. Collection now starts from the launcher after the attempt.

* docs(macos): ship the daemon launch rules, not the investigation

The signed investigation harness left the tree in the previous telemetry
commit by a staging slip; its results and pinned link stay in the PR. This
trims the reference doc to why the copy and launchd exist, the fallback
states and the cleanup rule, trims the reliability gate to what its
validator needs, and points AGENTS.md at the reference doc next to the
Windows relocation entry.

* fix(macos): hand off to the fork as soon as the stable job exits

A copied daemon refused at exec (for example by Gatekeeper) never answers,
so every fresh start paid the full 10 s readiness wait before the fork
fallback. The readiness loop now reads the job state every 500 ms and
abandons as soon as the job is stopped or missing; a running or
unreadable job is still waited out and never forked beside.

Also drops the dead forkEntryPath the stable launch never read (its
options type now omits it) and un-exports the handoff constant.

* fix(macos): keep daemon copies out of Spotlight and unregister retired ones

Each runtime copy is a full Orca.app, so Spotlight indexed it as an app
and LaunchServices kept a record for it that outlived the copy. A
registered copy claims orca: links and Markdown/CSV files, and becomes
their default handler whenever /Applications/Orca.app is not registered.

New copies go under runtime-*/app.noindex/, which Spotlight skips and which
stops the record LaunchServices made while the bundle was being copied.
Retirement now runs lsregister -u on every bundle in the copy (both the
app.noindex layout and the earlier runtime-*/Orca.app layout) once lsof
shows it unused, then deletes it. A running copy stays registered,
because Local Network access may be resolved through that record.

Collection and stable-copy telemetry match the runtime-* directory, so
copies made before this change are still adopted, classified and retired.

* docs(macos): record the LaunchServices trade-off and the rollback plan

The reference doc now explains why copies sit in a .noindex folder, why
only retired copies are unregistered, and how to turn the copy launch off:
make launchMacDaemonFromStableBundle return null, and keep the retirement
code until no copy-launched daemon can remain, because the old code never
deletes copies.

A test pins that rollback: with the stable launcher returning null, the
launcher forks and still runs the copy collection.

* fix(macos): keep daemon copies registered so Local Network can resolve them

The `.noindex` folder kept copies out of Spotlight and LaunchServices, but
Local Network access is resolved from the signing identifier through
LaunchServices to the executable's Mach-O UUID. After an update that changes
Electron only the copy carries the running daemon's UUID, so an unregistered
copy risks the same Local Network loss main has today. Copies go back to
`runtime-*/Orca.app`; retirement still unregisters each copy before deleting
it, so deleted copies leave no LaunchServices entry. The remaining trade-off:
while `/Applications/Orca.app` is missing, a running registered copy can be
picked for `orca:` links and documents.

* fix(macos): hide daemon copies from Spotlight and tie the job to Orca

Spotlight listed each runtime copy as a second Orca app. New copies go
back under runtime-*/app.noindex/Orca.app, which Spotlight skips.
Retirement and stable-copy telemetry match the runtime-* directory, so
copies in the earlier runtime-*/Orca.app layout still retire and classify
the same way.

Local Network access identifies the daemon by its code signature and
main-executable UUID, not by a LaunchServices record, so an unregistered
copy keeps it. The launchd job now sets AssociatedBundleIdentifiers to
com.stablyai.orca, which is how a job not installed through SMAppService
names the app it belongs to. Copies are never registered explicitly;
retirement still runs lsregister -u before deleting each one.

* feat(macos): package a signed terminal host helper that runs the daemon on the pinned Node

Contents/Helpers/Orca Terminal Host.app carries the pinned Node, the daemon's
out/main JS and this slice's node-pty, signed inside-out as com.stablyai.orca.
afterPack boots it through one PTY round trip, then fails the build on layout,
Info.plist, macOS floor (NODE_RUNTIME_DARWIN_MINIMUM_OS vs Node's minos),
architecture or signature drift.

* ci(macos): cache and prefetch the pinned Node before packaging

Packaging now fetches the darwin Node from nodejs.org, whose download has no
retry. Each mac workflow caches it with the electron-builder downloads, keyed on
the pin, and warms both darwin targets inside the existing retry wrapper.

* feat(macos): launch the daemon from a copy of the terminal host helper, not the whole app

The stable launch now clones Contents/Helpers/Orca Terminal Host.app (~124 MiB
instead of ~628 MiB) and runs its Node on its daemon entry, without
ELECTRON_RUN_AS_NODE. The copy must still match the running app's designated
requirement. A missing helper falls back to the fork, and macOS below the
helper's Node floor forks without copying.

* docs(macos): describe the terminal host helper, its floor and its identity

* fix(macos): bound the terminal host boot check by its deadline and always clean up

A daemon that stayed silent hung packaging past the 20 s deadline; one that closed the
connection left the hello unsettled so Node exited 0 without cleanup; a malformed line threw
uncaught. Every connect, hello and PTY wait now races the deadline, close/error/parse failures
reject, and the daemon and scratch dir are removed on every path.

* ci(macos): cache the pinned Node on its own key with no restore-keys fallback

Sharing the electron-builder cache let old Node archives accumulate through restore-keys.
Also moves release-mac-build's Electron cache comment back onto its step.

* fix(macos): reject a universal slice for the terminal host and pin its name in one test

packageMacTerminalHost turned an unknown arch into darwin-undefined; it now throws through the
shared mac arch mapping. A test asserts the packager and the desktop agree on the helper bundle
and executable names, replacing a Linux/Windows test that could not fail.

* fix(macos): pass the launchd adoption lease its options object after the main merge

* test(macos): accept a reset as the daemon closing the boot-check connection; keep only passing gate commands

* fix(daemon): bump the terminal daemon protocol to v45 so new macOS tabs start in the terminal host helper

* fix(macos): ship the daemon's required packages in the terminal host and boot it outside the build tree

* style(macos): use a template literal in the terminal host resolution check

* fix(macos): derive the terminal host's packages from the daemon's own module graph

* fix(macos): treat any non-literal daemon load as computed, follow directory loads, and keep nested dependencies

* refactor(macos): move terminal-host telemetry, Settings status and Helper folder strings to a follow-up

Keeps this change to the update-safe terminal host itself. The follow-up restores:
- the `stable-copy` spawner path class in daemon adoption telemetry;
- the per-terminal-host Full Disk Access row in Settings and its daemon RPC;
- writing folder usage descriptions into the app's Electron Helpers for the fork fallback.

The helper still takes its Desktop, Documents and Downloads descriptions from
`macos-folder-usage-descriptions.cjs`, so that constant stays.

* fix(i18n): restore en.json to this branch's base, not newer main

The previous commit took the file from a newer main, which pulled unrelated strings into this PR.

* refactor(macos): publish one terminal-host copy per helper build under a fixed launchd label

Name the copy after the helper's CDHash, reuse it when present, and
publish a new one by cloning into a staging dir, verifying it once
against the app's designated requirement and renaming it into place.
One launchd label per profile and protocol replaces per-launch labels,
so job.json, producer pids and per-label collection go away. Retirement
deletes any other copy lsof shows unused. The stable launcher returns
null for every fork fallback and logs its reason.

* fix(packaging): copy terminal-host dependencies from where Node resolves them

A nested package's own dependencies were never walked, so a hoisted
dependency of a nested package was left out of the helper. Resolve each
dependency through the node_modules lookup Node performs from its parent
and copy it to the same relative place, unless a copied package already
carries it.

* fix(packaging): fail release and CI mac builds that cannot boot a terminal-host slice

An arm64 runner without Rosetta skipped the x64 helper's PTY boot check
and shipped the slice untested. Release channels and CI now fail there;
local cross-arch builds still skip with a log line.

* docs(macos): describe the per-build terminal-host copy and its fallback reasons

* docs(macos): keep the codesign command on one line

* test(macos): type the launchd test state without an assertion

* docs(macos): name what the login-session flag gates in the stable launcher

* fix(macos): wait for launchd to drop a booted-out terminal job before reusing its label

---------

Co-authored-by: Neil <neil@stably.ai>
2026-10-11 14:54:36 -04:00

359 lines
15 KiB
JavaScript

import { spawnSync } from 'node:child_process'
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
MAC_TERMINAL_HOST_BUNDLE,
MAC_TERMINAL_HOST_EXECUTABLE
} from '../../src/main/daemon/macos-daemon-bundle.ts'
const require = createRequire(import.meta.url)
const {
assembleMacTerminalHost,
buildMacTerminalHostInfo,
daemonBareRequires,
findMacTerminalHostInfoProblems,
findUnresolvedDaemonRequires,
lipoArchsToNodeArchs,
macTerminalHostPaths,
macTerminalHostSignIgnore,
parseBuildVersionMinos
} = require('./macos-terminal-host-bundle.cjs')
const { macCommandPrefixForArch } = require('./verify-packaged-daemon-entry.cjs')
const electronBuilderConfig = require('../electron-builder.config.cjs')
const usageDescriptions = electronBuilderConfig.mac.extendInfo
const appInfo = { CFBundleIdentifier: 'com.stablyai.orca', CFBundleShortVersionString: '1.4.300' }
const expectations = { appId: 'com.stablyai.orca', usageDescriptions, minimumOs: '13.5' }
describe('terminal host Info.plist', () => {
const info = buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs: '13.5' })
it("carries the app's identifier, every purpose string and the Node floor", () => {
expect(info).toMatchObject({
...usageDescriptions,
CFBundleIdentifier: 'com.stablyai.orca',
CFBundleName: 'Orca',
CFBundleDisplayName: 'Orca',
CFBundleExecutable: 'orca-terminal-host',
CFBundleVersion: '0',
LSMinimumSystemVersion: '13.5',
LSUIElement: true
})
expect(usageDescriptions).toHaveProperty('NSDocumentsFolderUsageDescription')
expect(findMacTerminalHostInfoProblems(info, expectations)).toEqual([])
})
it('rejects link, document and type claims, drifted purpose strings and a stale floor', () => {
expect(
findMacTerminalHostInfoProblems(
{
...info,
CFBundleIdentifier: 'com.stablyai.orca.helper',
CFBundleURLTypes: [],
CFBundleDocumentTypes: [],
UTExportedTypeDeclarations: [],
NSLocalNetworkUsageDescription: 'other',
LSMinimumSystemVersion: '12.0'
},
expectations
)
).toEqual([
'CFBundleIdentifier is com.stablyai.orca.helper, expected com.stablyai.orca',
'declares CFBundleURLTypes',
'declares CFBundleDocumentTypes',
'declares UTExportedTypeDeclarations',
"NSLocalNetworkUsageDescription differs from the app's",
'LSMinimumSystemVersion is 12.0, expected 13.5'
])
})
})
describe('terminal host binary checks', () => {
it('reads minos from the LC_BUILD_VERSION load command only', () => {
const otool = [
'Load command 8',
' cmd LC_SEGMENT_64',
' minos 99.0',
'Load command 9',
' cmd LC_BUILD_VERSION',
' cmdsize 32',
' platform 1',
' minos 13.5',
' sdk 14.0',
'Load command 10',
' cmd LC_SOURCE_VERSION'
].join('\n')
expect(parseBuildVersionMinos(otool)).toBe('13.5')
expect(parseBuildVersionMinos('Load command 1\n cmd LC_SEGMENT_64\n')).toBeNull()
})
it("maps lipo's x86_64 to Node's x64", () => {
expect(lipoArchsToNodeArchs('x86_64\n')).toEqual(['x64'])
expect(lipoArchsToNodeArchs('arm64\n')).toEqual(['arm64'])
expect(lipoArchsToNodeArchs('x86_64 arm64\n')).toEqual(['x64', 'arm64'])
})
it('boots the host slice directly and never claims an arm64 slice runs on Intel', () => {
expect(macCommandPrefixForArch(process.arch)).toEqual([])
const noRosetta = () => ({ status: 1 })
if (process.arch === 'arm64') {
expect(macCommandPrefixForArch('x64', noRosetta)).toBeNull()
expect(macCommandPrefixForArch('x64', () => ({ status: 0 }))).toEqual([
'/usr/bin/arch',
'-x86_64'
])
} else {
expect(macCommandPrefixForArch('arm64', () => ({ status: 0 }))).toBeNull()
}
})
})
describe('terminal host packaging config', () => {
const pattern = new RegExp(macTerminalHostSignIgnore[0])
it('keeps the outer signer off the separately signed helper', () => {
expect(electronBuilderConfig.mac.signIgnore).toEqual(
expect.arrayContaining(macTerminalHostSignIgnore)
)
const helper = '/out/mac-arm64/Orca.app/Contents/Helpers/Orca Terminal Host.app'
expect(pattern.test(helper)).toBe(true)
expect(pattern.test(`${helper}/Contents/MacOS/orca-terminal-host`)).toBe(true)
expect(pattern.test('/out/mac-arm64/Orca.app/Contents/Helpers/Orca Terminal Host.bundle')).toBe(
false
)
expect(pattern.test('/out/mac-arm64/Orca.app/Contents/Frameworks/Orca Helper.app')).toBe(false)
})
it('packages the helper where the desktop looks for it', () => {
const paths = macTerminalHostPaths('/Orca.app')
expect(paths.bundle).toBe(join('/Orca.app', 'Contents', 'Helpers', MAC_TERMINAL_HOST_BUNDLE))
expect(basename(paths.executable)).toBe(MAC_TERMINAL_HOST_EXECUTABLE)
})
})
describe.skipIf(process.platform !== 'darwin')('terminal host assembly', () => {
let root
let appPath
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-terminal-host-'))
appPath = join(root, 'Orca.app')
const resources = join(appPath, 'Contents', 'Resources')
const out = join(resources, 'app.asar.unpacked', 'out')
mkdirSync(join(out, 'main', 'chunks'), { recursive: true })
writeFileSync(join(out, 'main', 'daemon-entry.js'), 'entry')
writeFileSync(join(out, 'main', 'chunks', 'protocol.js'), 'chunk')
writeFileSync(join(out, 'package.json'), '{"type":"commonjs"}')
const nodePty = join(resources, 'node_modules', 'node-pty')
mkdirSync(join(nodePty, 'lib'), { recursive: true })
mkdirSync(join(nodePty, 'build', 'Release'), { recursive: true })
mkdirSync(join(nodePty, 'src'), { recursive: true })
writeFileSync(join(nodePty, 'package.json'), '{}')
writeFileSync(join(nodePty, 'lib', 'index.js'), 'lib')
writeFileSync(join(nodePty, 'build', 'Release', 'pty.node'), 'addon')
// Some packaged node-pty copies ship spawn-helper without its execute bit.
writeFileSync(join(nodePty, 'build', 'Release', 'spawn-helper'), 'helper', { mode: 0o644 })
writeFileSync(join(nodePty, 'src', 'unix.cc'), 'source')
writeFileSync(join(root, 'node'), 'pinned-node', { mode: 0o644 })
writeFileSync(join(root, 'icon.icns'), 'icon')
})
afterEach(() => {
rmSync(root, { recursive: true, force: true })
})
it('lays out the pinned Node, daemon JS and runtime node-pty files only', () => {
const info = buildMacTerminalHostInfo({ appInfo, usageDescriptions, minimumOs: '13.5' })
const paths = assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info
})
expect(paths).toEqual(macTerminalHostPaths(appPath))
expect(paths.bundle).toBe(join(appPath, 'Contents', 'Helpers', 'Orca Terminal Host.app'))
expect(readFileSync(paths.executable, 'utf8')).toBe('pinned-node')
expect(statSync(paths.executable).mode & 0o777).toBe(0o755)
expect(statSync(paths.spawnHelper).mode & 0o777).toBe(0o755)
expect(readFileSync(paths.entry, 'utf8')).toBe('entry')
expect(existsSync(join(paths.daemon, 'out', 'main', 'chunks', 'protocol.js'))).toBe(true)
expect(readFileSync(paths.outPackageJson, 'utf8')).toBe('{"type":"commonjs"}')
expect(existsSync(join(paths.nodePty, 'lib', 'index.js'))).toBe(true)
expect(existsSync(join(paths.nodePty, 'src'))).toBe(false)
expect(readFileSync(paths.icon, 'utf8')).toBe('icon')
const plist = spawnSync('/usr/bin/plutil', ['-convert', 'json', '-o', '-', paths.infoPlist], {
encoding: 'utf8'
})
expect(findMacTerminalHostInfoProblems(JSON.parse(plist.stdout), expectations)).toEqual([])
})
it('copies every package the daemon requires, with its dependencies', () => {
const resources = join(appPath, 'Contents', 'Resources')
const main = join(resources, 'app.asar.unpacked', 'out', 'main')
writeFileSync(join(main, 'daemon-entry.js'), "require('./chunks/kill.js')")
writeFileSync(
join(main, 'chunks', 'kill.js'),
"require(`node:fs`);require('os');require(`@orca/process-host/gate`)"
)
// Main-process code shipped alongside is not part of the daemon.
writeFileSync(join(main, 'index.js'), "require('unused');require(`electron`)")
const modules = join(resources, 'node_modules')
mkdirSync(join(modules, '@orca', 'process-host', 'dist'), { recursive: true })
writeFileSync(
join(modules, '@orca', 'process-host', 'package.json'),
'{"dependencies":{"retry":"1"}}'
)
mkdirSync(join(modules, 'retry'), { recursive: true })
writeFileSync(join(modules, 'retry', 'package.json'), '{}')
mkdirSync(join(modules, 'unused'), { recursive: true })
writeFileSync(join(modules, 'unused', 'package.json'), '{}')
const paths = assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
const helperModules = join(paths.daemon, 'node_modules')
expect(daemonBareRequires(join(paths.daemon, 'out', 'main'))).toEqual([
'@orca/process-host/gate'
])
expect(existsSync(join(helperModules, '@orca', 'process-host', 'package.json'))).toBe(true)
expect(existsSync(join(helperModules, 'retry', 'package.json'))).toBe(true)
expect(existsSync(join(helperModules, 'unused'))).toBe(false)
expect(existsSync(join(helperModules, 'electron'))).toBe(false)
})
it('fails when the app lacks a package the daemon requires', () => {
const main = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'out', 'main')
writeFileSync(join(main, 'daemon-entry.js'), "require('./gone')")
writeFileSync(join(main, 'gone.js'), "require('yaml')")
expect(() =>
assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
).toThrow(/requires yaml, missing/)
})
it('flags a require that only resolves outside the helper', () => {
const paths = assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
writeFileSync(paths.entry, "require('jsonc-parser')")
// The build tree's node_modules above dist/ is what hid this from the boot check.
mkdirSync(join(root, 'node_modules', 'jsonc-parser'), { recursive: true })
writeFileSync(join(root, 'node_modules', 'jsonc-parser', 'package.json'), '{"main":"i.js"}')
writeFileSync(join(root, 'node_modules', 'jsonc-parser', 'i.js'), '')
expect(findUnresolvedDaemonRequires(paths)).toEqual([
expect.stringMatching(
/^jsonc-parser resolves to .*node_modules\/jsonc-parser\/i\.js, not inside/
)
])
// Node caches resolutions, so the in-bundle case uses a package not resolved above.
writeFileSync(paths.entry, "require('yaml')")
mkdirSync(join(paths.daemon, 'node_modules', 'yaml'), { recursive: true })
writeFileSync(join(paths.daemon, 'node_modules', 'yaml', 'package.json'), '{"main":"i.js"}')
writeFileSync(join(paths.daemon, 'node_modules', 'yaml', 'i.js'), '')
expect(findUnresolvedDaemonRequires(paths)).toEqual([])
})
it('follows directory and JSON loads and keeps nested dependencies with their parent', () => {
const resources = join(appPath, 'Contents', 'Resources')
const main = join(resources, 'app.asar.unpacked', 'out', 'main')
mkdirSync(join(main, 'lib'), { recursive: true })
writeFileSync(join(main, 'daemon-entry.js'), "require('./lib');require('./data.json')")
writeFileSync(join(main, 'lib', 'index.js'), "require('outer')")
writeFileSync(join(main, 'data.json'), '{}')
const outer = join(resources, 'node_modules', 'outer')
mkdirSync(join(outer, 'node_modules', 'inner'), { recursive: true })
writeFileSync(join(outer, 'package.json'), '{"dependencies":{"inner":"1"}}')
writeFileSync(join(outer, 'node_modules', 'inner', 'package.json'), '{}')
const paths = assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
expect(daemonBareRequires(join(paths.daemon, 'out', 'main'))).toEqual(['outer'])
expect(existsSync(join(paths.daemon, 'node_modules', 'outer', 'node_modules', 'inner'))).toBe(
true
)
})
it("copies a nested package's dependency from wherever Node resolves it", () => {
const resources = join(appPath, 'Contents', 'Resources')
writeFileSync(
join(resources, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js'),
"require('b')"
)
const modules = join(resources, 'node_modules')
const packages = {
b: { dependencies: { c: '2' } },
// The top-level c is another version; b gets its own nested copy.
c: {},
[join('b', 'node_modules', 'c')]: { dependencies: { e: '1' } },
e: {}
}
for (const [dir, manifest] of Object.entries(packages)) {
mkdirSync(join(modules, dir), { recursive: true })
writeFileSync(join(modules, dir, 'package.json'), JSON.stringify(manifest))
}
const paths = assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
const helperModules = join(paths.daemon, 'node_modules')
expect(existsSync(join(helperModules, 'b', 'node_modules', 'c', 'package.json'))).toBe(true)
expect(existsSync(join(helperModules, 'e', 'package.json'))).toBe(true)
expect(existsSync(join(helperModules, 'c'))).toBe(false)
})
it.each([
['a variable', 'const name = pick();require(name)'],
['a concatenated literal', "require('yaml' + suffix)"],
['a template with a substitution', 'require(`./chunk-${id}.js`)']
])('fails when the daemon loads a module through %s', (_label, source) => {
const main = join(appPath, 'Contents', 'Resources', 'app.asar.unpacked', 'out', 'main')
writeFileSync(join(main, 'daemon-entry.js'), source)
expect(() =>
assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
).toThrow(/loads a module by a computed name/)
})
it('fails before writing anything when the daemon entry is missing', () => {
rmSync(join(appPath, 'Contents', 'Resources', 'app.asar.unpacked'), { recursive: true })
expect(() =>
assembleMacTerminalHost({
appPath,
nodeExecutable: join(root, 'node'),
iconPath: join(root, 'icon.icns'),
info: {}
})
).toThrow(/missing .*daemon-entry\.js/)
expect(existsSync(join(appPath, 'Contents', 'Helpers'))).toBe(false)
})
})