import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile, } from "node:fs/promises"; import { dirname, join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; let root = ""; const original = { home: process.env.HOME, klausDebug: process.env.PI_KLAUS_DEBUG, legacyDebug: process.env.KLAUS_DEBUG_LOG, localAppData: process.env.LOCALAPPDATA, stateHome: process.env.XDG_STATE_HOME, }; function restore(name: keyof typeof original, value: string | undefined): void { const key = name === "home" ? "HOME" : name === "klausDebug" ? "PI_KLAUS_DEBUG" : name === "legacyDebug" ? "KLAUS_DEBUG_LOG" : name === "localAppData" ? "LOCALAPPDATA" : "XDG_STATE_HOME"; if (value === undefined) delete process.env[key]; else process.env[key] = value; } async function writeSettings(debug: unknown): Promise { const agentDirectory = join(root, ".pi", "agent"); await mkdir(agentDirectory, { recursive: true }); await writeFile( join(agentDirectory, "settings.json"), JSON.stringify({ "pi-ext": { debug } }), ); } async function tracePaths(directory = root): Promise { const paths: string[] = []; for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name); if (entry.isDirectory()) paths.push(...(await tracePaths(path))); else if (entry.isFile() && entry.name.endsWith(".jsonl")) paths.push(path); } return paths; } async function tracePath(): Promise { const paths = await tracePaths(); expect(paths).toHaveLength(1); return paths[0]; } beforeEach(async () => { vi.resetModules(); vi.doUnmock("node:fs"); root = await mkdtemp(join(process.cwd(), ".klaus-debug-test-")); process.env.HOME = root; process.env.XDG_STATE_HOME = join(root, "state"); process.env.LOCALAPPDATA = join(root, "local-app-data"); delete process.env.PI_KLAUS_DEBUG; delete process.env.KLAUS_DEBUG_LOG; }); afterEach(async () => { vi.doUnmock("node:fs"); for (const [name, value] of Object.entries(original)) restore(name as keyof typeof original, value); if (root) await rm(root, { recursive: true, force: true }); root = ""; }); describe("dbg", () => { it("selects Klaus through global settings and writes private safe JSONL", async () => { await writeSettings(["other", "klaus"]); const { dbg } = await import("../src/pi-ext-debug.js"); dbg?.("test.event", { count: 2, modelId: "claude-sonnet-5", message: "secret prompt", payload: { value: "private-data" }, }); const path = await tracePath(); const record = JSON.parse((await readFile(path, "utf8")).trim()); expect(record).toMatchObject({ extension: "klaus", pid: process.pid, event: "test.event", count: 2, modelId: "claude-sonnet-5", }); expect(record.timestamp).toEqual(expect.any(String)); expect(JSON.stringify(record)).not.toContain("secret"); if (process.platform !== "win32") { expect((await stat(path)).mode & 0o777).toBe(0o600); expect((await stat(dirname(path))).mode & 0o777).toBe(0o700); } }); it("lets PI_KLAUS_DEBUG override global selection and ignores legacy paths", async () => { await writeSettings(true); process.env.PI_KLAUS_DEBUG = "0"; let debug = await import("../src/pi-ext-debug.js"); expect(debug.dbg).toBeUndefined(); vi.resetModules(); await writeSettings(["other"]); process.env.PI_KLAUS_DEBUG = "1"; process.env.KLAUS_DEBUG_LOG = join(root, "legacy.jsonl"); debug = await import("../src/pi-ext-debug.js"); debug.dbg?.("test.env"); expect(await tracePath()).toMatch(/\.jsonl$/); await expect(stat(process.env.KLAUS_DEBUG_LOG)).rejects.toMatchObject({ code: "ENOENT", }); }); it("does no debug work when disabled", async () => { await writeSettings(["other"]); const { dbg } = await import("../src/pi-ext-debug.js"); let evaluated = false; dbg?.( "test.disabled", (() => { evaluated = true; return {}; })(), ); expect(dbg).toBeUndefined(); expect(evaluated).toBe(false); expect(await tracePaths()).toEqual([]); }); it("records unique monotonic spans with one completion each", async () => { await writeSettings("klaus"); const { span: debugSpan } = await import("../src/pi-ext-debug.js"); const first = debugSpan?.("test.operation", { modelId: "claude-opus-5" }); const second = debugSpan?.("test.operation"); await new Promise((resolve) => setTimeout(resolve, 1)); first?.(); first?.("error"); second?.("error"); const records = (await readFile(await tracePath(), "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line)); const starts = records.filter((record) => record.event.endsWith(".start")); const completions = records.filter( (record) => record.event.endsWith(".finish") || record.event.endsWith(".error"), ); expect(starts).toHaveLength(2); expect(completions).toHaveLength(2); expect(starts[0].spanId).not.toBe(starts[1].spanId); expect(completions[0]).toMatchObject({ event: "test.operation.finish", spanId: starts[0].spanId, }); expect(completions[0].durationMs).toBeGreaterThanOrEqual(0); }); it("permanently stops after a write failure", async () => { await writeSettings(true); const actual = await vi.importActual("node:fs"); const writeSync = vi.fn(() => { throw new Error("write failed"); }); vi.doMock("node:fs", () => ({ ...actual, writeSync })); const { dbg, closeDebug } = await import("../src/pi-ext-debug.js"); dbg?.("test.failure"); closeDebug(); dbg?.("test.failure"); expect(writeSync).toHaveBeenCalledOnce(); expect(await tracePath()).toBeTruthy(); }); it("classifies diagnostics without retaining diagnostic text", async () => { const { diagnosticKind } = await import("../src/debug.js"); expect( diagnosticKind(new Error("No message found with message.uuid of: x")), ).toBe("missing-resume-uuid"); expect( diagnosticKind(new Error("OAuth access token has been revoked")), ).toBe("revoked-oauth"); expect(diagnosticKind({ toString: () => "private detail" })).toBe( "redacted", ); }); it("caps each process file and writes one terminal record", async () => { process.env.PI_KLAUS_DEBUG = "1"; const { dbg, closeDebug } = await import("../src/pi-ext-debug.js"); const event = `test.limit.${"x".repeat(100)}`; const modelId = "m".repeat(128); for (let index = 0; index < 45_000; index += 1) dbg?.(event, { index, modelId }); closeDebug(); dbg?.("test.event"); const path = await tracePath(); const size = (await stat(path)).size; const records = (await readFile(path, "utf8")) .trim() .split("\n") .map((line) => JSON.parse(line)); expect(size).toBeLessThanOrEqual(8 * 1024 * 1024); expect( records.filter((record) => record.event === "debug.limit"), ).toHaveLength(1); }); });