import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildAgentCliLogSources, buildRunEventLogSources } from "../src/agent-logs.js"; let tempDir: string; beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), "diagnostics-agent-logs-")); }); afterEach(async () => { await rm(tempDir, { recursive: true, force: true }); }); async function touch(path: string, content = "x"): Promise { await mkdir(join(path, ".."), { recursive: true }); await writeFile(path, content, "utf8"); } async function touchAt(path: string, mtime: Date, content = "x"): Promise { await touch(path, content); await utimes(path, mtime, mtime); } describe("buildRunEventLogSources", () => { it("returns [] when no runs dir", async () => { expect(await buildRunEventLogSources(null)).toEqual([]); expect(await buildRunEventLogSources(join(tempDir, "missing"))).toEqual([]); }); it("collects the most-recent per-run events.jsonl, newest first", async () => { const runsDir = join(tempDir, "runs"); await touchAt(join(runsDir, "run-a", "events.jsonl"), new Date("2026-01-01T00:00:00.000Z"), "a"); await touchAt(join(runsDir, "run-b", "events.jsonl"), new Date("2026-01-02T00:00:00.000Z"), "b"); // Directory without events.jsonl is skipped. await mkdir(join(runsDir, "run-empty"), { recursive: true }); const sources = await buildRunEventLogSources(runsDir, { maxRuns: 5 }); const names = sources.map((s) => s.name); expect(names).toEqual(["runs/run-b/events.jsonl", "runs/run-a/events.jsonl"]); for (const source of sources) { expect(source.kind).toBe("text"); expect(source.tailBytes).toBeGreaterThan(0); } }); it("caps the number of runs", async () => { const runsDir = join(tempDir, "runs"); for (let i = 0; i < 5; i += 1) { await touchAt(join(runsDir, `run-${i}`, "events.jsonl"), new Date(2026, 0, i + 1), String(i)); } const sources = await buildRunEventLogSources(runsDir, { maxRuns: 2 }); expect(sources.map((source) => source.name)).toEqual([ "runs/run-4/events.jsonl", "runs/run-3/events.jsonl", ]); }); it("skips unsafe run directory names when collecting event logs", async () => { const runsDir = join(tempDir, "runs"); await touch(join(runsDir, "safe-run_1.2", "events.jsonl"), "safe"); await touch(join(runsDir, "unsafe run", "events.jsonl"), "space"); await touch(join(runsDir, "unsafe:run", "events.jsonl"), "colon"); const sources = await buildRunEventLogSources(runsDir, { maxRuns: 5 }); expect(sources.map((source) => source.name)).toEqual([ "runs/safe-run_1.2/events.jsonl", ]); }); }); describe("buildAgentCliLogSources", () => { it("collects claude / codex / opencode / amr log files under their known dirs", async () => { const home = join(tempDir, "home"); const dataDir = join(tempDir, "data"); await touch(join(home, ".claude", "daemon.log")); await touch(join(home, ".codex", "log", "codex-tui.log")); await touch(join(home, ".local", "share", "opencode", "log", "2026-01-01T00.log")); await touch( join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "2026-01-02T00.log"), ); // Secret-bearing files outside *.log dirs must NOT be swept. await touch(join(home, ".codex", "auth.json"), "secret"); await touch(join(home, ".amr", "config.json"), "secret"); const sources = await buildAgentCliLogSources({ homeDir: home, dataDir }); const names = sources.map((s) => s.name); expect(names).toContain("agent-cli-logs/claude/daemon.log"); expect(names).toContain("agent-cli-logs/codex/codex-tui.log"); expect(names).toContain("agent-cli-logs/opencode/2026-01-01T00.log"); expect(names).toContain("agent-cli-logs/amr/2026-01-02T00.log"); // No secrets, and only *.log files. expect(names.some((n) => n.includes("auth.json"))).toBe(false); expect(names.some((n) => n.includes("config.json"))).toBe(false); for (const source of sources) { expect(source.name.endsWith(".log")).toBe(true); expect(source.tailBytes).toBeGreaterThan(0); } }); it("honors an explicit amrOpenCodeHome override over the dataDir default", async () => { const home = join(tempDir, "home"); const dataDir = join(tempDir, "data"); const overrideHome = join(tempDir, "custom-amr-home"); // Default location has a log, but the override points elsewhere — only the // override's logs should be swept (mirrors a user OPENCODE_TEST_HOME). await touch( join(dataDir, "amr", "opencode-home", ".local", "share", "opencode", "log", "default.log"), ); await touch( join(overrideHome, ".local", "share", "opencode", "log", "override.log"), ); const sources = await buildAgentCliLogSources({ homeDir: home, dataDir, amrOpenCodeHome: overrideHome }); const amrNames = sources.filter((s) => s.name.startsWith("agent-cli-logs/amr/")).map((s) => s.name); expect(amrNames).toContain("agent-cli-logs/amr/override.log"); expect(amrNames).not.toContain("agent-cli-logs/amr/default.log"); }); it("honors claudeConfigDir / codexHome overrides over the home defaults", async () => { const home = join(tempDir, "home"); const claudeDir = join(tempDir, "custom-claude"); const codexHome = join(tempDir, "custom-codex"); // Default homes have logs that must be ignored when overrides are set. await touch(join(home, ".claude", "daemon.log")); await touch(join(home, ".codex", "log", "codex-tui.log")); await touch(join(claudeDir, "cost-tracker.log")); await touch(join(codexHome, "log", "session.log")); const sources = await buildAgentCliLogSources({ homeDir: home, claudeConfigDir: claudeDir, codexHome }); const names = sources.map((s) => s.name); expect(names).toContain("agent-cli-logs/claude/cost-tracker.log"); expect(names).toContain("agent-cli-logs/codex/session.log"); expect(names).not.toContain("agent-cli-logs/claude/daemon.log"); expect(names).not.toContain("agent-cli-logs/codex/codex-tui.log"); }); it("honors XDG_DATA_HOME for the opencode user log dir", async () => { const home = join(tempDir, "home"); const xdg = join(tempDir, "xdg"); await touch(join(xdg, "opencode", "log", "session.log")); const sources = await buildAgentCliLogSources({ homeDir: home, xdgDataHome: xdg }); expect(sources.map((s) => s.name)).toContain("agent-cli-logs/opencode/session.log"); }); it("returns [] when homeDir is empty", async () => { expect(await buildAgentCliLogSources({ homeDir: "" })).toEqual([]); }); });