import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
getDaemonUnitPath,
installDaemon,
orphanDispatcherPids,
planDaemonInstall,
readInstalledUnit,
renderLaunchdPlist,
renderSystemdUnit,
resolveDaemonPathEnv,
resolveRestartScope,
resolveRunAsUser,
restartDaemon,
supervisorCommands,
uninstallDaemon,
unitInstalledInAnyScope,
} from './events-daemon';
describe('renderSystemdUnit', () => {
it('produces a unit with the expected ExecStart and Environment lines', () => {
const out = renderSystemdUnit({
celiloPath: '/usr/local/bin/celilo',
busDbPath: '/var/lib/celilo/events.db',
pollMs: 1000,
concurrency: 4,
home: '/home/op',
scope: 'user',
pathEnv: '/bun/bin:/usr/bin:/bin',
});
expect(out).toContain(
'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4',
);
expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db');
// celilo#1373 — the unit's environment must resolve bun; the global celilo
// wrapper is a bash script whose first act is `command -v bun`.
expect(out).toContain('Environment=PATH=/bun/bin:/usr/bin:/bin');
expect(out).toContain('Restart=on-failure');
expect(out).toContain('WantedBy=default.target');
expect(out).not.toContain('User=');
});
it('honors --poll-ms and --concurrency overrides', () => {
const out = renderSystemdUnit({
celiloPath: '/c',
busDbPath: '/db',
pollMs: 250,
concurrency: 8,
home: '/h',
scope: 'user',
pathEnv: '/bun/bin:/usr/bin',
});
expect(out).toContain('--poll-ms 250 --concurrency 8');
});
it('system scope sets explicit User= and multi-user.target', () => {
const out = renderSystemdUnit({
celiloPath: '/usr/local/bin/celilo',
busDbPath: '/var/celilo/events.db',
pollMs: 1000,
concurrency: 4,
home: '/root',
scope: 'system',
pathEnv: '/bun/bin:/usr/bin',
runAsUser: 'celilo',
});
expect(out).toContain('User=celilo');
expect(out).toContain('WantedBy=multi-user.target');
expect(out).toContain('journalctl -u celilo-events.service');
expect(out).not.toContain('journalctl --user');
});
});
describe('renderLaunchdPlist', () => {
it('puts log files under the user Library/Logs', () => {
const out = renderLaunchdPlist({
celiloPath: '/c',
busDbPath: '/db',
pollMs: 1000,
concurrency: 4,
home: '/Users/op',
scope: 'user',
pathEnv: '/bun/bin:/usr/bin:/bin',
});
expect(out).toContain('/Users/op/Library/Logs/celilo-events.out.log');
expect(out).toContain('/Users/op/Library/Logs/celilo-events.err.log');
expect(out).toContain('Label');
expect(out).toContain('com.celilo.events');
expect(out).toContain('RunAtLoad');
expect(out).toContain('KeepAlive');
expect(out).not.toContain('UserName');
});
// celilo#1373 — launchd starts jobs with its own default PATH (no bun),
// and the global celilo wrapper is a bash script whose first act is
// `command -v bun`. The rendered plist MUST carry a PATH that resolves
// bun, inside EnvironmentVariables, or the unit crash-loops forever.
it('carries a PATH in EnvironmentVariables that resolves bun', () => {
const out = renderLaunchdPlist({
celiloPath: '/Users/op/.bun/bin/celilo',
busDbPath: '/db',
pollMs: 1000,
concurrency: 4,
home: '/Users/op',
scope: 'user',
pathEnv: '/bun/bin:/usr/local/bin:/usr/bin:/bin',
});
expect(out).toContain('PATH');
expect(out).toContain('/bun/bin:/usr/local/bin:/usr/bin:/bin');
// PATH lives INSIDE the EnvironmentVariables dict, after EVENT_BUS_DB.
const envIdx = out.indexOf('EnvironmentVariables');
const pathIdx = out.indexOf('PATH');
expect(envIdx).toBeGreaterThan(-1);
expect(pathIdx).toBeGreaterThan(envIdx);
});
it('system scope sets UserName and logs under /Library/Logs', () => {
const out = renderLaunchdPlist({
celiloPath: '/c',
busDbPath: '/db',
pollMs: 1000,
concurrency: 4,
home: '/Users/op',
scope: 'system',
pathEnv: '/bun/bin:/usr/bin',
runAsUser: 'celilo',
});
expect(out).toContain('UserName');
expect(out).toContain('celilo');
expect(out).toContain('/Library/Logs/celilo-events.out.log');
expect(out).toContain('/Library/Logs/celilo-events.err.log');
expect(out).not.toContain('/Users/op/Library/Logs');
});
});
describe('resolveDaemonPathEnv', () => {
// celilo#1373 — the whole point of the PATH key is that the bun the
// wrapper needs is actually in the directory we wrote. First colon
// segment, and a bun executable must exist in it.
it('starts with a directory that contains a bun executable', () => {
const firstDir = resolveDaemonPathEnv().split(':')[0] ?? '';
expect(firstDir).not.toBe('');
expect(existsSync(join(firstDir, 'bun'))).toBe(true);
});
it('planDaemonInstall pins the PATH into the rendered unit', () => {
const dir = mkdtempSync(join(tmpdir(), 'celilo-pathenv-'));
const celiloPath = join(dir, 'fake-celilo');
writeFileSync(celiloPath, '#!/bin/sh\n', { mode: 0o755 });
const plan = planDaemonInstall({
platform: 'darwin',
scope: 'user',
home: join(dir, 'home'),
celiloPath,
pathEnv: '/bun/bin:/usr/bin:/bin',
});
expect(plan.unitContent).toContain('/bun/bin:/usr/bin:/bin');
});
});
describe('getDaemonUnitPath', () => {
it('returns the systemd user path on linux', () => {
expect(getDaemonUnitPath('linux', '/home/op')).toBe(
'/home/op/.config/systemd/user/celilo-events.service',
);
});
it('returns the LaunchAgents path on darwin', () => {
expect(getDaemonUnitPath('darwin', '/Users/op')).toBe(
'/Users/op/Library/LaunchAgents/com.celilo.events.plist',
);
});
it('returns the system paths for system scope', () => {
expect(getDaemonUnitPath('linux', '/home/op', 'system')).toBe(
'/etc/systemd/system/celilo-events.service',
);
expect(getDaemonUnitPath('darwin', '/Users/op', 'system')).toBe(
'/Library/LaunchDaemons/com.celilo.events.plist',
);
});
});
describe('resolveRunAsUser', () => {
it('honors an explicit override', () => {
expect(resolveRunAsUser('/var/celilo/events.db', 'celilo')).toBe('celilo');
});
it('falls back to the current user when the state dir is missing', () => {
const me = resolveRunAsUser('/no/such/dir/events.db');
expect(me.length).toBeGreaterThan(0);
});
});
describe('installDaemon / uninstallDaemon roundtrip', () => {
let dir: string;
let celiloPath: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'daemon-test-'));
celiloPath = join(dir, 'fake-celilo');
// Touch a fake celilo executable so resolveCeliloPath via override is happy.
writeFileSync(celiloPath, '#!/bin/sh\nexit 0\n');
chmodSync(celiloPath, 0o755);
});
afterEach(() => {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
/* ignore */
}
});
it('writes a systemd unit and uninstall removes it', () => {
const home = join(dir, 'home');
const installed = installDaemon({
platform: 'linux',
home,
celiloPath,
busDbPath: '/var/lib/celilo/events.db',
});
expect(existsSync(installed.unitPath)).toBe(true);
expect(installed.scope).toBe('user');
expect(installed.runAsUser).toBeUndefined();
expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service'));
expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload');
const removed = uninstallDaemon({ platform: 'linux', home });
expect(removed.removed).toBe(true);
expect(existsSync(installed.unitPath)).toBe(false);
});
// #610 — install-daemon defaults to USER scope, so running it on a box whose
// system unit Ansible already installed silently produced a second daemon of
// the same name. That is how celilo-mgr ended up with two dispatchers.
it('refuses to install when the other scope already has a unit', () => {
const home = join(dir, 'home');
const systemRoot = join(dir, 'root');
installDaemon({
platform: 'linux',
scope: 'system',
home,
systemRoot,
celiloPath,
busDbPath: '/var/lib/celilo/events.db',
});
expect(() =>
installDaemon({
platform: 'linux',
scope: 'user',
home,
systemRoot,
celiloPath,
busDbPath: '/var/lib/celilo/events.db',
}),
).toThrow(/system-scope unit is already installed/);
// Nothing written: refusing must not leave the second unit behind.
expect(existsSync(getDaemonUnitPath('linux', home, 'user'))).toBe(false);
});
// --print creates nothing, and the celilo-mgmt Ansible role captures it —
// throwing there would wedge the deploy of the tool used to fix the conflict.
it('reports the conflict from planDaemonInstall without throwing', () => {
const home = join(dir, 'home');
const systemRoot = join(dir, 'root');
installDaemon({
platform: 'linux',
scope: 'system',
home,
systemRoot,
celiloPath,
busDbPath: '/var/lib/celilo/events.db',
});
const plan = planDaemonInstall({
platform: 'linux',
scope: 'user',
home,
systemRoot,
celiloPath,
busDbPath: '/var/lib/celilo/events.db',
});
expect(plan.conflict?.scope).toBe('system');
expect(plan.unitContent).toContain('ExecStart=');
});
it('writes a launchd plist and uninstall removes it', () => {
const home = join(dir, 'home');
const installed = installDaemon({
platform: 'darwin',
home,
celiloPath,
busDbPath: '/Users/op/celilo/events.db',
});
expect(existsSync(installed.unitPath)).toBe(true);
expect(installed.unitPath).toBe(join(home, 'Library/LaunchAgents/com.celilo.events.plist'));
expect(readFileSync(installed.unitPath, 'utf-8')).toContain('com.celilo.events');
expect(installed.nextSteps[0]).toContain('launchctl load');
const removed = uninstallDaemon({ platform: 'darwin', home });
expect(removed.removed).toBe(true);
});
it('install is idempotent — second call overwrites with new content', () => {
const home = join(dir, 'home');
installDaemon({
platform: 'linux',
home,
celiloPath,
busDbPath: '/db1',
pollMs: 1000,
});
const second = installDaemon({
platform: 'linux',
home,
celiloPath,
busDbPath: '/db2',
pollMs: 500,
});
const written = readFileSync(second.unitPath, 'utf-8');
expect(written).toContain('EVENT_BUS_DB=/db2');
expect(written).toContain('--poll-ms 500');
expect(written).not.toContain('EVENT_BUS_DB=/db1');
});
it('uninstall on a missing unit reports not-removed', () => {
const home = join(dir, 'home');
const result = uninstallDaemon({ platform: 'linux', home });
expect(result.removed).toBe(false);
expect(result.nextSteps[0]).toContain('nothing to clean up');
});
it('readInstalledUnit returns content when present, exists:false when not', () => {
const home = join(dir, 'home');
const before = readInstalledUnit({ platform: 'linux', home });
expect(before.exists).toBe(false);
installDaemon({
platform: 'linux',
home,
celiloPath,
busDbPath: '/db',
});
const after = readInstalledUnit({ platform: 'linux', home });
expect(after.exists).toBe(true);
if (after.exists) {
expect(after.content).toContain('events run');
}
});
it('rejects --celilo-path overrides that do not exist', () => {
expect(() =>
installDaemon({
platform: 'linux',
home: join(dir, 'home'),
celiloPath: '/no/such/celilo',
busDbPath: '/db',
}),
).toThrow(/does not exist/);
});
});
// --- restart (celilo#604) ---------------------------------------------
/**
* A fake bus + supervisor. `restartUnit()` only produces a new dispatcher if no
* live one is left — that IS the one-dispatcher-per-bus guard (#584), and it's
* what makes the orphan case fatal rather than merely untidy.
*/
function fakeFleet(opts: {
initial: Array<{ pid: number; version: string | null; supervised?: boolean }>;
newVersion: string;
}) {
let live = opts.initial.map((d) => ({ ...d }));
const supervised = opts.initial.find((d) => d.supervised);
const killed: number[] = [];
let nextPid = 9000;
let restarts = 0;
return {
killed,
restarts: () => restarts,
deps: {
liveDispatchers: () => live.map((d) => ({ pid: d.pid, version: d.version })),
supervisorPid: () => supervised?.pid ?? null,
kill: (pid: number) => {
killed.push(pid);
live = live.filter((d) => d.pid !== pid);
},
restartUnit: () => {
restarts++;
live = live.filter((d) => d.pid !== supervised?.pid);
if (live.length > 0) return; // guard refuses: another dispatcher is live
live = [{ pid: nextPid++, version: opts.newVersion }];
},
sleep: async () => {},
},
};
}
describe('orphanDispatcherPids', () => {
it('names every live dispatcher the supervisor does not own', () => {
expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], 200)).toEqual([100]);
});
it('treats all of them as orphans when the supervisor owns none', () => {
expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], null)).toEqual([100, 200]);
});
});
describe('restartDaemon', () => {
let dir: string;
let home: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'celilo-restart-'));
home = join(dir, 'home');
installDaemon({ platform: 'linux', home, celiloPath: '/bin/sh', busDbPath: '/db' });
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('stops an orphan the supervisor does not own, then brings up new code', async () => {
// celilo-mgr exactly: PPID 1, stale v0.1.8, systemd owns nothing.
const fleet = fakeFleet({
initial: [{ pid: 3639051, version: '0.1.8' }],
newVersion: '0.2.0',
});
const result = await restartDaemon(
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
fleet.deps,
);
expect(fleet.killed).toContain(3639051);
expect(result.orphansKilled).toEqual([3639051]);
expect(result.dispatcher.version).toBe('0.2.0');
expect(result.dispatcher.pid).not.toBe(3639051);
});
it('does not kill the dispatcher the supervisor already owns', async () => {
const fleet = fakeFleet({
initial: [{ pid: 4242, version: '0.1.8', supervised: true }],
newVersion: '0.2.0',
});
const result = await restartDaemon(
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 },
fleet.deps,
);
expect(fleet.killed).toEqual([]);
expect(result.orphansKilled).toEqual([]);
expect(result.dispatcher.version).toBe('0.2.0');
});
it('fails when the restarted dispatcher still reports the OLD version', async () => {
// systemctl returned 0, a dispatcher is live — and it is the stale code.
// The whole point: never report success off the supervisor's exit code.
const fleet = fakeFleet({
initial: [{ pid: 3639051, version: '0.1.8' }],
newVersion: '0.1.8',
});
await expect(
restartDaemon(
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
fleet.deps,
),
).rejects.toThrow(/no dispatcher on v0\.2\.0 came up/);
});
it('fails rather than reporting success when nothing comes back at all', async () => {
const fleet = fakeFleet({ initial: [], newVersion: '0.2.0' });
fleet.deps.restartUnit = () => {}; // unit crash-loops; bus stays empty
await expect(
restartDaemon(
{ platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 },
fleet.deps,
),
).rejects.toThrow(/no dispatcher is live on the bus/);
});
it('refuses when no supervisor unit is installed', () => {
expect(() => resolveRestartScope({ platform: 'linux', home: join(dir, 'empty') })).toThrow(
/no supervisor unit installed/,
);
});
// apt-upgrade runs restart-daemon on every box, including ones that never
// installed the daemon. The CLI branches on this so an upgrade with nothing
// stale to fix is not failed by a missing unit.
it('unitInstalledInAnyScope sees an installed unit, and its absence', () => {
expect(unitInstalledInAnyScope('linux', home)).toBe(true);
expect(unitInstalledInAnyScope('linux', join(dir, 'empty'))).toBe(false);
});
});
describe('supervisorCommands', () => {
it('uses systemctl --user for user scope', () => {
expect(supervisorCommands('linux', 'user').restart).toEqual([
'systemctl',
'--user',
'restart',
'celilo-events.service',
]);
});
// The system unit is root-owned and celilo is unprivileged. Drop the sudo
// and every apt-upgrade on celilo-mgr reports "dispatcher still on old
// code" — honest, and never able to do its job. celilo-bootstrap ships the
// scoped grant for exactly these two argvs, so they must match it verbatim.
it('goes through sudo for system scope, matching the shipped sudoers grant', () => {
expect(supervisorCommands('linux', 'system').restart).toEqual([
'sudo',
'systemctl',
'restart',
'celilo-events.service',
]);
expect(supervisorCommands('linux', 'system').mainPid).toEqual([
'sudo',
'systemctl',
'show',
'celilo-events.service',
'-p',
'MainPID',
'--value',
]);
});
it('the shipped sudoers grant covers exactly the argvs used', () => {
const grant = readFileSync(
join(
import.meta.dir,
'../../../../packaging/celilo-bootstrap/conffiles/sudoers.d-celilo-events-restart',
),
'utf-8',
);
const cmds = supervisorCommands('linux', 'system');
for (const argv of [cmds.restart, cmds.mainPid as string[]]) {
// `sudo` itself is the invoker, not part of the granted command.
expect(grant).toContain(`/usr/bin/${argv.slice(1).join(' ')}`);
}
});
});