OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / child-env.test.js

93 lines · javascript ref:
// The env a supervised app is spawned with must be RE-RESOLVED on every respawn.
//
// Regression: the supervisor parsed PGH_SUP_CHILD_ENV once at start and reused it forever, so an OTA
// respawn re-exec'd the new code with the old environment. Measured on tabertron 2026-08-05 —
// favela-web came up missing both a variable added in that release AND one that had been in the
// manifest since 0.2.25 (ATPROTO_OVERLAY_ROOT, which points it at hpp's shared record store).
// Everything still rendered, so nothing surfaced it.
import test from 'brittle'
import { resolveChildEnv, envDelta, SUP_ONLY } from '../lib/child-env.js'

const MANIFEST = { defaults: { env: { FOO: 'from-manifest', ADDED_LATER: 'yes' } } }
const deps = (manifest, env) => ({
  readInstallManifest: () => manifest,
  effectiveEnv: () => ({ ...env })
})

test('picks up manifest env that did not exist when the supervisor started', (t) => {
  const fallback = { FOO: 'stale' } // what the parent computed at start
  const env = resolveChildEnv({
    name: 'app', fallback,
    ...deps(MANIFEST, { FOO: 'from-manifest', ADDED_LATER: 'yes' })
  })
  t.is(env.FOO, 'from-manifest', 'a changed value is picked up')
  t.is(env.ADDED_LATER, 'yes', 'and a NEW variable appears — this is the actual bug')
  t.is(fallback.FOO, 'stale', 'the fallback object is not mutated')
})

test('strips the supervisor plumbing — it was never in the child env', (t) => {
  // effectiveEnv() starts from process.env, which in the SUPERVISOR contains all of this. Leaking it
  // would hand the app its own supervisor config, including the whole child-env JSON blob.
  const leaky = { FOO: 'x' }
  for (const k of SUP_ONLY) leaky[k] = 'LEAKED'
  const env = resolveChildEnv({ name: 'app', fallback: {}, ...deps(MANIFEST, leaky) })
  for (const k of SUP_ONLY) t.absent(k in env, `${k} is stripped`)
  t.is(env.FOO, 'x', 'while real variables survive')
})

test('always sets PGH_APP_NAME — the install name can differ from the package name', (t) => {
  // A realistic env is never empty — effectiveEnv() always merges process.env, so PATH is present.
  const env = resolveChildEnv({ name: 'pear-git-v3-dev', fallback: {}, ...deps(MANIFEST, { PATH: '/usr/bin' }) })
  t.is(env.PGH_APP_NAME, 'pear-git-v3-dev', 'adopted dev installs register under the INSTALL name')
})

// ── never throws: down is worse than stale ──────────────────────────────────────────────────────
test('a throwing manifest read falls back rather than leaving the app down', (t) => {
  const warns = []
  const env = resolveChildEnv({
    name: 'app', fallback: { FOO: 'stale' }, onWarn: (m) => warns.push(m),
    readInstallManifest: () => { throw new Error('ENOENT') },
    effectiveEnv: () => ({})
  })
  t.is(env.FOO, 'stale', 'the app still starts, with the env it had')
  t.is(warns.length, 1, 'and it is LOUD — a silent fallback is how the original bug hid')
})

test('a throwing effectiveEnv falls back too', (t) => {
  const env = resolveChildEnv({
    name: 'app', fallback: { FOO: 'stale' },
    readInstallManifest: () => MANIFEST,
    effectiveEnv: () => { throw new Error('bad user config') }
  })
  t.is(env.FOO, 'stale')
})

test('no manifest is not an error — adopted apps may have none', (t) => {
  const env = resolveChildEnv({ name: 'app', fallback: { FOO: 'stale' }, ...deps(null, {}) })
  t.is(env.FOO, 'stale', 'keeps what works')
})

test('a non-object or EMPTY env falls back rather than spawning with nothing', (t) => {
  const bad = (v) => resolveChildEnv({
    name: 'app', fallback: { FOO: 'stale' },
    readInstallManifest: () => MANIFEST, effectiveEnv: () => v
  })
  t.is(bad(null).FOO, 'stale', 'null falls back')
  t.is(bad('nope').FOO, 'stale', 'a non-object falls back')
  // Found by this test: {...null} is {}, which passed a typeof-only guard. Spawning with an empty
  // env strips PATH and HOME — the app dies in a way that looks nothing like a config problem.
  t.is(bad({}).FOO, 'stale', 'an EMPTY object falls back too')
})

test('a missing name falls back', (t) => {
  t.is(resolveChildEnv({ fallback: { FOO: 'stale' }, ...deps(MANIFEST, {}) }).FOO, 'stale')
})

// ── the delta report ────────────────────────────────────────────────────────────────────────────
test('envDelta names added, removed AND changed keys', (t) => {
  t.alike(envDelta({ A: '1', B: '2' }, { A: '1', B: 'CHANGED', C: 'new' }), ['B', 'C'],
    'a changed VALUE counts, not just a new key')
  t.alike(envDelta({ A: '1' }, { A: '1' }), [], 'no change reports nothing')
  t.alike(envDelta(null, { A: '1' }), [], 'the first spawn has nothing to compare against')
})