test/service.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { execFile, spawn } from 'child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from 'fs';
import { createServer } from 'net';
import { tmpdir } from 'os';
import { join } from 'path';
import {
plist, systemdUnit, parseServiceFile, parseServiceState, serviceDefinition, loginShellPath, mergePath,
checkoutDecision, waitForIdle, runCommand, capLog, formatUptime, serviceFilePath, LABEL, UNIT,
} from '../server/service.js';
import { readServerFile, writeServerFile } from '../server/control.js';
import { removeTempDir } from './temp-dir.js';
const ROOT = process.cwd();
// The "restarted" server's pid: one that is alive on every platform (there is
// no pid 1 on Windows), and not this process's.
const NEW_PID = process.ppid;
const tmp = (p) => mkdtempSync(join(tmpdir(), p));
// No real launchctl, systemctl, git or npm: `run` answers from `answer` and
// records every call. HOME is a temp folder, so nothing lands in ~/Library.
function fakeCtx(over = {}) {
const home = over.home || tmp('a007-svc-home-');
const calls = [];
const out = [];
let clock = 0;
const ctx = {
calls, out, home,
run: async (cmd, args, opts) => {
calls.push([cmd, ...args].join(' '));
return { code: 0, stdout: '', stderr: '', ...(await over.answer?.(cmd, args, opts)) };
},
platform: 'linux',
uid: 501,
user: 'ada',
env: { HOME: home, SHELL: '/bin/zsh', PATH: '/usr/bin', AGENT007_CONFIG_DIR: join(home, '.agent-007') },
cwd: over.cwd || tmp('a007-svc-cwd-'),
execPath: '/opt/node/bin/node',
root: '/opt/app',
bin: '/opt/app/bin/agent-007.js',
port: 7007,
host: '127.0.0.1',
portState: async () => 'free',
readServer: () => null,
callServer: async () => null,
version: () => '1.0.0.0',
sleep: async () => {},
now: () => (clock += 1000),
log: (s) => out.push(s),
err: (s) => out.push(`ERR ${s}`),
write: (s) => out.push(s),
cmd: (sub) => `agent007 ${sub}`.trim(),
...over,
};
return ctx;
}
const LOGIN_PATH = '/home/ada/.nvm/versions/node/v22/bin:/home/ada/.local/bin:/usr/bin';
const loginShell = (cmd) => (cmd === '/bin/zsh' ? { stdout: `welcome!\n__A007_PATH__${LOGIN_PATH}__A007_PATH__\n` } : {});
describe('service definition', () => {
const def = {
args: ['/Users/a b/.nvm/node', '/Users/a b/app/bin/agent-007.js'],
env: { PATH: '/x:/y', HOME: '/Users/a b', AGENT007_SERVICE: 'launchd', AGENT007_LOG: '/Users/a b/.agent-007/logs/server.log', HOST: 'a&b<c>' },
cwd: '/Users/a b/app',
log: '/Users/a b/.agent-007/logs/server.log',
};
it('plist: absolute node and bin, the PATH, the log, KeepAlive; XML-escaped and read back', () => {
const text = plist(def);
expect(text).toContain(`<key>Label</key><string>${LABEL}</string>`);
expect(text).toContain('<string>/Users/a b/.nvm/node</string>');
expect(text).toContain('<key>PATH</key><string>/x:/y</string>');
expect(text).toContain('<key>StandardOutPath</key><string>/Users/a b/.agent-007/logs/server.log</string>');
expect(text).toContain('<key>KeepAlive</key><true/>');
expect(text).toContain('a&b<c>');
expect(parseServiceFile(text)).toEqual({ args: def.args, env: def.env });
});
it('systemd unit: quoted args, % and $ escaped, append to the log, Restart=always; read back', () => {
const odd = { ...def, args: ['/n/node', '/a 100%/$HOME/bin.js'], env: { ...def.env, PATH: '/p%q:/r"s' } };
const text = systemdUnit(odd);
expect(text).toContain('ExecStart="/n/node" "/a 100%%/$$HOME/bin.js"');
expect(text).toContain('Environment="PATH=/p%%q:/r\\"s"');
expect(text).toContain('StandardOutput=append:/Users/a b/.agent-007/logs/server.log');
expect(text).toContain('Restart=always');
expect(text).toContain('WantedBy=default.target');
expect(parseServiceFile(text)).toEqual({ args: odd.args, env: odd.env });
});
it('bakes PORT/HOST only when set; log under the config dir; a clone runs in itself, an install in the config dir', () => {
const base = { kind: 'systemd', execPath: '/n/node', bin: '/c/bin/agent-007.js', path: '/p', home: '/h' };
const clone = serviceDefinition({ ...base, root: ROOT, launchEnv: { AGENT007_CONFIG_DIR: '/cfg' } });
expect(clone.args).toEqual(['/n/node', '/c/bin/agent-007.js']);
expect(clone.env).toEqual({ PATH: '/p', HOME: '/h', AGENT007_SERVICE: 'systemd', AGENT007_LOG: join('/cfg', 'logs', 'server.log'), AGENT007_CONFIG_DIR: '/cfg' });
expect(clone.cwd).toBe(ROOT);
const npm = serviceDefinition({ ...base, root: tmp('a007-pkg-'), launchEnv: { AGENT007_CONFIG_DIR: '/cfg', PORT: '8008', HOST: '0.0.0.0' } });
expect(npm.env).toMatchObject({ PORT: '8008', HOST: '0.0.0.0' });
expect(npm.cwd).toBe('/cfg');
});
it('PATH comes from a bare login shell, read between markers; node\'s folder first, no repeats', async () => {
let seen;
const run = async (cmd, args, opts) => { seen = { cmd, args, opts }; return loginShell(cmd); };
expect(await loginShellPath(run, { SHELL: '/bin/zsh', HOME: '/h', USER: 'ada', PATH: '/junk/node_modules/.bin' })).toBe(LOGIN_PATH);
expect(seen.args[0]).toBe('-ilc');
expect(seen.opts.env).toEqual({ HOME: '/h', USER: 'ada', SHELL: '/bin/zsh', PATH: '/usr/bin:/bin:/usr/sbin:/sbin' });
expect(await loginShellPath(async () => ({ code: 1, stdout: '' }), {})).toBe(null);
expect(mergePath('/opt/node/bin/node', '/a:/opt/node/bin:/b:/a')).toBe('/opt/node/bin:/a:/b');
});
it('service state from launchctl print and systemctl show', () => {
expect(parseServiceState('launchd', `gui/501/${LABEL} = {\n\tactive count = 1\n\tstate = running\n\tpid = 4242\n\tendpoints = {\n\t\tstate = active\n\t}\n}`))
.toEqual({ running: true, state: 'running', pid: 4242 });
expect(parseServiceState('launchd', '\tstate = not running\n')).toEqual({ running: false, state: 'not', pid: null });
expect(parseServiceState('systemd', 'ActiveState=active\nMainPID=99\n')).toEqual({ running: true, state: 'active', pid: 99 });
expect(parseServiceState('systemd', 'ActiveState=failed\nMainPID=0\n')).toEqual({ running: false, state: 'failed', pid: null });
});
it('uptime reads short', () => {
expect([12, 125, 7260, 90000].map(formatUptime)).toEqual(['12s', '2m', '2h 1m', '1d 1h']);
});
});
describe('install / uninstall', () => {
it('linux: writes the unit under the temp HOME, then daemon-reload, enable, restart and linger', async () => {
const ctx = fakeCtx({ answer: (cmd, args) => (cmd === 'loginctl' && args[0] === 'show-user' ? { stdout: 'yes\n' } : loginShell(cmd)) });
expect(await runCommand('install', {}, ctx)).toBe(0);
const file = join(ctx.home, '.config', 'systemd', 'user', UNIT);
const { args, env } = parseServiceFile(readFileSync(file, 'utf8'));
expect(args).toEqual(['/opt/node/bin/node', '/opt/app/bin/agent-007.js']);
expect(env.PATH).toBe(`/opt/node/bin:${LOGIN_PATH}`);
expect(env.AGENT007_LOG).toBe(join(ctx.home, '.agent-007', 'logs', 'server.log'));
expect(ctx.calls.filter(c => !c.startsWith('/bin/zsh'))).toEqual([
'systemctl --user daemon-reload', `systemctl --user enable ${UNIT}`, `systemctl --user restart ${UNIT}`, 'loginctl show-user ada -p Linger --value',
]);
expect(ctx.out.join('\n')).toContain('Installed');
expect(await runCommand('uninstall', {}, ctx)).toBe(0);
expect(existsSync(file)).toBe(false);
expect(ctx.calls).toContain(`systemctl --user disable --now ${UNIT}`);
expect(existsSync(join(ctx.home, '.agent-007', 'logs'))).toBe(true);
});
it('linger off and not fixable: says how to fix it', async () => {
const ctx = fakeCtx({ answer: (cmd) => (cmd === 'loginctl' ? { stdout: 'no\n' } : loginShell(cmd)) });
expect(await runCommand('install', {}, ctx)).toBe(0);
expect(ctx.calls).toContain('loginctl enable-linger ada');
expect(ctx.out.join('\n')).toContain('sudo loginctl enable-linger ada');
});
it('macOS: bootout, then bootstrap, retrying while launchd answers EIO', async () => {
let tries = 0;
const ctx = fakeCtx({
platform: 'darwin',
answer: (cmd, args) => {
if (cmd === 'launchctl' && args[0] === 'print') return { code: 113 };
if (cmd === 'launchctl' && args[0] === 'bootstrap') return { code: ++tries < 3 ? 5 : 0 };
return loginShell(cmd);
},
});
expect(await runCommand('install', {}, ctx)).toBe(0);
const file = join(ctx.home, 'Library', 'LaunchAgents', `${LABEL}.plist`);
expect(serviceFilePath('launchd', ctx.home)).toBe(file);
expect(parseServiceFile(readFileSync(file, 'utf8')).env.AGENT007_SERVICE).toBe('launchd');
expect(ctx.calls.filter(c => c.startsWith('launchctl bootstrap'))).toHaveLength(3);
expect(ctx.calls[1]).toBe(`launchctl bootout gui/501/${LABEL}`);
});
it('--dry-run prints the file and writes nothing', async () => {
const ctx = fakeCtx({ answer: loginShell });
expect(await runCommand('install', { 'dry-run': true }, ctx)).toBe(0);
expect(ctx.out.join('\n')).toContain('ExecStart="/opt/node/bin/node" "/opt/app/bin/agent-007.js"');
expect(existsSync(join(ctx.home, '.config'))).toBe(false);
expect(ctx.calls.filter(c => !c.startsWith('/bin/zsh'))).toEqual([]);
});
it('refuses while a server runs in a terminal, or the port is taken; Windows and npx are not supported', async () => {
const info = { pid: process.pid, port: 7007, token: 't' };
const terminal = fakeCtx({ readServer: () => info, callServer: async () => ({ pid: process.pid, port: 7007, service: null }) });
expect(await runCommand('install', {}, terminal)).toBe(1);
expect(terminal.out.join('\n')).toMatch(/already running in a terminal .*Ctrl-C/);
const taken = fakeCtx({ portState: async () => 'other' });
expect(await runCommand('install', {}, taken)).toBe(1);
expect(taken.out.join('\n')).toContain('Port 7007 is in use by another program');
const win = fakeCtx({ platform: 'win32' });
expect(await runCommand('install', {}, win)).toBe(1);
expect(win.out.join('\n')).toMatch(/not supported on win32 yet\. Run it in a terminal/);
const npx = fakeCtx({ root: '/home/ada/.npm/_npx/abc/node_modules/@bill10/agent-007' });
expect(await runCommand('install', {}, npx)).toBe(1);
expect(npx.out.join('\n')).toContain('npm install -g @bill10/agent-007');
for (const c of [terminal, taken, win, npx]) expect(existsSync(join(c.home, '.config'))).toBe(false);
});
it("carries the old folder's ./.env into the config dir's, never overwriting a key and never printing a value", async () => {
const ctx = fakeCtx({ answer: loginShell });
writeFileSync(join(ctx.cwd, '.env'), '# old terminal setup\nALLOWED_ORIGINS=mac-mini.tail1.ts.net\nexport PORT=7010\nTELEGRAM_BOT_TOKEN="123:secret" # bot\nHOST=0.0.0.0\nKEY="multi\nline"\n');
const shared = join(ctx.home, '.agent-007', '.env');
mkdirSync(join(ctx.home, '.agent-007'), { recursive: true });
writeFileSync(shared, '# HOST=\nHOST=127.0.0.1');
expect(await runCommand('install', {}, ctx)).toBe(0);
const text = readFileSync(shared, 'utf8');
expect(text).toContain('HOST=127.0.0.1\n\n# Carried over from');
expect(text).toContain('\nALLOWED_ORIGINS=mac-mini.tail1.ts.net\nexport PORT=7010\nTELEGRAM_BOT_TOKEN="123:secret" # bot\n');
expect(text).not.toContain('0.0.0.0');
expect(text).not.toContain('multi');
expect(ctx.out.join('\n')).toContain('Copied ALLOWED_ORIGINS, PORT, TELEGRAM_BOT_TOKEN from');
expect(ctx.out.join('\n')).not.toContain('secret');
const made = fakeCtx({ answer: loginShell, cwd: ctx.cwd });
expect(await runCommand('install', {}, made)).toBe(0);
if (process.platform !== 'win32') expect(statSync(join(made.home, '.agent-007', '.env')).mode & 0o777).toBe(0o600);
// Again: nothing left to copy, so nothing is appended or said.
const again = fakeCtx({ answer: loginShell, home: ctx.home, cwd: ctx.cwd });
expect(await runCommand('install', {}, again)).toBe(0);
expect(readFileSync(shared, 'utf8')).toBe(text);
expect(again.out.join('\n')).not.toContain('Copied');
});
it('install --public-url: PUBLIC_URL in the settings file, then the service, never Tailscale', async () => {
const asked = [];
const ctx = fakeCtx({ answer: loginShell, tailscaleBin: () => '/usr/bin/tailscale', tty: true, ask: async (q) => { asked.push(q); return ''; } });
expect(await runCommand('install', { 'public-url': 'https://agent.example.com' }, ctx)).toBe(0);
expect(readFileSync(join(ctx.home, '.agent-007', '.env'), 'utf8')).toContain('PUBLIC_URL=https://agent.example.com');
expect(existsSync(join(ctx.home, '.config', 'systemd', 'user', UNIT))).toBe(true);
expect(ctx.calls.some(c => c.includes('tailscale'))).toBe(false);
expect(asked.join()).not.toContain('Tailscale');
expect(await runCommand('install', { 'public-url': 'nope' }, fakeCtx())).toBe(2);
const both = fakeCtx();
expect(await runCommand('install', { 'public-url': 'https://a.example', remote: true }, both)).toBe(2);
expect(both.out.join('\n')).toContain('not with --voice or --remote');
});
it('uninstall with nothing installed says so', async () => {
const ctx = fakeCtx();
expect(await runCommand('uninstall', {}, ctx)).toBe(0);
expect(ctx.out).toEqual(['No Agent 007 service is installed.']);
});
});
describe('status / restart', () => {
const live = (over = {}) => ({ pid: process.pid, version: '1.0.0.0', port: 7007, service: null, uptime: 3700, sessions: 3, workers: 0, ...over });
it('status: how it runs, pid, version, port, uptime, workers, log', async () => {
const ctx = fakeCtx({ readServer: () => ({ pid: process.pid, token: 't' }), callServer: async () => live({ service: 'systemd', workers: 2 }) });
writeFileSync(join(mkdirSync(join(ctx.home, '.config', 'systemd', 'user'), { recursive: true }), UNIT), '');
expect(await runCommand('status', {}, ctx)).toBe(0);
const text = ctx.out.join('\n');
expect(text).toContain('Agent 007 1.0.0.0 is running as a service (systemd).');
expect(text).toContain(`pid ${process.pid}, port 7007, up 1h 1m`);
expect(text).toContain('workers running: 2');
expect(text).toContain('server.log');
const off = fakeCtx();
expect(await runCommand('status', {}, off)).toBe(3);
expect(off.out.join('\n')).toMatch(/not running\.\n {2}service: not installed \(agent007 install/);
});
it('restart waits for mid-run workers, then asks the server, then reports the new pid', async () => {
let workers = [2, 2, 1, 0];
let restarted = false;
const ctx = fakeCtx({
readServer: () => ({ pid: restarted ? NEW_PID : process.pid, token: 't' }),
callServer: async (path, { method } = {}) => {
if (method === 'POST') { restarted = true; return { ok: true }; }
return restarted ? live({ pid: NEW_PID, version: '1.1.0.0' }) : live({ workers: workers.shift() ?? 0 });
},
});
expect(await runCommand('restart', {}, ctx)).toBe(0);
const text = ctx.out.join('\n');
expect(text.match(/mid-run/g)).toHaveLength(2);
expect(text).toContain(`Restarted: pid ${process.pid} → ${NEW_PID}, version 1.0.0.0 → 1.1.0.0.`);
});
it('--now skips the wait; nothing running and no service is an error', async () => {
let restarted = false;
const ctx = fakeCtx({
readServer: () => ({ pid: restarted ? NEW_PID : process.pid, token: 't' }),
callServer: async (path, { method } = {}) => {
if (method === 'POST') { restarted = true; return { ok: true }; }
return restarted ? live({ pid: NEW_PID }) : live({ workers: 5 });
},
});
expect(await runCommand('restart', { now: true }, ctx)).toBe(0);
expect(ctx.out.join('\n')).not.toContain('mid-run');
const none = fakeCtx();
expect(await runCommand('restart', {}, none)).toBe(1);
});
it('waitForIdle: logs when the count changes, returns at 0, null when the server goes away', async () => {
const seq = [{ workers: 3 }, { workers: 3 }, { workers: 1 }, { workers: 0 }];
const logs = [];
let sleeps = 0;
expect(await waitForIdle({ getStatus: async () => seq.shift(), sleep: async () => { sleeps++; }, log: (s) => logs.push(s) })).toEqual({ workers: 0 });
expect(sleeps).toBe(3);
expect(logs.map(l => l.split(' mid-run')[0])).toEqual(['3 workers are', '1 worker is']);
expect(await waitForIdle({ getStatus: async () => null, sleep: async () => {}, log: () => {} })).toBe(null);
});
});
describe('update', () => {
it('decides from what git says', () => {
expect(checkoutDecision({ dirty: ' M server.js', upstream: 'origin/main' }).error).toMatch(/local changes:\n M server.js/);
expect(checkoutDecision({ dirty: '', upstream: null }).error).toMatch(/tracks no remote branch/);
expect(checkoutDecision({ dirty: '', upstream: 'origin/main', ahead: 1, behind: 2 }).error).toMatch(/diverged \(1 local commit, 2 new upstream\)/);
expect(checkoutDecision({ dirty: '', upstream: 'origin/main', ahead: 0, behind: 0 })).toEqual({ upToDate: true });
expect(checkoutDecision({ dirty: '', upstream: 'origin/main', ahead: 3, behind: 0 })).toEqual({ upToDate: true });
expect(checkoutDecision({ dirty: '', upstream: 'origin/main', ahead: 0, behind: 4 })).toEqual({ pull: true });
});
// A checkout: root has a .git.
const checkout = () => { const root = tmp('a007-clone-'); mkdirSync(join(root, '.git')); return root; };
const gitAnswers = ({ dirty = '', counts = '0\t2', lockChanges = false } = {}) => {
let pulled = false;
return (cmd, args) => {
if (cmd !== 'git') return {};
const sub = args[2];
if (sub === 'status') return { stdout: dirty };
if (sub === 'rev-parse' && args.includes('@{u}')) return { stdout: 'origin/main\n' };
if (sub === 'rev-list') return { stdout: `${counts}\n` };
if (sub === 'rev-parse') return { stdout: lockChanges && pulled ? 'new\n' : 'old\n' };
if (sub === 'pull') { pulled = true; return {}; }
return {};
};
};
it('checkout: pull --ff-only, npm install only when the lock changed, old → new', async () => {
let version = '1.0.0.0';
const ctx = fakeCtx({ root: checkout(), answer: (cmd, args) => { if (args[2] === 'pull') version = '1.1.0.0'; return gitAnswers()(cmd, args); }, version: () => version });
expect(await runCommand('update', {}, ctx)).toBe(0);
expect(ctx.calls.some(c => c.endsWith('pull --ff-only'))).toBe(true);
expect(ctx.calls.some(c => c.startsWith('npm'))).toBe(false);
expect(ctx.out.join('\n')).toContain('Updated 1.0.0.0 → 1.1.0.0.');
expect(ctx.out.join('\n')).toContain('not running');
const locked = fakeCtx({ root: checkout(), answer: gitAnswers({ lockChanges: true }) });
expect(await runCommand('update', {}, locked)).toBe(0);
expect(locked.calls).toContain('npm install');
});
it('checkout: refuses on local changes or a diverged branch, pulls nothing', async () => {
for (const answers of [gitAnswers({ dirty: ' M x' }), gitAnswers({ counts: '1\t1' })]) {
const ctx = fakeCtx({ root: checkout(), answer: answers });
expect(await runCommand('update', {}, ctx)).toBe(1);
expect(ctx.calls.some(c => c.includes('pull'))).toBe(false);
}
const current = fakeCtx({ root: checkout(), answer: gitAnswers({ counts: '0\t0' }) });
expect(await runCommand('update', {}, current)).toBe(0);
expect(current.out).toEqual(['Already up to date (1.0.0.0).']);
});
it('checkout with a running server: updates, then restarts it', async () => {
let restarted = false;
const ctx = fakeCtx({
root: checkout(),
answer: gitAnswers(),
readServer: () => ({ pid: restarted ? NEW_PID : process.pid, token: 't' }),
callServer: async (path, { method } = {}) => {
if (method === 'POST') { restarted = true; return { ok: true }; }
return { pid: restarted ? NEW_PID : process.pid, version: '1.0.0.0', workers: 0, port: 7007 };
},
});
expect(await runCommand('update', { now: true }, ctx)).toBe(0);
expect(restarted).toBe(true);
});
it('npm global: installs the registry latest exactly; a copy outside npm root -g is refused; npx needs nothing', async () => {
const root = tmp('a007-global-');
mkdirSync(join(root, '@bill10', 'agent-007'), { recursive: true });
const globalCtx = ({ installs = '1.2.0.0', latest = async () => '1.2.0', start = '1.0.0.0' } = {}) => {
let version = start;
return fakeCtx({
root: join(root, '@bill10', 'agent-007'),
answer: (cmd, args) => {
if (args[0] === 'root') return { stdout: `${root}\n` };
if (args[0] === 'install') version = installs;
return {};
},
version: () => version,
latest,
});
};
const ctx = globalCtx();
expect(await runCommand('update', {}, ctx)).toBe(0);
expect(ctx.calls).toEqual(['npm root -g', 'npm install -g @bill10/agent-007@1.2.0 --prefer-online']);
expect(ctx.out.join('\n')).toContain('Updated 1.0.0.0 → 1.2.0.0.');
// npm's stale cache reinstalls the old version: said plainly, never "up to date", no restart.
const stale = globalCtx({ installs: '1.0.0.0' });
expect(await runCommand('update', {}, stale)).toBe(1);
expect(stale.out.join('\n')).toMatch(/ERR npm installed 1\.0\.0\.0, not 1\.2\.0\.0.*Retry: npm install -g @bill10\/agent-007@1\.2\.0 --prefer-online/);
expect(stale.out.join('\n')).not.toContain('up to date');
const current = globalCtx({ latest: async () => '1.0.0' });
expect(await runCommand('update', {}, current)).toBe(0);
expect(current.out.join('\n')).toContain('Already up to date (1.0.0.0).');
expect(current.calls).toEqual(['npm root -g']);
const offline = globalCtx({ latest: async () => null });
expect(await runCommand('update', {}, offline)).toBe(1);
expect(offline.out.join('\n')).toMatch(/ERR Could not reach the npm registry/);
expect(offline.out.join('\n')).not.toContain('up to date');
const elsewhere = fakeCtx({ root: '/somewhere/node_modules/@bill10/agent-007', answer: () => ({ stdout: `${root}\n` }) });
expect(await runCommand('update', {}, elsewhere)).toBe(1);
expect(elsewhere.calls).toEqual(['npm root -g']);
const npx = fakeCtx({ env: { npm_command: 'exec' } });
expect(await runCommand('update', {}, npx)).toBe(0);
expect(npx.out.join('\n')).toContain('npx runs the latest version each time');
expect(npx.calls).toEqual([]);
});
});
describe('logs', () => {
it('prints the last lines; caps a log over the limit to one older copy', async () => {
const home = tmp('a007-logs-');
const cfg = join(home, '.agent-007');
mkdirSync(join(cfg, 'logs'), { recursive: true });
const file = join(cfg, 'logs', 'server.log');
writeFileSync(file, Array.from({ length: 80 }, (_, i) => `line ${i}`).join('\n') + '\n');
const ctx = fakeCtx({ home, env: { AGENT007_CONFIG_DIR: cfg } });
expect(await runCommand('logs', { lines: '3' }, ctx)).toBe(0);
expect(ctx.out).toEqual(['line 77\nline 78\nline 79\n']);
expect(capLog(file, 10)).toBe(true);
expect(readFileSync(file, 'utf8')).toBe('');
expect(readFileSync(`${file}.1`, 'utf8')).toMatch(/^line 0\n/);
expect(capLog(file, 10)).toBe(false);
const none = fakeCtx({ env: { AGENT007_CONFIG_DIR: join(home, 'nope') } });
expect(await runCommand('logs', {}, none)).toBe(1);
});
});
describe('server.json', () => {
it.skipIf(process.platform === 'win32')('is owner-only even when an older one was readable by others', () => {
const file = join(tmp('a007-sj-'), 'server.json');
writeFileSync(file, '{}', { mode: 0o644 });
writeServerFile({ port: 7007, host: '127.0.0.1', token: 'tok', file });
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(readServerFile(file)).toMatchObject({ pid: process.pid, port: 7007, token: 'tok' });
});
});
// The real thing in a terminal: a server started from bin/agent-007.js,
// `restart` asking it over the control channel, and bin bringing it back in
// the same process tree. Temp HOME and config dir; BILLION off.
describe('restart in a terminal', () => {
it('status sees it; restart brings it back on a new pid; status and a name of agent007 say so', async () => {
const home = tmp('a007-restart-home-');
const cfg = join(home, '.agent-007');
const env = { ...process.env, HOME: home, USERPROFILE: home, AGENT007_CONFIG_DIR: cfg, BILLION: '0' };
delete env.npm_command; delete env.npm_lifecycle_event;
const port = await new Promise((res) => { const s = createServer().listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); });
// Not execFileSync: the server's output has to keep arriving meanwhile.
const cli = (args) => new Promise((done) => execFile(process.execPath, [join(ROOT, 'bin/agent-007.js'), ...args], { cwd: home, env, encoding: 'utf8' },
(err, stdout, stderr) => done({ code: err ? err.code : 0, out: stdout + stderr })));
const child = spawn(process.execPath, [join(ROOT, 'bin/agent-007.js'), '--port', String(port)], { cwd: home, env });
let out = '';
child.stdout.on('data', d => { out += d; });
child.stderr.on('data', d => { out += d; });
const until = async (cond, what) => {
for (let i = 0; i < 200; i++) { if (cond()) return; await new Promise(r => setTimeout(r, 100)); }
throw new Error(`${what}:\n${out}`);
};
try {
await until(() => readServerFile(join(cfg, 'server.json')), 'no server.json');
const first = readServerFile(join(cfg, 'server.json'));
expect(first.port).toBe(port);
const status = await cli(['status']);
expect(status.out).toContain('is running in a terminal');
expect(status.code).toBe(0);
const restarted = await cli(['restart']);
expect(restarted.out).toMatch(new RegExp(`Restarted: pid ${first.pid} → \\d+`));
expect(restarted.code).toBe(0);
await until(() => out.split('is running at').length === 3, 'no second start line');
expect(out).toContain('Restarting...');
expect(child.exitCode).toBe(null);
if (process.platform !== 'win32') {
// Installed as agent007, it says agent007 back.
const link = join(home, 'agent007');
symlinkSync(join(ROOT, 'bin/agent-007.js'), link);
const named = await new Promise((done) => execFile(process.execPath, [link, 'status'], { cwd: home, env, encoding: 'utf8' }, (e, o) => done(o)));
expect(named).toContain('agent007 install');
}
} finally {
// The newest server; the old process waits on it and leaves with it.
const now = readServerFile(join(cfg, 'server.json'));
try { if (now) process.kill(now.pid); } catch {}
await until(() => child.exitCode !== null, 'did not exit').catch(() => child.kill());
await removeTempDir(home);
}
}, 60000);
});