import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; 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"; const extensionPath = fileURLToPath(new URL("../index.ts", import.meta.url)); function envValue(env: NodeJS.ProcessEnv, key: string): string { const value = env[key]; if (!value) throw new Error(`missing sandbox environment variable: ${key}`); return value; } function directory(env: NodeJS.ProcessEnv): string { const home = envValue(env, "HOME"); if (process.platform === "darwin") return path.join(home, "Library", "Logs", "pi-ext", "firefox-bidi"); if (process.platform === "win32") return path.join( env["LOCALAPPDATA"] ?? path.join(home, "AppData", "Local"), "pi-ext", "debug", "firefox-bidi", ); return path.join( envValue(env, "XDG_STATE_HOME"), "pi-ext", "debug", "firefox-bidi", ); } function records(env: NodeJS.ProcessEnv): Record[] { try { const files = readdirSync(directory(env)).filter((file) => file.endsWith(".jsonl"), ); return files.flatMap((file) => readFileSync(path.join(directory(env), file), "utf8") .trim() .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as Record), ); } catch { return []; } } function sandboxEnv(root: string, debug?: "0" | "1"): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, HOME: path.join(root, ".test-home"), USERPROFILE: path.join(root, ".test-home"), XDG_STATE_HOME: path.join(root, ".test-state"), APPDATA: path.join(root, ".test-config"), LOCALAPPDATA: path.join(root, ".test-state"), }; delete env["PI_CODING_AGENT_DIR"]; if (debug !== undefined) env["PI_FIREFOX_BIDI_DEBUG"] = debug; return env; } async function withProcessEnv( env: NodeJS.ProcessEnv, fn: () => T | Promise, ): Promise { const original = { ...process.env }; for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, env); try { return await fn(); } finally { for (const key of Object.keys(process.env)) delete process.env[key]; Object.assign(process.env, original); } } async function writeSettings( env: NodeJS.ProcessEnv, debug: unknown, ): Promise { const agentDirectory = path.join(envValue(env, "HOME"), ".pi", "agent"); await mkdir(agentDirectory, { recursive: true }); await writeFile( path.join(agentDirectory, "settings.json"), JSON.stringify({ "pi-ext": { debug } }), ); } describe("firefox-bidi debug", () => { let root: string | undefined; let session: PiSession | undefined; afterEach(async () => { vi.doUnmock("node:fs"); vi.resetModules(); await session?.dispose(); if (root) rmSync(root, { recursive: true, force: true }); session = undefined; root = undefined; }); it("writes lifecycle events and pairs command spans", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-firefox-bidi-debug-")); session = await createPiSession({ cwd: root, extensionPaths: [extensionPath], env: { PI_FIREFOX_BIDI_DEBUG: "1" }, }); const runner = session.session.extensionRunner; await runner.emit({ type: "session_start", reason: "startup" }); await runner .getCommand("firefox") ?.handler("close", runner.createCommandContext()); await runner.emit({ type: "session_shutdown", reason: "quit" }); const sandbox = session.env; await vi.waitFor(() => { const events = records(sandbox).map((record) => record["event"]); expect(events).toEqual( expect.arrayContaining([ "session.start", "session.shutdown", "firefox.command.start", "firefox.command.finish", ]), ); }); const trace = records(session.env); const start = trace.find( (record) => record["event"] === "firefox.command.start", ); const finish = trace.find( (record) => record["event"] === "firefox.command.finish", ); expect(finish).toMatchObject({ spanId: start?.["spanId"], durationMs: expect.any(Number), }); expect(finish?.["durationMs"]).toBeGreaterThanOrEqual(0); }); it("uses user-level extension selection", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-firefox-bidi-debug-")); const env = sandboxEnv(root); await writeSettings(env, "firefox-bidi"); vi.resetModules(); const debug = await withProcessEnv( env, () => import("../src/pi-ext-debug.ts"), ); await withProcessEnv(env, () => { debug.dbg?.("test.selection", { count: 1, payload: "secret" }); debug.closeDebug(); }); const [record] = records(env); expect(record).toMatchObject({ extension: "firefox-bidi", event: "test.selection", count: 1, }); expect(JSON.stringify(record)).not.toContain("secret"); }); it("does no debug work when explicit disable overrides settings", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-firefox-bidi-debug-")); const env = sandboxEnv(root, "0"); await writeSettings(env, true); vi.resetModules(); const debug = await withProcessEnv( env, () => import("../src/pi-ext-debug.ts"), ); let evaluated = false; debug.dbg?.( "test.disabled", (() => { evaluated = true; return {}; })(), ); expect(debug.dbg).toBeUndefined(); expect(evaluated).toBe(false); expect(records(env)).toEqual([]); }); it("does not throw when a debug write fails", async () => { root = mkdtempSync(path.join(tmpdir(), "pi-ext-firefox-bidi-debug-")); const env = sandboxEnv(root, "1"); const actual = await vi.importActual("node:fs"); const writeSync = vi.fn(() => { throw new Error("write failed"); }); vi.doMock("node:fs", () => ({ ...actual, writeSync })); vi.resetModules(); const debug = await withProcessEnv( env, () => import("../src/pi-ext-debug.ts"), ); await expect( withProcessEnv(env, () => { debug.dbg?.("test.failure"); debug.dbg?.("test.failure"); debug.closeDebug(); }), ).resolves.toBeUndefined(); expect(writeSync).toHaveBeenCalledOnce(); }); });