784 lines
25 KiB
TypeScript
784 lines
25 KiB
TypeScript
import { describe, it, afterEach, beforeAll, afterAll } from 'vitest';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync, existsSync, mkdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir, homedir } from 'node:os';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { execFileSync, spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname } from 'node:path';
|
|
import { resolveConfigPath, loadConfig, validateConfig } from './config.js';
|
|
import { Logger } from './logger.js';
|
|
import { Daemon } from './daemon.js';
|
|
import { SessionManager } from './session-manager.js';
|
|
import type { DaemonConfig, LogEntry } from './types.js';
|
|
|
|
// ---------- helpers ----------
|
|
|
|
function tmpDir(): string {
|
|
return mkdtempSync(join(tmpdir(), `daemon-test-${randomUUID().slice(0, 8)}-`));
|
|
}
|
|
|
|
const cleanupDirs: string[] = [];
|
|
afterEach(() => {
|
|
while (cleanupDirs.length) {
|
|
const d = cleanupDirs.pop()!;
|
|
if (existsSync(d)) rmSync(d, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// ---------- config ----------
|
|
|
|
describe('resolveConfigPath', () => {
|
|
it('prefers explicit CLI path', () => {
|
|
const p = resolveConfigPath('/custom/config.yaml');
|
|
assert.equal(p, '/custom/config.yaml');
|
|
});
|
|
|
|
it('expands ~ in CLI path', () => {
|
|
const p = resolveConfigPath('~/my-daemon.yaml');
|
|
assert.ok(p.startsWith(homedir()));
|
|
assert.ok(p.endsWith('my-daemon.yaml'));
|
|
});
|
|
|
|
it('falls back to SF_DAEMON_CONFIG env var', () => {
|
|
const prev = process.env['SF_DAEMON_CONFIG'];
|
|
try {
|
|
process.env['SF_DAEMON_CONFIG'] = '/env/path.yaml';
|
|
const p = resolveConfigPath();
|
|
assert.equal(p, '/env/path.yaml');
|
|
} finally {
|
|
if (prev === undefined) delete process.env['SF_DAEMON_CONFIG'];
|
|
else process.env['SF_DAEMON_CONFIG'] = prev;
|
|
}
|
|
});
|
|
|
|
it('defaults to ~/.sf/daemon.yaml', () => {
|
|
const prev = process.env['SF_DAEMON_CONFIG'];
|
|
try {
|
|
delete process.env['SF_DAEMON_CONFIG'];
|
|
const p = resolveConfigPath();
|
|
assert.equal(p, join(homedir(), '.sf', 'daemon.yaml'));
|
|
} finally {
|
|
if (prev !== undefined) process.env['SF_DAEMON_CONFIG'] = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('loadConfig', () => {
|
|
// Save and clear DISCORD_BOT_TOKEN for this suite — env override interferes with file-token assertions
|
|
let savedToken: string | undefined;
|
|
beforeAll(() => {
|
|
savedToken = process.env['DISCORD_BOT_TOKEN'];
|
|
delete process.env['DISCORD_BOT_TOKEN'];
|
|
});
|
|
afterEach(() => {}); // cleanup dirs handled by top-level afterEach
|
|
// Restore after all tests in this suite
|
|
afterAll(() => {
|
|
if (savedToken !== undefined) process.env['DISCORD_BOT_TOKEN'] = savedToken;
|
|
});
|
|
|
|
it('parses valid YAML config', () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const configPath = join(dir, 'daemon.yaml');
|
|
writeFileSync(configPath, `
|
|
discord:
|
|
token: "test-token-123"
|
|
guild_id: "g1"
|
|
owner_id: "o1"
|
|
projects:
|
|
scan_roots:
|
|
- ~/projects
|
|
- /absolute/path
|
|
log:
|
|
file: ~/logs/daemon.log
|
|
level: debug
|
|
max_size_mb: 100
|
|
`);
|
|
const cfg = loadConfig(configPath);
|
|
assert.equal(cfg.discord?.token, 'test-token-123');
|
|
assert.equal(cfg.discord?.guild_id, 'g1');
|
|
assert.equal(cfg.log.level, 'debug');
|
|
assert.equal(cfg.log.max_size_mb, 100);
|
|
assert.ok(cfg.log.file.startsWith(homedir()));
|
|
assert.ok(cfg.projects.scan_roots[0]!.startsWith(homedir()));
|
|
assert.equal(cfg.projects.scan_roots[1], '/absolute/path');
|
|
});
|
|
|
|
it('returns defaults when config file is missing', () => {
|
|
const cfg = loadConfig('/nonexistent/path/daemon.yaml');
|
|
assert.equal(cfg.log.level, 'info');
|
|
assert.equal(cfg.log.max_size_mb, 50);
|
|
assert.ok(cfg.log.file.endsWith('daemon.log'));
|
|
assert.deepEqual(cfg.projects.scan_roots, []);
|
|
assert.equal(cfg.discord, undefined);
|
|
});
|
|
|
|
it('throws on malformed YAML', () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const configPath = join(dir, 'bad.yaml');
|
|
writeFileSync(configPath, ':\n :\n bad: [unclosed');
|
|
assert.throws(() => loadConfig(configPath), (err: unknown) => {
|
|
assert.ok(err instanceof Error);
|
|
assert.ok(err.message.includes('Failed to parse YAML'));
|
|
assert.ok(err.message.includes(configPath));
|
|
return true;
|
|
});
|
|
});
|
|
|
|
it('returns defaults for empty YAML file', () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const configPath = join(dir, 'empty.yaml');
|
|
writeFileSync(configPath, '');
|
|
const cfg = loadConfig(configPath);
|
|
assert.equal(cfg.log.level, 'info');
|
|
assert.equal(cfg.log.max_size_mb, 50);
|
|
assert.deepEqual(cfg.projects.scan_roots, []);
|
|
});
|
|
});
|
|
|
|
describe('validateConfig', () => {
|
|
// Save and clear DISCORD_BOT_TOKEN for tests that don't expect it
|
|
let savedToken: string | undefined;
|
|
beforeAll(() => {
|
|
savedToken = process.env['DISCORD_BOT_TOKEN'];
|
|
delete process.env['DISCORD_BOT_TOKEN'];
|
|
});
|
|
afterAll(() => {
|
|
if (savedToken !== undefined) process.env['DISCORD_BOT_TOKEN'] = savedToken;
|
|
});
|
|
|
|
it('fills remaining defaults for partial config', () => {
|
|
const cfg = validateConfig({ projects: { scan_roots: ['/a'] } });
|
|
assert.equal(cfg.log.level, 'info');
|
|
assert.equal(cfg.log.max_size_mb, 50);
|
|
assert.ok(cfg.log.file.endsWith('daemon.log'));
|
|
assert.deepEqual(cfg.projects.scan_roots, ['/a']);
|
|
assert.equal(cfg.discord, undefined);
|
|
});
|
|
|
|
it('falls back to info for invalid log level', () => {
|
|
const cfg = validateConfig({ log: { level: 'trace' } });
|
|
assert.equal(cfg.log.level, 'info');
|
|
});
|
|
|
|
it('returns full defaults for null input', () => {
|
|
const cfg = validateConfig(null);
|
|
assert.equal(cfg.log.level, 'info');
|
|
assert.equal(cfg.log.max_size_mb, 50);
|
|
});
|
|
|
|
it('returns full defaults for non-object input', () => {
|
|
const cfg = validateConfig('not-an-object');
|
|
assert.equal(cfg.log.level, 'info');
|
|
});
|
|
|
|
it('expands ~ in log file path', () => {
|
|
const cfg = validateConfig({ log: { file: '~/my.log' } });
|
|
assert.ok(cfg.log.file.startsWith(homedir()));
|
|
assert.ok(cfg.log.file.endsWith('my.log'));
|
|
});
|
|
|
|
it('overrides discord token from DISCORD_BOT_TOKEN env var', () => {
|
|
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
try {
|
|
process.env['DISCORD_BOT_TOKEN'] = 'env-override-token';
|
|
const cfg = validateConfig({
|
|
discord: { token: 'file-token', guild_id: 'g1', owner_id: 'o1' },
|
|
});
|
|
assert.equal(cfg.discord?.token, 'env-override-token');
|
|
assert.equal(cfg.discord?.guild_id, 'g1');
|
|
} finally {
|
|
if (prev === undefined) delete process.env['DISCORD_BOT_TOKEN'];
|
|
else process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
}
|
|
});
|
|
|
|
it('creates discord block from env var even when absent in config', () => {
|
|
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
try {
|
|
process.env['DISCORD_BOT_TOKEN'] = 'env-only-token';
|
|
const cfg = validateConfig({});
|
|
assert.equal(cfg.discord?.token, 'env-only-token');
|
|
} finally {
|
|
if (prev === undefined) delete process.env['DISCORD_BOT_TOKEN'];
|
|
else process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------- logger ----------
|
|
|
|
describe('Logger', () => {
|
|
it('writes JSON-lines entries to file', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'test.log');
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'debug' });
|
|
logger.info('hello world');
|
|
logger.debug('detail', { key: 'val' });
|
|
await logger.close();
|
|
|
|
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
assert.equal(lines.length, 2);
|
|
|
|
const entry0: LogEntry = JSON.parse(lines[0]!);
|
|
assert.equal(entry0.level, 'info');
|
|
assert.equal(entry0.msg, 'hello world');
|
|
assert.ok(entry0.ts); // ISO-8601
|
|
|
|
const entry1: LogEntry = JSON.parse(lines[1]!);
|
|
assert.equal(entry1.level, 'debug');
|
|
assert.equal(entry1.msg, 'detail');
|
|
assert.deepEqual(entry1.data, { key: 'val' });
|
|
});
|
|
|
|
it('filters entries below configured level', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'filter.log');
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'warn' });
|
|
logger.debug('should not appear');
|
|
logger.info('should not appear either');
|
|
logger.warn('visible warning');
|
|
logger.error('visible error');
|
|
await logger.close();
|
|
|
|
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
assert.equal(lines.length, 2);
|
|
assert.equal((JSON.parse(lines[0]!) as LogEntry).level, 'warn');
|
|
assert.equal((JSON.parse(lines[1]!) as LogEntry).level, 'error');
|
|
});
|
|
|
|
it('close() resolves after stream ends', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'close.log');
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
logger.info('before close');
|
|
await logger.close();
|
|
|
|
// File should be readable and contain the entry
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
assert.ok(content.includes('before close'));
|
|
});
|
|
|
|
it('creates parent directories if they do not exist', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'nested', 'deep', 'test.log');
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
logger.info('nested dir test');
|
|
await logger.close();
|
|
|
|
assert.ok(existsSync(logPath));
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
assert.ok(content.includes('nested dir test'));
|
|
});
|
|
|
|
it('does not include data field when not provided', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'nodata.log');
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
logger.info('no extra data');
|
|
await logger.close();
|
|
|
|
const entry: LogEntry = JSON.parse(readFileSync(logPath, 'utf-8').trim());
|
|
assert.equal(entry.data, undefined);
|
|
// Also verify the raw JSON doesn't contain "data" key
|
|
assert.ok(!readFileSync(logPath, 'utf-8').includes('"data"'));
|
|
});
|
|
});
|
|
|
|
// ---------- token safety ----------
|
|
|
|
describe('token safety', () => {
|
|
it('discord token never appears in log output', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'token-safety.log');
|
|
|
|
// Config with a token
|
|
const prev = process.env['DISCORD_BOT_TOKEN'];
|
|
try {
|
|
process.env['DISCORD_BOT_TOKEN'] = 'super-secret-token-value';
|
|
const cfg = validateConfig({});
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'debug' });
|
|
// Log the config object — token must not leak
|
|
logger.info('config loaded', { discord_configured: !!cfg.discord });
|
|
logger.debug('startup complete');
|
|
await logger.close();
|
|
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
assert.ok(!content.includes('super-secret-token-value'));
|
|
} finally {
|
|
if (prev === undefined) delete process.env['DISCORD_BOT_TOKEN'];
|
|
else process.env['DISCORD_BOT_TOKEN'] = prev;
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------- daemon lifecycle ----------
|
|
|
|
// Resolve the dist/ directory for spawning CLI
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
describe('Daemon', () => {
|
|
it('logs lifecycle events on start and shutdown', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'daemon-lifecycle.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: ['/a', '/b'] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
await daemon.start();
|
|
|
|
// start() should have logged 'daemon started'
|
|
// shutdown() directly — we override process.exit to prevent test runner from dying
|
|
const origExit = process.exit;
|
|
let exitCode: number | undefined;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = (code?: number) => { exitCode = code ?? 0; };
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
|
|
assert.equal(exitCode, 0);
|
|
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
const lines = content.trim().split('\n');
|
|
|
|
// First line: daemon started
|
|
const startEntry: LogEntry = JSON.parse(lines[0]!);
|
|
assert.equal(startEntry.msg, 'daemon started');
|
|
assert.equal(startEntry.data?.scan_roots, 2);
|
|
assert.equal(startEntry.data?.discord_configured, false);
|
|
|
|
// Second line: daemon shutting down
|
|
const stopEntry: LogEntry = JSON.parse(lines[1]!);
|
|
assert.equal(stopEntry.msg, 'daemon shutting down');
|
|
});
|
|
|
|
it('shutdown is idempotent — second call is a no-op', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'idempotent.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
await daemon.start();
|
|
|
|
const origExit = process.exit;
|
|
let exitCount = 0;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => { exitCount++; };
|
|
try {
|
|
await daemon.shutdown();
|
|
await daemon.shutdown(); // second call — should be no-op
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
|
|
assert.equal(exitCount, 1, 'process.exit should be called exactly once');
|
|
|
|
const lines = readFileSync(logPath, 'utf-8').trim().split('\n');
|
|
const shutdownLines = lines.filter(l => {
|
|
const e: LogEntry = JSON.parse(l);
|
|
return e.msg === 'daemon shutting down';
|
|
});
|
|
assert.equal(shutdownLines.length, 1, 'shutdown log should appear exactly once');
|
|
});
|
|
});
|
|
|
|
// ---------- Health heartbeat ----------
|
|
|
|
describe('Health heartbeat', () => {
|
|
it('logs health entry with expected fields after interval tick', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'health.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
// Use 50ms interval for fast test
|
|
const daemon = new Daemon(config, logger, 50);
|
|
|
|
await daemon.start();
|
|
|
|
// Wait for at least one health tick
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
|
|
const origExit = process.exit;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => {};
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
const lines = content.trim().split('\n');
|
|
const healthLines = lines.filter((l) => {
|
|
const e: LogEntry = JSON.parse(l);
|
|
return e.msg === 'health';
|
|
});
|
|
|
|
assert.ok(healthLines.length >= 1, 'should have at least one health log entry');
|
|
|
|
const entry: LogEntry = JSON.parse(healthLines[0]!);
|
|
assert.equal(entry.msg, 'health');
|
|
assert.equal(typeof entry.data?.uptime_s, 'number');
|
|
assert.equal(typeof entry.data?.active_sessions, 'number');
|
|
assert.equal(typeof entry.data?.discord_connected, 'boolean');
|
|
assert.equal(typeof entry.data?.memory_rss_mb, 'number');
|
|
assert.equal(entry.data?.discord_connected, false); // no discord configured
|
|
assert.equal(entry.data?.active_sessions, 0); // no sessions
|
|
});
|
|
|
|
it('health timer is cleared on shutdown — no lingering intervals', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'health-cleanup.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
// Use 50ms interval
|
|
const daemon = new Daemon(config, logger, 50);
|
|
|
|
await daemon.start();
|
|
|
|
// Wait for one tick
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
const origExit = process.exit;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => {};
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
|
|
// Count health entries at shutdown
|
|
const contentAtShutdown = readFileSync(logPath, 'utf-8');
|
|
const healthCountAtShutdown = contentAtShutdown
|
|
.trim()
|
|
.split('\n')
|
|
.filter((l) => JSON.parse(l).msg === 'health').length;
|
|
|
|
// Wait another interval — no new health entries should appear
|
|
await new Promise((r) => setTimeout(r, 120));
|
|
|
|
// Re-read (logger is closed, so file shouldn't change)
|
|
const contentAfterWait = readFileSync(logPath, 'utf-8');
|
|
const healthCountAfterWait = contentAfterWait
|
|
.trim()
|
|
.split('\n')
|
|
.filter((l) => JSON.parse(l).msg === 'health').length;
|
|
|
|
assert.equal(
|
|
healthCountAfterWait,
|
|
healthCountAtShutdown,
|
|
'no new health entries should appear after shutdown',
|
|
);
|
|
});
|
|
});
|
|
|
|
function resolveCliPath(): string | undefined {
|
|
const srcJs = join(__dirname, 'cli.js');
|
|
const distJs = join(__dirname, '../dist/cli.js');
|
|
if (existsSync(srcJs)) return srcJs;
|
|
if (existsSync(distJs)) return distJs;
|
|
return undefined;
|
|
}
|
|
|
|
function canRunCli(): boolean {
|
|
const cli = resolveCliPath();
|
|
if (!cli) return false;
|
|
try {
|
|
execFileSync(process.execPath, [cli, '--help'], { encoding: 'utf-8', timeout: 5000 });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('CLI integration', () => {
|
|
const cliRunnable = canRunCli();
|
|
|
|
it('--help prints usage and exits 0', { skip: !cliRunnable }, () => {
|
|
const result = execFileSync(
|
|
process.execPath,
|
|
[resolveCliPath()!, '--help'],
|
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
);
|
|
assert.ok(result.includes('Usage: sf-daemon'));
|
|
assert.ok(result.includes('--config'));
|
|
assert.ok(result.includes('--verbose'));
|
|
});
|
|
|
|
it('starts, logs to file, and exits cleanly on SIGTERM', { timeout: 15000, skip: !cliRunnable }, async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'integration.log');
|
|
const configPath = join(dir, 'daemon.yaml');
|
|
|
|
writeFileSync(configPath, `
|
|
projects:
|
|
scan_roots:
|
|
- /tmp/test-project
|
|
log:
|
|
file: "${logPath}"
|
|
level: info
|
|
max_size_mb: 10
|
|
`);
|
|
|
|
// Use execFile with a wrapper script approach: spawn, wait for start, SIGTERM, verify
|
|
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
const child = spawn(
|
|
process.execPath,
|
|
[resolveCliPath()!, '--config', configPath],
|
|
{ stdio: 'ignore' },
|
|
);
|
|
|
|
let resolved = false;
|
|
child.on('error', (err) => { if (!resolved) { resolved = true; reject(err); } });
|
|
child.on('exit', (code) => { if (!resolved) { resolved = true; resolve(code ?? 1); } });
|
|
|
|
// Poll for startup, then send SIGTERM
|
|
const poll = setInterval(() => {
|
|
if (existsSync(logPath)) {
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
if (content.includes('daemon started')) {
|
|
clearInterval(poll);
|
|
child.kill('SIGTERM');
|
|
}
|
|
}
|
|
}, 100);
|
|
|
|
// Safety: kill child if it takes too long
|
|
setTimeout(() => {
|
|
clearInterval(poll);
|
|
if (!resolved) {
|
|
child.kill('SIGKILL');
|
|
resolved = true;
|
|
reject(new Error('timed out waiting for daemon'));
|
|
}
|
|
}, 10000);
|
|
});
|
|
|
|
assert.equal(exitCode, 0, 'daemon should exit with code 0 on SIGTERM');
|
|
|
|
// Small delay for filesystem flush
|
|
await new Promise(r => setTimeout(r, 100));
|
|
|
|
// Verify log file contents
|
|
const finalContent = readFileSync(logPath, 'utf-8');
|
|
assert.ok(finalContent.includes('daemon started'), 'log should contain startup entry');
|
|
assert.ok(finalContent.includes('daemon shutting down'), 'log should contain shutdown entry');
|
|
|
|
// Verify log entries are valid JSON-lines
|
|
const lines = finalContent.trim().split('\n');
|
|
for (const line of lines) {
|
|
const entry: LogEntry = JSON.parse(line);
|
|
assert.ok(entry.ts, 'each entry should have a timestamp');
|
|
assert.ok(entry.level, 'each entry should have a level');
|
|
assert.ok(entry.msg, 'each entry should have a message');
|
|
}
|
|
});
|
|
|
|
it('exits with code 1 on invalid config', { skip: !cliRunnable }, () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const configPath = join(dir, 'bad.yaml');
|
|
writeFileSync(configPath, ':\n :\n bad: [unclosed');
|
|
|
|
try {
|
|
execFileSync(
|
|
process.execPath,
|
|
[resolveCliPath()!, '--config', configPath],
|
|
{ encoding: 'utf-8', timeout: 5000 },
|
|
);
|
|
assert.fail('should have thrown');
|
|
} catch (err: unknown) {
|
|
// execFileSync throws on non-zero exit
|
|
const execErr = err as { status: number; stderr: string };
|
|
assert.equal(execErr.status, 1);
|
|
assert.ok(execErr.stderr.includes('fatal'));
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------- Daemon + SessionManager integration ----------
|
|
|
|
describe('Daemon integration', () => {
|
|
it('getSessionManager() returns SessionManager after start()', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'daemon-sm.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
await daemon.start();
|
|
|
|
const sm = daemon.getSessionManager();
|
|
assert.ok(sm instanceof SessionManager);
|
|
|
|
// Clean shutdown
|
|
const origExit = process.exit;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => {};
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
});
|
|
|
|
it('getSessionManager() throws before start()', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'daemon-nostart.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
assert.throws(
|
|
() => daemon.getSessionManager(),
|
|
(err: Error) => {
|
|
assert.ok(err.message.includes('Daemon not started'));
|
|
return true;
|
|
}
|
|
);
|
|
|
|
// Close logger to prevent async write stream from hitting cleaned-up tmpdir
|
|
await logger.close();
|
|
});
|
|
|
|
it('scanProjects() delegates to scanForProjects with configured roots', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'daemon-scan.log');
|
|
|
|
// Create a fake project root with a project that has a .git marker
|
|
const scanRoot = join(dir, 'projects');
|
|
mkdirSync(scanRoot);
|
|
const projectDir = join(scanRoot, 'my-project');
|
|
mkdirSync(projectDir);
|
|
mkdirSync(join(projectDir, '.git'));
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [scanRoot] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
await daemon.start();
|
|
|
|
const projects = await daemon.scanProjects();
|
|
assert.ok(projects.length >= 1);
|
|
const found = projects.find(p => p.name === 'my-project');
|
|
assert.ok(found);
|
|
assert.ok(found.markers.includes('git'));
|
|
|
|
// Clean shutdown
|
|
const origExit = process.exit;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => {};
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
});
|
|
|
|
it('shutdown cleans up sessionManager before closing logger', async () => {
|
|
const dir = tmpDir();
|
|
cleanupDirs.push(dir);
|
|
const logPath = join(dir, 'daemon-cleanup.log');
|
|
|
|
const config: DaemonConfig = {
|
|
discord: undefined,
|
|
projects: { scan_roots: [] },
|
|
log: { file: logPath, level: 'info', max_size_mb: 50 },
|
|
};
|
|
|
|
const logger = new Logger({ filePath: logPath, level: 'info' });
|
|
const daemon = new Daemon(config, logger);
|
|
|
|
await daemon.start();
|
|
|
|
// Access sessionManager to verify it exists
|
|
const sm = daemon.getSessionManager();
|
|
assert.ok(sm);
|
|
|
|
// Shutdown — should not throw even though sessionManager has no active sessions
|
|
const origExit = process.exit;
|
|
// @ts-expect-error — overriding process.exit for test
|
|
process.exit = () => {};
|
|
try {
|
|
await daemon.shutdown();
|
|
} finally {
|
|
process.exit = origExit;
|
|
}
|
|
|
|
// Verify log contains both started and shutting down
|
|
const content = readFileSync(logPath, 'utf-8');
|
|
assert.ok(content.includes('daemon started'));
|
|
assert.ok(content.includes('daemon shutting down'));
|
|
});
|
|
});
|