Files
hermes-agent/apps/desktop/scripts/bundle-electron-main.test.mjs
Brooklyn Nicholson 89cc403b23 fix(desktop): resolve the pre-launch home through the shared resolver
configuredElectronFlags() inlined HERMES_HOME normalization, so it missed
the two cases resolveDesktopHermesHome() handles: HERMES_DATA_DIR_SUFFIX
channel installs and profiles/-rooted HERMES_HOME values. Both read
config.yaml from a different home than main.ts does, silently dropping
desktop.electron_flags on the relaunch. Use the shared resolver and cover
both shapes with bundle-entry regression tests.
2026-09-27 06:26:35 -05:00

136 lines
5.4 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterAll, beforeAll, expect, test } from 'vitest'
import { bundleElectronMain } from './bundle-electron-main.mjs'
const repo = resolve(import.meta.dirname, '../../..')
let root
let bundle
let preload
// Run the shipped electron-main.mjs under plain Node as if on Linux. The entry
// relaunches through process.execPath; the preload (inherited through
// NODE_OPTIONS) makes that child print its argv instead of starting the app.
beforeAll(async () => {
root = mkdtempSync(join(tmpdir(), 'hermes-electron-entry-'))
await bundleElectronMain({ source: repo, out: join(root, 'dist'), dev: true })
bundle = join(root, 'dist/electron-main.mjs')
preload = join(root, 'preload.mjs')
writeFileSync(
preload,
`
import { readFileSync } from 'node:fs'
import { registerHooks } from 'node:module'
Object.defineProperty(process, 'platform', { value: 'linux' })
if (process.argv.some(arg => arg.startsWith('--ozone-platform='))) {
console.log(JSON.stringify({ relaunched: process.argv.slice(2) }))
process.exit(0)
}
// Externals the bundle leaves to Electron's node_modules. Stub each with
// every name the bundle imports from it; only app.exit is ever called.
const bundled = readFileSync(${JSON.stringify(bundle)}, 'utf8')
const stub = specifier => {
const names = new Set(specifier === 'electron' ? ['app'] : [])
for (const [, list, from] of bundled.matchAll(/import\\s*\\{([^}]*)\\}\\s*from\\s*"([^"]+)"/g)) {
if (from !== specifier) continue
for (const name of list.split(',')) names.add(name.trim().split(/\\s+as\\s+/)[0])
}
names.delete('')
return [...names].map(name => name === 'app'
? 'export const app = { exit: code => process.exit(code) }'
: 'export const ' + name + ' = {}').join('\\n') + '\\nexport default {}'
}
const externals = new Set(['electron', 'node-pty', 'get-windows'])
registerHooks({
resolve: (specifier, context, next) => externals.has(specifier)
? { url: 'hermes-stub:' + specifier, shortCircuit: true } : next(specifier, context),
load: (url, context, next) => url.startsWith('hermes-stub:')
? { format: 'module', source: stub(url.slice('hermes-stub:'.length)), shortCircuit: true } : next(url, context),
})
`
)
}, 120_000)
afterAll(() => rmSync(root, { recursive: true, force: true }))
function launch(env, config, extraEnv = {}) {
const home = mkdtempSync(join(root, 'home-'))
if (config) {
mkdirSync(home, { recursive: true })
writeFileSync(join(home, 'config.yaml'), config)
}
const out = execFileSync(process.execPath, [bundle, '.'], {
cwd: root,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
HERMES_HOME: home,
NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`,
...env,
...extraEnv
}
})
return JSON.parse(out.trim().split('\n').at(-1))
}
test('the bundled entry relaunches native Wayland sessions with the wayland ozone platform', () => {
expect(launch({ XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' })).toEqual({
relaunched: ['.', '--ozone-platform=wayland']
})
})
test('the bundled entry keeps the WSLg Wayland relaunch', () => {
expect(launch({ WSL_DISTRO_NAME: 'Ubuntu', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' })).toEqual({
relaunched: ['.', '--ozone-platform=wayland']
})
})
test('the bundled entry lets desktop.electron_flags choose the ozone platform', () => {
const config = 'desktop:\n electron_flags:\n - --ozone-platform=x11\n'
expect(launch({ XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' }, config)).toEqual({
relaunched: ['.', '--ozone-platform=x11']
})
})
// The bundled entry must read config.yaml from the same home main.ts does
// (resolveDesktopHermesHome), or desktop.electron_flags set for the real
// profile silently never reaches the relaunch. The two cases below are the
// ones the inline resolution in configuredElectronFlags() got wrong: the
// data-dir suffix channel installs rely on, and multiplexed profiles/ homes.
test('the bundled entry reads desktop.electron_flags through a suffixed home', () => {
const config = 'desktop:\n electron_flags:\n - --ozone-platform=x11\n'
const home = mkdtempSync(join(root, 'suffixed-home-'))
mkdirSync(join(home, '.hermes-canary'), { recursive: true })
writeFileSync(join(home, '.hermes-canary', 'config.yaml'), config)
expect(
launch({ XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' }, undefined, {
HERMES_DATA_DIR_SUFFIX: '-canary',
HERMES_HOME: '',
HOME: home
})
).toEqual({ relaunched: ['.', '--ozone-platform=x11'] })
})
test('the bundled entry reads desktop.electron_flags from the parent of a profiles/-rooted HERMES_HOME', () => {
const config = 'desktop:\n electron_flags:\n - --ozone-platform=x11\n'
const home = mkdtempSync(join(root, 'profiles-home-'))
mkdirSync(home, { recursive: true })
writeFileSync(join(home, 'config.yaml'), config)
expect(
launch({ XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0', DISPLAY: ':0' }, undefined, {
HERMES_HOME: join(home, 'profiles', 'alpha')
})
).toEqual({ relaunched: ['.', '--ozone-platform=x11'] })
})