Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/nushell/__tests__/debug.test.ts

Raw
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<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));
}