/** * Shared test utilities for the pi-docgraph unit test suite. * * - Temp-repo filesystem helpers (isolated, cleaned up by the caller). * - A capturing `ExtensionAPI` mock so tool/command/event registrations can be * inspected and their handlers executed against a minimal `ExtensionContext`. * - Small factories for tickets, session entries, tool results, and themes. * * These are deliberately not a `.test.ts` file so the test runner treats this * as a module, not a suite. */ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import assert from "node:assert/strict"; import type { AgentToolResult, ExtensionAPI, ExtensionContext, ExtensionUIContext, SessionEntry, Theme, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { DocgraphState, Ticket } from "../src/types.ts"; export type MockedTool = ToolDefinition; export type ToolResult = AgentToolResult; // ── Temp repository helpers ─────────────────────────────────────────── /** Create an isolated scratch directory to act as a repo root. */ export function makeTempRepo(): string { return mkdtempSync(join(tmpdir(), "docgraph-test-")); } /** Recursively remove a scratch directory. Safe to call on any path. */ export function cleanupTemp(cwd: string): void { rmSync(cwd, { recursive: true, force: true }); } /** Write a set of files under `cwd`, creating parent directories as needed. */ export function writeFiles(cwd: string, files: Record): void { for (const [rel, content] of Object.entries(files)) { const full = join(cwd, rel); mkdirSync(dirname(full), { recursive: true }); writeFileSync(full, content, "utf-8"); } } /** Read a file under `cwd`, returning null when it does not exist. */ export function readRepoFile(cwd: string, rel: string): string | null { try { return readFileSync(join(cwd, rel), "utf-8"); } catch { return null; } } // ── Mock ExtensionAPI capture ───────────────────────────────────────── export interface CapturedCommand { name: string; description: string; handler: ( args: Record, ctx: ExtensionContext, ) => Promise | void; } export interface CapturedEvent { event: string; handler: (event: Record, ctx: ExtensionContext) => Promise | unknown; } export interface MockPiResult { pi: ExtensionAPI; tools: MockedTool[]; commands: CapturedCommand[]; events: CapturedEvent[]; } /** * Build a capturing `ExtensionAPI`. Every tool/command/event registration is * recorded so tests can inspect metadata and invoke handlers directly. */ export function mockPi(): MockPiResult { const tools: MockedTool[] = []; const commands: CapturedCommand[] = []; const events: CapturedEvent[] = []; const pi = { registerTool: (tool: MockedTool) => { tools.push(tool); }, registerCommand: ( name: string, options: Pick, ) => { commands.push({ name, description: options.description, handler: options.handler }); }, on: (event: string, handler: CapturedEvent["handler"]) => { events.push({ event, handler }); }, } as unknown as ExtensionAPI; return { pi, tools, commands, events }; } /** Run a tool's `registerX` factory and return the single captured definition. */ export function captureTool(registerFn: (pi: ExtensionAPI) => void): MockedTool { const { pi, tools } = mockPi(); registerFn(pi); assert.equal(tools.length, 1, "expected exactly one tool registration"); return tools[0]!; } // ── Mock ExtensionContext ───────────────────────────────────────────── export interface MockCtxResult { ctx: ExtensionContext; /** Every `ui.notify(message, type)` call, in order. */ notifyCalls: Array<{ message: string; type: string | undefined }>; /** Every `ui.setStatus(key, text)` call, in order. */ statusCalls: Array<{ key: string; text: string | undefined }>; } export interface MakeCtxOptions { cwd: string; /** * Session branch entries returned by `sessionManager.getBranch()`. * When omitted and `state` is given, a single docgraph_init tool-result entry * carrying that state is synthesized. */ entries?: SessionEntry[]; /** Persisted docgraph state; synthesized into a branch entry when given. */ state?: DocgraphState; } /** Build a `SessionEntry` for a tool-result message carrying docgraph state. */ export function resultStateEntry( toolName: string, state: DocgraphState, action = "tool", ): SessionEntry { return { type: "message", id: `msg-${Math.random().toString(36).slice(2)}`, parentId: null, timestamp: new Date().toISOString(), message: { role: "toolResult", toolCallId: `call-${Math.random().toString(36).slice(2)}`, toolName, content: [{ type: "text", text: "ok" }], details: { action, state }, isError: false, timestamp: Date.now(), }, } as unknown as SessionEntry; } /** Build a minimal `ExtensionContext` with recording UI and a fake branch. */ export function makeCtx(opts: MakeCtxOptions): MockCtxResult { const notifyCalls: Array<{ message: string; type: string | undefined }> = []; const statusCalls: Array<{ key: string; text: string | undefined }> = []; const ui = { notify: (message: string, type?: "info" | "warning" | "error") => { notifyCalls.push({ message, type }); }, setStatus: (key: string, text: string | undefined) => { statusCalls.push({ key, text }); }, } as unknown as ExtensionUIContext; const entries = opts.entries ?? (opts.state ? [resultStateEntry("docgraph_init", opts.state, "init")] : []); const sessionManager = { getBranch: () => entries, } as unknown as { getBranch: () => SessionEntry[] }; const ctx = { cwd: opts.cwd, ui, sessionManager } as unknown as ExtensionContext; return { ctx, notifyCalls, statusCalls }; } // ── Tool execution helpers ──────────────────────────────────────────── /** Invoke a captured tool definition's `execute` with inert call plumbing. */ export function runTool( tool: MockedTool, params: Record, ctx: ExtensionContext, ): Promise { return tool.execute("test-call-id", params, undefined, undefined, ctx); } /** Extract the first text content item from a tool result. */ export function toolText(result: ToolResult): string { const item = result.content?.[0]; return item?.type === "text" ? (item as { type: "text"; text: string }).text : ""; } /** Render a TUI component created by `renderCall`/`renderResult` to plain text. */ export function componentText(component: unknown): string { // Call render as a method so Text.render keeps its `this` binding. const lines = (component as { render: (width: number) => string[] }).render.call( component, 200, ); return lines .map((line) => line.trim()) .join("\n") .trim(); } /** Render a tool's `renderCall` with a stub theme and context. */ export function renderCall(tool: MockedTool, args: Record): unknown { return tool.renderCall!(args as never, stubTheme(), undefined as never); } /** Render a tool's `renderResult` with a stub theme and context. */ export function renderResult( tool: MockedTool, result: ToolResult, options: unknown = {}, ): unknown { return tool.renderResult!(result, options as never, stubTheme(), undefined as never); } /** Minimal theme stub that passes strings through untouched. */ export function stubTheme(): Theme { return { fg: (_color: string, text: string) => text, bg: (_color: string, text: string) => text, bold: (text: string) => text, italic: (text: string) => text, underline: (text: string) => text, } as unknown as Theme; } // ── Fixtures ────────────────────────────────────────────────────────── export function sampleTicket(overrides: Partial = {}): Ticket { return { id: "001", title: "Add dark mode", status: "backlog", priority: "P2", dependencies: [], context: "", acceptanceCriteria: [], definitionOfDone: [], implementationNotes: [], relatedDocs: [], relatedFiles: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", ...overrides, }; } export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; export const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;