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 { 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<string, unknown>);
		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<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 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();
	});
});