import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; 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; } } // Mirrors the owned fixture's sandbox env so the debug module (which decides its // enablement at import time) reads the same HOME/XDG/agent paths the fixture // later applies for the session's lifetime. function fixtureEnv( root: string, overrides: Record = {}, ): NodeJS.ProcessEnv { return { ...process.env, HOME: path.join(root, ".home"), USERPROFILE: path.join(root, ".home"), APPDATA: path.join(root, ".config"), LOCALAPPDATA: path.join(root, ".test-state"), XDG_CONFIG_HOME: path.join(root, ".config"), XDG_CACHE_HOME: path.join(root, ".cache"), XDG_STATE_HOME: path.join(root, ".test-state"), XDG_DATA_HOME: path.join(root, ".data"), PI_CODING_AGENT_DIR: path.join(root, "agent"), ...overrides, }; } async function withProcessEnv( env: NodeJS.ProcessEnv, fn: () => T | Promise, ): Promise { const original = { ...process.env }; for (const key of Object.keys(process.env)) delete process.env[key]; for (const [key, value] of Object.entries(env)) if (value !== undefined) process.env[key] = value; try { return await fn(); } finally { for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, original); } } // The debug module reads env at import, so the factory must be imported under the // sandbox env. The in-process factory shares the test module graph, so the // node:fs mock below is observable to the extension's debug writer. async function extensionFactory( env: NodeJS.ProcessEnv, ): Promise { vi.resetModules(); return await withProcessEnv( env, async () => (await import("../index.ts")).default as ExtensionFactory, ); } 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: PiSession | undefined; let root: string | undefined; afterEach(async () => { await session?.dispose(); session = undefined; vi.doUnmock("node:fs"); vi.restoreAllMocks(); 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 = fixtureEnv(root, { PI_NUSHELL_DEBUG: "1" }); const factory = await extensionFactory(env); session = await createPiSession({ cwd: root, extensionFactories: [factory], env: { PI_NUSHELL_DEBUG: "1" }, }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); const { freshOperations } = await import("../runtime.ts"); await freshOperations(process.execPath).exec("trace-secret-command", root, { onData: () => {}, }); await runner.emit({ type: "session_shutdown", reason: "quit" }); const paths = tracePaths(stateHome(session.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"]) { 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"); }); 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, "agent"); mkdirSync(agentDirectory, { recursive: true }); writeSettings(agentDirectory, { "pi-ext": { debug: "nushell" } }); const env = fixtureEnv(root, { PI_NUSHELL_DEBUG: "0" }); const factory = await extensionFactory(env); session = await createPiSession({ 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(session.env))).toEqual([]); }); it("enables through global pi-ext selection", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-nushell-debug-")); const agentDirectory = path.join(root, "agent"); mkdirSync(agentDirectory, { recursive: true }); writeSettings(agentDirectory, { "pi-ext": { debug: ["other", "nushell"] }, }); const env = fixtureEnv(root); const factory = await extensionFactory(env); session = await createPiSession({ 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(session.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 = fixtureEnv(root, { 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 createPiSession({ 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(); }); }); function writeSettings(directory: string, value: unknown): void { writeFileSync(path.join(directory, "settings.json"), JSON.stringify(value)); }