import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTestSession, sandboxEnv, type TestSession, withProcessEnv, } from "../../../test/harness"; function tracePaths(directory: string): string[] { if (!pathExists(directory)) return []; return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const candidate = path.join(directory, entry.name); if (entry.isDirectory()) return tracePaths(candidate); return entry.isFile() && entry.name.endsWith(".jsonl") ? [candidate] : []; }); } function pathExists(candidate: string): boolean { try { readdirSync(candidate); return true; } catch { return false; } } async function extensionFactory(env: NodeJS.ProcessEnv) { vi.resetModules(); return await withProcessEnv( env, async () => (await import("../index.ts")).default, ); } function stateHome(env: NodeJS.ProcessEnv): string { if (!env.XDG_STATE_HOME) throw new Error("sandbox has no XDG state home"); return env.XDG_STATE_HOME; } describe("Nushell opt-in debug logging", () => { let session: TestSession | undefined; let root: string | undefined; afterEach(() => { session?.dispose(); session = undefined; vi.doUnmock("node:fs"); if (root) rmSync(root, { recursive: true, force: true }); root = undefined; }); it("writes safe lifecycle events and pairs operation spans", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-debug-")); const env = sandboxEnv(root, { env: { PI_NUSHELL_DEBUG: "1" } }); const factory = await extensionFactory(env); session = await createTestSession({ cwd: root, extensionFactories: [factory], env: { PI_NUSHELL_DEBUG: "1" }, }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); const { freshOperations, NuSession } = await withProcessEnv( session.env, async () => await import("../runtime.ts"), ); await freshOperations(process.execPath).exec("trace-secret-command", root, { onData: () => {}, }); const worker = new NuSession(); await expect( worker.evaluate( path.join(root, "missing-nu"), root, "trace-secret-request", ), ).rejects.toThrow(); await worker.reset(); await runner.emit({ type: "session_shutdown", reason: "quit" }); const paths = tracePaths(stateHome(env)); expect(paths).toHaveLength(1); const [trace] = paths; if (!trace) throw new Error("missing debug trace"); const text = readFileSync(trace, "utf8"); const records = text .trim() .split("\n") .map((line) => JSON.parse(line) as Record); expect(records.map((record) => record.event)).toEqual( expect.arrayContaining(["session.start", "session.shutdown"]), ); for (const event of ["fresh", "session.connect", "session.request"]) { const start = records.find((record) => record.event === `${event}.start`); expect(start).toBeDefined(); const terminal = records.filter( (record) => (record.event === `${event}.finish` || record.event === `${event}.error`) && record.spanId === start?.spanId, ); expect(terminal).toHaveLength(1); expect(terminal[0]?.durationMs).toEqual(expect.any(Number)); } expect(text).not.toContain("trace-secret-command"); expect(text).not.toContain("trace-secret-request"); }); it("lets PI_NUSHELL_DEBUG=0 override global pi-ext selection", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-debug-")); const agentDirectory = path.join(root, ".test-home/.pi/agent"); mkdirSync(agentDirectory, { recursive: true }); writeFileSync( path.join(agentDirectory, "settings.json"), JSON.stringify({ "pi-ext": { debug: "nushell" } }), ); const env = sandboxEnv(root, { env: { PI_NUSHELL_DEBUG: "0" } }); const factory = await extensionFactory(env); session = await createTestSession({ cwd: root, extensionFactories: [factory], env: { PI_NUSHELL_DEBUG: "0" }, }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); await runner.emit({ type: "session_shutdown", reason: "quit" }); expect(tracePaths(stateHome(env))).toEqual([]); }); it("enables through global pi-ext selection", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-debug-")); const agentDirectory = path.join(root, ".test-home/.pi/agent"); mkdirSync(agentDirectory, { recursive: true }); writeFileSync( path.join(agentDirectory, "settings.json"), JSON.stringify({ "pi-ext": { debug: ["other", "nushell"] } }), ); const env = sandboxEnv(root); const factory = await extensionFactory(env); session = await createTestSession({ cwd: root, extensionFactories: [factory], }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); await runner.emit({ type: "session_shutdown", reason: "quit" }); expect(tracePaths(stateHome(env))).toHaveLength(1); }); it("keeps extension work usable after a debug write failure", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-debug-")); const env = sandboxEnv(root, { env: { PI_NUSHELL_DEBUG: "1" } }); const actual = await vi.importActual("node:fs"); const writeSync = vi.fn(() => { throw new Error("debug write failed"); }); vi.doMock("node:fs", () => ({ ...actual, writeSync })); const factory = await extensionFactory(env); session = await createTestSession({ cwd: root, extensionFactories: [factory], env: { PI_NUSHELL_DEBUG: "1" }, }); await session.session.extensionRunner.emit({ type: "session_start", reason: "startup", }); expect(session.session.extensionRunner.getCommand("nushell")).toBeDefined(); expect(writeSync).toHaveBeenCalledOnce(); }); });