OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / app-state-relocation.test.js

143 lines · javascript ref:
// Tests for relocating app state (pid / state / log) out of the install
// directory.
//
// Bug being fixed (issue #5 high-priority item 1):
//   `pgh app install` calls fs.rmSync(installPath) before materializing.
//   pidFile / logFile / stateFile all live inside installPath, so they
//   get nuked even when the app's supervisor + child are still running.
//   After install, pgh app status returns running:false / pgh app stop
//   returns "no pid file" — even though the daemon is alive.
//
// Fix: pid/state/log live in <XDG_RUNTIME_DIR>/pgh/<name>/ (or
// ~/.config/pgh/run/<name>/), outside the install path. installManifest
// stays inside the install path. Migration: readers fall back to the
// old in-install location when the new one is absent (one-time
// migration on next start).

import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'

import * as M from '../lib/app-state.js'

let n = 0
function tmpRoot () {
  return mkdtempSync(join(tmpdir(), 'pgh-relocate-' + (n++) + '-'))
}

function setupEnv () {
  // Each test gets fresh storeRoot + runRoot. Paths in app-state.js are
  // env-aware at call-time, so resetting the env here applies to all
  // subsequent calls within this test.
  const storeRoot = tmpRoot()
  const runRoot = tmpRoot()
  process.env.PGH_STORE_ROOT = storeRoot
  process.env.XDG_RUNTIME_DIR = runRoot
  return { storeRoot, runRoot }
}

test('relocation: writePid writes to runDir, readPid reads from runDir', (t) => {
  const { storeRoot, runRoot } = setupEnv()
  const name = 'demo-' + (n++)

  M.writePid(name, 12345)

  const installPath = join(storeRoot, 'apps', name)
  t.absent(existsSync(join(installPath, 'app.pid')), 'pid is NOT inside installPath')
  t.ok(existsSync(join(runRoot, 'pgh', name, 'app.pid')), 'pid IS inside runDir')

  t.is(M.readPid(name), 12345, 'readPid returns the value just written')
})

test('relocation: rmSync(installPath) does NOT destroy pid/state', (t) => {
  // The exact production scenario: app is running (pid + state written),
  // then `pgh app install` does rmSync(installPath). Pid/state must
  // survive so pgh app status / stop / restart still work.
  const { storeRoot } = setupEnv()
  const name = 'demo-' + (n++)

  // Simulate "app is running" — supervisor has written pid + state.
  const installPath = join(storeRoot, 'apps', name)
  mkdirSync(installPath, { recursive: true })
  writeFileSync(join(installPath, 'index.js'), 'console.log("app")') // pretend code
  M.writePid(name, 99999)
  M.writeState(name, { childPid: 100000, startedAt: '2026-05-05T00:00:00Z' })

  t.is(M.readPid(name), 99999, 'pre-install: pid readable')
  t.is(M.readState(name).childPid, 100000, 'pre-install: state readable')

  // Production install path: rmSync the install dir.
  rmSync(installPath, { recursive: true, force: true })

  t.is(M.readPid(name), 99999, 'POST-rmSync: pid still readable (the OTA-orphan fix)')
  t.is(M.readState(name).childPid, 100000, 'POST-rmSync: state still readable')
})

test('migration: legacy pid in installPath is readable as fallback', (t) => {
  // For pgh users with apps started under the old path scheme — pid
  // exists at <install>/app.pid, runDir is empty. readPid must fall
  // back to the legacy location so existing running apps don't appear
  // dead after the upgrade.
  const { storeRoot } = setupEnv()
  const name = 'demo-' + (n++)

  // Hand-write a legacy pid file in the install path (simulates a
  // running app that was started under pre-0.1.23 pgh).
  const installPath = join(storeRoot, 'apps', name)
  mkdirSync(installPath, { recursive: true })
  writeFileSync(join(installPath, 'app.pid'), '7777')

  t.is(M.readPid(name), 7777, 'legacy pid in installPath is read as fallback')

  // After the next writePid (e.g. supervisor restart), the new location
  // takes precedence.
  M.writePid(name, 8888)
  t.is(M.readPid(name), 8888, 'after writePid, runDir wins')
})

test('migration: legacy state in installPath is readable as fallback', (t) => {
  const { storeRoot } = setupEnv()
  const name = 'demo-' + (n++)

  const installPath = join(storeRoot, 'apps', name)
  mkdirSync(installPath, { recursive: true })
  writeFileSync(join(installPath, 'app.state.json'),
    JSON.stringify({ childPid: 5555, startedAt: 'legacy' }))

  t.is(M.readState(name).childPid, 5555, 'legacy state read as fallback')
})

test('relocation: installManifest STAYS in installPath (it is install-time data)', (t) => {
  // Manifest is install-time metadata (entrypoint, runtime, defaults.env).
  // It SHOULD die with the install — every `pgh app install` writes a
  // fresh one. Don't relocate it.
  const { storeRoot } = setupEnv()
  const name = 'demo-' + (n++)

  M.writeInstallManifest(name, { entrypoint: 'server.js', version: '1.0.0' })
  const installPath = join(storeRoot, 'apps', name)
  t.ok(existsSync(join(installPath, '.pgh-install.json')),
    'manifest IS inside installPath (install-time data, will be rewritten on reinstall)')
  t.is(M.readInstallManifest(name).version, '1.0.0', 'manifest readable')
})

test('relocation: clearPid removes pid from runDir', (t) => {
  setupEnv()
  const name = 'demo-' + (n++)

  M.writePid(name, 1234)
  t.is(M.readPid(name), 1234, 'pid set')
  M.clearPid(name)
  t.is(M.readPid(name), null, 'pid cleared')
})

test('paths: pidFile / stateFile / logFile all under runDir', (t) => {
  const { runRoot } = setupEnv()
  const name = 'demo-' + (n++)
  t.ok(M.pidFile(name).startsWith(join(runRoot, 'pgh', name)), 'pidFile under runRoot')
  t.ok(M.stateFile(name).startsWith(join(runRoot, 'pgh', name)), 'stateFile under runRoot')
  t.ok(M.logFile(name).startsWith(join(runRoot, 'pgh', name)), 'logFile under runRoot')
})