Files
hermes-agent/apps/desktop/scripts/bundle-env.test.mjs
ethernet 0ecfcc2881 fix(ci): predict the desktop smoke's Hermes home from the baked bundle env
Commit desktop bundles can bake environment defaults/clears (--bundle-unset
HERMES_DESKTOP_USER_DATA_DIR, HERMES_HOME=null) that stomp the smoke driver's
--home/--user-data pin, so every native smoke threw "Desktop did not honor the
isolated home and userData directories".

Instead of pinning, the driver replays the bundle env over its launch env
through the same resolver the app runs, seeds the predicted home (bailing
rather than wiping when it is not empty), and verifies the app landed there
via a new hermesHome report on the version bridge. The --user-data equality
check now applies only when the artifact bakes no env.

- Extract the pure path resolver into electron/data-paths.mjs (data-paths.ts
  is now a typed re-export) so Node's type-stripped driver can import it.
- Add applyBundleEnvironment/validateBundleEnvironment as the pure twin of the
  bundle banner, pinned by a lockstep test.
- Record bundleEnv in the install stamp and add readBundledBundleEnv.
- Report hermesHome from hermes:version and assert it equals the predicted home.
2026-09-15 20:32:31 -04:00

92 lines
5.5 KiB
JavaScript

import { build } from 'esbuild'
import { execFileSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { expect, test } from 'vitest'
import { applyBundleEnvironment, environmentDefaultsBanner } from './bundle-env.mjs'
test('explicit clears beat inherited homes and prevent Windows registry fallback before spawning', async () => {
const root = mkdtempSync(join(tmpdir(), 'hermes-bundle-clear-'))
const paths = fileURLToPath(new URL('../electron/data-paths.ts', import.meta.url))
const defaults = { HERMES_HOME: null, HERMES_DATA_DIR_SUFFIX: 'magic-test' }
try {
const entry = join(root, 'entry.mjs')
writeFileSync(entry, `
import {resolveDesktopHermesHome} from ${JSON.stringify(paths)};
import {execFileSync} from 'node:child_process';
let registryReads = 0;
const home = resolveDesktopHermesHome({
home: 'C:/Users/test', platform: 'win32', env: process.env,
readWindowsHome: () => { registryReads++; return 'C:/old-hermes'; }
});
console.log(JSON.stringify({cleared: process.env.HERMES_HOME, home, registryReads,
child: execFileSync(process.execPath, ['-p', 'process.env.HERMES_HOME'], {
env: {...process.env, HERMES_HOME: home}, encoding: 'utf8'
}).trim()}));
`)
const outfile = join(root, 'bundle.mjs')
await build({ entryPoints: [entry], bundle: true, platform: 'node', format: 'esm', outfile,
banner: { js: environmentDefaultsBanner(JSON.stringify(defaults)) } })
const env = { ...process.env, HERMES_HOME: 'C:/old-hermes', LOCALAPPDATA: 'C:/Users/test/AppData/Local' }
delete env.HERMES_DATA_DIR_SUFFIX
delete env.HERMES_DESKTOP_USER_DATA_DIR
const actual = JSON.parse(execFileSync(process.execPath, [outfile], { env, encoding: 'utf8' }))
expect(actual).toEqual({ cleared: '', home: 'C:\\Users\\test\\AppData\\Local\\hermesmagic-test',
child: 'C:\\Users\\test\\AppData\\Local\\hermesmagic-test', registryReads: 0 })
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('applyBundleEnvironment replays the banner semantics for defaults, runtime overrides and clears', async () => {
const root = mkdtempSync(join(tmpdir(), 'hermes-bundle-pure-'))
const defaults = { KEEP: 'default', MISSING: 'default', CLEARED: null, OVERRIDDEN: 'default', SUFFIX: 'baked' }
const keys = Object.keys(defaults)
const base = { KEEP: 'x', CLEARED: 'runtime', OVERRIDDEN: 'runtime', SUFFIX: 'explicit' }
try {
// The bundled banner must produce the same effective environment the pure
// function computes, so the smoke driver can predict the app's home from
// the same bundle env data without running a child process.
writeFileSync(join(root, 'reader.mjs'), `export const values = Object.fromEntries(${JSON.stringify(keys)}.map(key => [key, process.env[key]]));`, 'utf8')
const entry = join(root, 'entry.mjs')
writeFileSync(entry, `import {values} from './reader.mjs'; console.log(JSON.stringify(values));`, 'utf8')
const outfile = join(root, 'bundle.mjs')
await build({ entryPoints: [entry], bundle: true, platform: 'node', format: 'esm', outfile, banner: { js: environmentDefaultsBanner(JSON.stringify(defaults)) } })
const viaBanner = JSON.parse(execFileSync(process.execPath, [outfile], { env: { ...process.env, ...base }, encoding: 'utf8' }))
const viaFunction = Object.fromEntries(keys.map(key => [key, applyBundleEnvironment(base, defaults)[key]]))
expect(viaFunction).toEqual(viaBanner)
expect(viaFunction).toEqual({
KEEP: 'x', MISSING: 'default', CLEARED: '', OVERRIDDEN: 'runtime', SUFFIX: 'explicit',
})
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('baked defaults precede imported module initialization and reach children without overriding explicit env', async () => {
const root = mkdtempSync(join(tmpdir(), 'hermes-bundle-env-'))
const defaults = { HERMES_GUEST_ONBOARDING: '1', HERMES_DATA_DIR_SUFFIX: 'magic-test', LITERAL: 'a=b "q"\n$(no)', EMPTY: '' }
const env = { ...process.env }
for (const key of Object.keys(defaults)) {
delete env[key]
}
try {
writeFileSync(join(root, 'reader.mjs'), `export const values = Object.fromEntries(${JSON.stringify(Object.keys(defaults))}.map(key => [key, process.env[key]]));`, 'utf8')
const entry = join(root, 'entry.mjs')
writeFileSync(entry, `import {values} from './reader.mjs'; import {execFileSync} from 'node:child_process'; console.log(JSON.stringify({values, child: execFileSync(process.execPath, ['-p', 'process.env.HERMES_DATA_DIR_SUFFIX'], {encoding:'utf8'}).trim()}));`, 'utf8')
const outfile = join(root, 'bundle.mjs')
await build({ entryPoints: [entry], bundle: true, platform: 'node', format: 'esm', outfile, banner: { js: environmentDefaultsBanner(JSON.stringify(defaults)) } })
const run = extra => JSON.parse(execFileSync(process.execPath, [outfile], { env: { ...env, ...extra }, encoding: 'utf8' }))
expect(run({})).toEqual({ values: defaults, child: defaults.HERMES_DATA_DIR_SUFFIX })
expect(run({ HERMES_DATA_DIR_SUFFIX: '-explicit', HERMES_GUEST_ONBOARDING: '' })).toEqual({ values: { ...defaults, HERMES_DATA_DIR_SUFFIX: '-explicit', HERMES_GUEST_ONBOARDING: '' }, child: '-explicit' })
for (const bad of ['[]', 'null', '{"BAD-NAME":"x"}', '{"NAME":1}', '{"NAME":"\\u0000"}']) {
expect(() => environmentDefaultsBanner(bad)).toThrow()
}
} finally {
rmSync(root, { recursive: true, force: true })
}
})