import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { afterEach, describe, expect, it } from "vitest"; import { addTaskDestination, flushLogs, getLogger, initSessionLogger, removeTaskDestination, resetLogger, setLogLevel } from "./log.js"; const tempDirs: string[] = []; function makeTempDir(): string { const dir = mkdtempSync(join(tmpdir(), "pi-pi-log-test-")); tempDirs.push(dir); return dir; } function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } function readText(path: string): string { return existsSync(path) ? readFileSync(path, "utf-8") : ""; } async function waitForContent(path: string, substring: string, timeoutMs = 2000): Promise { const deadline = Date.now() + timeoutMs; let text = readText(path); while (!text.includes(substring) && Date.now() < deadline) { await delay(10); text = readText(path); } return text; } function sessionLogPath(ppDir: string): string { const logsDir = join(ppDir, "logs"); const entries = existsSync(logsDir) ? readdirSync(logsDir).filter((f) => f.startsWith("session-") && f.endsWith(".jsonl")) : []; if (entries.length === 0) return ""; return join(logsDir, entries.sort()[entries.length - 1]); } afterEach(async () => { flushLogs(); resetLogger(); await delay(50); for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); describe("log", () => { it("getLogger returns silent logger before init", async () => { await (async () => { const module = await import("./log.js"); expect(module.getLogger().level).toBe("silent"); })(); }); it("initSessionLogger creates session log file in .pp/logs", async () => { const ppDir = makeTempDir(); initSessionLogger(ppDir, "info"); getLogger().info({ s: "test" }, "create-session-log"); flushLogs(); await delay(30); const logPath = sessionLogPath(ppDir); expect(logPath).not.toBe(""); expect(existsSync(logPath)).toBe(true); }); it("initSessionLogger writes JSONL to session file", async () => { const ppDir = makeTempDir(); initSessionLogger(ppDir, "debug"); getLogger().info({ s: "test" }, "touch-session-log"); flushLogs(); await delay(60); const logPath = sessionLogPath(ppDir); expect(logPath).not.toBe(""); expect(existsSync(logPath)).toBe(true); getLogger().info({ s: "test" }, "hello-session"); flushLogs(); await delay(60); expect(readText(logPath)).toContain("hello-session"); }); it("addTaskDestination creates debug.jsonl under .pp/logs (re-rooted from .pp/state)", async () => { const cwd = makeTempDir(); const ppDir = join(cwd, ".pp"); const stateTaskDir = join(ppDir, "state", "implement", "abc_task"); const logsTaskDir = join(ppDir, "logs", "implement", "abc_task"); initSessionLogger(ppDir, "info"); addTaskDestination(stateTaskDir); getLogger().info({ s: "test" }, "touch-task-log"); flushLogs(); await delay(30); expect(existsSync(join(logsTaskDir, "debug.jsonl"))).toBe(true); expect(existsSync(join(stateTaskDir, "debug.jsonl"))).toBe(false); }); it("addTaskDestination logs to both session and task files", async () => { const cwd = makeTempDir(); const ppDir = join(cwd, ".pp"); const stateTaskDir = join(ppDir, "state", "implement", "abc_task"); const logsTaskDir = join(ppDir, "logs", "implement", "abc_task"); initSessionLogger(ppDir, "debug"); const sessionPath = sessionLogPath(ppDir); addTaskDestination(stateTaskDir); getLogger().info({ s: "test" }, "both-destinations"); flushLogs(); expect(await waitForContent(sessionPath, "both-destinations")).toContain("both-destinations"); expect(await waitForContent(join(logsTaskDir, "debug.jsonl"), "both-destinations")).toContain("both-destinations"); }); it("removeTaskDestination stops writing to task file", async () => { const cwd = makeTempDir(); const ppDir = join(cwd, ".pp"); const stateTaskDir = join(ppDir, "state", "implement", "abc_task"); const logsTaskDir = join(ppDir, "logs", "implement", "abc_task"); initSessionLogger(ppDir, "debug"); addTaskDestination(stateTaskDir); getLogger().info({ s: "test" }, "before-remove"); flushLogs(); const before = await waitForContent(join(logsTaskDir, "debug.jsonl"), "before-remove"); removeTaskDestination(); getLogger().info({ s: "test" }, "after-remove"); flushLogs(); await delay(30); const after = readText(join(logsTaskDir, "debug.jsonl")); expect(before).toContain("before-remove"); expect(after).not.toContain("after-remove"); }); it("setLogLevel changes effective log level", async () => { const ppDir = makeTempDir(); initSessionLogger(ppDir, "info"); const path = sessionLogPath(ppDir); getLogger().debug({ s: "test" }, "debug-before"); getLogger().info({ s: "test" }, "info-before"); flushLogs(); const before = await waitForContent(path, "info-before"); setLogLevel("debug"); getLogger().debug({ s: "test" }, "debug-after"); flushLogs(); const after = await waitForContent(path, "debug-after"); expect(before).toContain("info-before"); expect(before).not.toContain("debug-before"); expect(after).toContain("debug-after"); }); it("setLogLevel ignores invalid levels", () => { const ppDir = makeTempDir(); initSessionLogger(ppDir, "info"); setLogLevel("invalid" as any); expect(getLogger().level).toBe("info"); }); it("cleanOldSessionLogs deletes files older than max age", () => { const ppDir = makeTempDir(); const logsDir = join(ppDir, "logs"); mkdirSync(logsDir, { recursive: true }); const oldFile = join(logsDir, "session-old.jsonl"); writeFileSync(oldFile, "{}\n", "utf-8"); const oldTime = new Date(Date.now() - 9 * 24 * 60 * 60 * 1000); utimesSync(oldFile, oldTime, oldTime); initSessionLogger(ppDir, "info"); expect(existsSync(oldFile)).toBe(false); }); it("cleanOldSessionLogs keeps recent files", () => { const ppDir = makeTempDir(); const logsDir = join(ppDir, "logs"); mkdirSync(logsDir, { recursive: true }); const recentFile = join(logsDir, "session-recent.jsonl"); writeFileSync(recentFile, "{}\n", "utf-8"); const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); utimesSync(recentFile, recentTime, recentTime); initSessionLogger(ppDir, "info"); expect(existsSync(recentFile)).toBe(true); }); it("initSessionLogger falls back to silent on unwritable path", () => { const dir = makeTempDir(); const filePath = join(dir, "not-a-dir"); writeFileSync(filePath, "x", "utf-8"); initSessionLogger(filePath, "debug"); expect(getLogger().level).toBe("silent"); }); it("flushLogs does not crash when no streams", () => { const dir = makeTempDir(); const filePath = join(dir, "not-a-dir"); writeFileSync(filePath, "x", "utf-8"); initSessionLogger(filePath, "info"); expect(() => flushLogs()).not.toThrow(); }); });