Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/ask/__tests__/debug.test.ts

Raw
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
	createPiSession,
	type PiSession,
} from "../../../test/fixture/create-pi-session.ts";

describe("ask debug", () => {
	let session: PiSession | undefined;

	afterEach(async () => {
		await session?.dispose();
		session = undefined;
		vi.unstubAllEnvs();
	});

	it("writes its lifecycle JSONL through the standalone factory", async () => {
		session = await createPiSession({
			extensionPaths: [fileURLToPath(new URL("../index.ts", import.meta.url))],
			env: { PI_ASK_DEBUG: "1" },
		});
		await session.session.extensionRunner.emit({
			type: "session_start",
			reason: "startup",
		});
		await session.session.extensionRunner.emit({
			type: "session_shutdown",
			reason: "quit",
		});

		const stateHome = session.env["XDG_STATE_HOME"];
		if (!stateHome) throw new Error("missing sandbox state home");
		const directory = path.join(stateHome, "pi-ext", "debug", "ask");
		const events = await vi.waitFor(() => {
			const [file] = readdirSync(directory);
			if (!file) throw new Error("no debug file yet");
			const parsed = readFileSync(path.join(directory, file), "utf8")
				.trim()
				.split("\n")
				.map((line) => JSON.parse(line).event);
			if (
				!parsed.includes("session.start") ||
				!parsed.includes("session.shutdown")
			)
				throw new Error("lifecycle events not flushed yet");
			return parsed;
		});
		expect(events).toEqual(
			expect.arrayContaining(["session.start", "session.shutdown"]),
		);
	});

	it("keeps declared fields and rejects unknown events, values, and accessors", async () => {
		session = await createPiSession({ env: { PI_ASK_DEBUG: "1" } });
		vi.resetModules();
		const { defineDebug } = await import("../src/pi-ext-debug.ts");
		const { dbg, span, closeDebug } = defineDebug({
			events: ["request.done"],
			spans: ["request"],
			fields: {
				retryCount: "number",
				hasToken: "boolean",
				outcome: ["ok", "timeout"],
			},
		} as const);
		const hostile = Object.defineProperties(
			{ retryCount: 2, hasToken: true, outcome: "ok", payload: "secret" },
			{
				secret: {
					get: () => {
						throw new Error("must not read undeclared metadata");
					},
				},
			},
		);
		dbg?.("request.done", hostile as Parameters<NonNullable<typeof dbg>>[1]);
		(dbg as ((event: string, details?: unknown) => void) | undefined)?.(
			"token-shaped-secret",
			{ retryCount: 3 },
		);
		const end = span?.("request", { retryCount: 4 });
		end?.("finish", { outcome: "timeout" });
		(dbg as ((event: string, details?: unknown) => void) | undefined)?.(
			"request.done",
			{ outcome: "sk-test-ABC123", retryCount: Number.POSITIVE_INFINITY },
		);
		const accessor = Object.defineProperty({}, "retryCount", {
			get: () => {
				throw new Error("must not inspect declared accessors");
			},
		});
		(dbg as ((event: string, details?: unknown) => void) | undefined)?.(
			"request.done",
			accessor,
		);
		expect(
			defineDebug({
				events: ["invalid"],
				spans: [],
				fields: { pid: "number" },
			}).dbg,
		).toBeUndefined();
		closeDebug();

		const stateHome = session.env["XDG_STATE_HOME"];
		if (!stateHome) throw new Error("missing sandbox state home");
		const directory = path.join(stateHome, "pi-ext", "debug", "ask");
		const [file] = readdirSync(directory);
		if (!file) throw new Error("no debug file");
		const records = readFileSync(path.join(directory, file), "utf8")
			.trim()
			.split("\n")
			.map((line) => JSON.parse(line));
		expect(records).toEqual([
			expect.objectContaining({
				event: "request.done",
				retryCount: 2,
				hasToken: true,
				outcome: "ok",
			}),
			expect.objectContaining({
				event: "request.start",
				retryCount: 4,
				spanId: expect.any(String),
			}),
			expect.objectContaining({
				event: "request.finish",
				outcome: "timeout",
				durationMs: expect.any(Number),
			}),
			expect.objectContaining({ event: "request.done" }),
			expect.objectContaining({ event: "request.done" }),
		]);
		expect(JSON.stringify(records)).not.toMatch(/secret|payload|Infinity/);
		expect(records[3]).not.toHaveProperty("outcome");
		expect(records[3]).not.toHaveProperty("retryCount");
		expect(records[4]).not.toHaveProperty("retryCount");
	});

	it("continues in a new private file after a clean close", async () => {
		session = await createPiSession({ env: { PI_ASK_DEBUG: "1" } });
		vi.resetModules();
		const { defineDebug } = await import("../src/pi-ext-debug.ts");
		const { dbg, span, closeDebug } = defineDebug({
			events: ["session.start"],
			spans: ["operation"],
			fields: {},
		} as const);
		const end = span?.("operation");
		closeDebug();
		closeDebug();
		end?.();
		dbg?.("session.start");
		closeDebug();

		const stateHome = session.env["XDG_STATE_HOME"];
		if (!stateHome) throw new Error("missing sandbox state home");
		const directory = path.join(stateHome, "pi-ext", "debug", "ask");
		const segments = readdirSync(directory).map((file) =>
			readFileSync(path.join(directory, file), "utf8")
				.trim()
				.split("\n")
				.map((line) => JSON.parse(line)),
		);
		expect(segments).toHaveLength(2);
		const starts = segments
			.flat()
			.filter((row) => row.event === "operation.start");
		const finishes = segments
			.flat()
			.filter((row) => row.event === "operation.finish");
		expect(starts).toHaveLength(1);
		expect(finishes).toEqual([
			expect.objectContaining({
				spanId: starts[0].spanId,
				durationMs: expect.any(Number),
			}),
		]);
		expect(segments.flat().map((row) => row.event)).toContain("session.start");
	});
});