repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/nushell/__tests__/debug.test.ts
Rawimport {
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<string, string | undefined> = {},
): 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<T>(
env: NodeJS.ProcessEnv,
fn: () => T | Promise<T>,
): Promise<T> {
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<ExtensionFactory> {
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<string, unknown>);
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<typeof import("node:fs")>("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));
}