836 lines
24 KiB
TypeScript
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"));
|
|
});
|
|
});
|