import { mkdtempSync, readdirSync, readFileSync, rmSync } 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"; describe("ask debug", () => { let root: string | undefined; let session: TestSession | undefined; afterEach(() => { session?.dispose(); if (root) rmSync(root, { recursive: true, force: true }); session = undefined; root = undefined; }); it("writes its lifecycle JSONL through the standalone factory", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-ask-debug-")); const env = sandboxEnv(root, { env: { PI_ASK_DEBUG: "1" } }); vi.resetModules(); const factory = await withProcessEnv( env, async () => (await import("../index.ts")).default, ); session = await createTestSession({ cwd: root, extensionFactories: [factory], 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", }); if (!env.XDG_STATE_HOME) throw new Error("missing sandbox state home"); const directory = path.join(env.XDG_STATE_HOME, "pi-ext", "debug", "ask"); const [file] = readdirSync(directory); const events = readFileSync(path.join(directory, file), "utf8") .trim() .split("\n") .map((line) => JSON.parse(line).event); expect(events).toEqual( expect.arrayContaining(["session.start", "session.shutdown"]), ); }); it("keeps declared fields and rejects unknown events, values, and accessors", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-ask-schema-")); const env = sandboxEnv(root, { env: { PI_ASK_DEBUG: "1" } }); vi.resetModules(); await withProcessEnv(env, async () => { 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); (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(); }); if (!env.XDG_STATE_HOME) throw new Error("missing sandbox state home"); const directory = path.join(env.XDG_STATE_HOME, "pi-ext", "debug", "ask"); const [file] = readdirSync(directory); 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 () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-ask-segments-")); const env = sandboxEnv(root, { env: { PI_ASK_DEBUG: "1" } }); await withProcessEnv(env, async () => { 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(); }); if (!env.XDG_STATE_HOME) throw new Error("missing sandbox state home"); const directory = path.join(env.XDG_STATE_HOME, "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"); }); });