singularity-forge/packages/daemon/src/daemon.test.ts
2026-05-05 14:46:18 +02:00

836 lines
24 KiB
TypeScript

import assert from "node:assert/strict";
import { execFileSync, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, afterEach, beforeAll, describe, it } from "vitest";
import { loadConfig, resolveConfigPath, validateConfig } from "./config.js";
import { Daemon } from "./daemon.js";
import { Logger } from "./logger.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-server"));
assert.ok(result.includes("Alias: sf-daemon"));
assert.ok(result.includes("--config"));
assert.ok(result.includes("--verbose"));
assert.ok(result.includes("--start"));
});
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"));
});
});