// SPDX-License-Identifier: MIT // Part of pi-steering. /** * Shared test-double helpers. * * `evaluator.test.ts` and `observer-dispatcher.test.ts` both need: * * - a minimal {@link ExtensionContext} stub whose `sessionManager` * only exposes `getEntries()` (everything else throws on access), * - a "tracked host" {@link EvaluatorHost} that records every * `exec` / `appendEntry` call plus pushes `appendEntry` payloads * into an entries array shaped like the pi session JSONL, so the * same array can back a `makeCtx` stub and let tests assert * cross-handler `findEntries` visibility. * * The two former copies diverged only in whether `makeHost` accepted an * `exec` override (evaluator tests need it to count child-process * invocations for the memoization assertions; observer tests don't). * That's now a single option on the unified helper. * * Kept OUT of the public surface: `__test-helpers__` is a leading-double- * underscore convention indicating "test only"; nothing under `src/` * imports it at runtime. */ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach } from "node:test"; import type { ExtensionContext, ExecResult as PiExecResult, } from "@earendil-works/pi-coding-agent"; import type { EvaluatorHost } from "./evaluator-internals/context.ts"; // --------------------------------------------------------------------------- // Isolated $HOME fixture // --------------------------------------------------------------------------- /** * Per-test scratch `$HOME` fixture. Registers `beforeEach` / * `afterEach` that: * * - `mkdtempSync` a fresh temp dir using `prefix`, * - save `process.env["HOME"]`, point it at the temp dir, * - restore `process.env["HOME"]` and recursively remove the temp * dir on teardown. * * Used by every test surface that exercises the loader's two-layer * discovery (`index.test.ts`, `loader.test.ts`, * `internal/session-runtime.test.ts`) so the per-file scratch-HOME * boilerplate stays in one place. * * The temp dir path is exposed via the optional `onReady` callback, * fired inside `beforeEach`; tests typically stash it in a * describe-scoped `let` for terser reads. */ export function useIsolatedHome( prefix: string, onReady?: (tmp: string) => void, ): void { let tmp: string; let priorHome: string | undefined; beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), prefix)); priorHome = process.env["HOME"]; process.env["HOME"] = tmp; onReady?.(tmp); }); afterEach(() => { if (priorHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = priorHome; rmSync(tmp, { recursive: true, force: true }); }); } /** * Like {@link useIsolatedHome} but also chdirs into the scratch dir, so factory-time tests find the per-test config via the loader's project layer. macOS tmpdir is a symlink; canonicalized via `realpathSync` so cwd-mismatch tests don't see false-divergence. */ export function useScratchHome( prefix: string, onReady?: (tmp: string) => void, ): void { let tmp: string; let priorHome: string | undefined; let priorCwd: string; beforeEach(() => { priorCwd = process.cwd(); tmp = realpathSync(mkdtempSync(join(tmpdir(), prefix))); priorHome = process.env["HOME"]; process.env["HOME"] = tmp; process.chdir(tmp); onReady?.(tmp); }); afterEach(() => { process.chdir(priorCwd); if (priorHome === undefined) delete process.env["HOME"]; else process.env["HOME"] = priorHome; rmSync(tmp, { recursive: true, force: true }); }); } // --------------------------------------------------------------------------- // Steering-config fixture writers // --------------------------------------------------------------------------- /** * Write a single-file steering config to `/.pi/steering.ts`. * `body` is the full module source (must include `export default`). * Used by suites whose fixtures embed regex literals or other * non-JSON-friendly module shapes inline. */ export function writeSteeringSingleFileConfig(dir: string, body: string): void { mkdirSync(join(dir, ".pi"), { recursive: true }); writeFileSync(join(dir, ".pi", "steering.ts"), body, "utf8"); } /** * Write a directory-form steering config to * `/.pi/steering/index.ts`. `body` is the full module source * (must include `export default`). Mirrors the layout the bin tests * use for their isolated `@cad0p/pi-steering` invocations. */ export function writeSteeringDirConfig(dir: string, body: string): void { const pi = join(dir, ".pi", "steering"); mkdirSync(pi, { recursive: true }); writeFileSync(join(pi, "index.ts"), body, "utf8"); } // --------------------------------------------------------------------------- // Session-entry shape // --------------------------------------------------------------------------- /** * Exact shape pi's `sessionManager.getEntries()` returns for entries * produced by `appendEntry`. The evaluator filters to `type: "custom"`, * matches by `customType`, and reads `{ data, timestamp }` — other * fields (`id`, `parentId`) exist on real entries so we mirror them * here to avoid silent type drift. */ export interface CustomEntry { readonly type: "custom"; readonly customType: string; readonly data: unknown; readonly timestamp: string; readonly id: string; readonly parentId: string | null; } // --------------------------------------------------------------------------- // ExtensionContext stub // --------------------------------------------------------------------------- /** * Minimal stub for pi's `ExtensionContext`. Only the fields the * evaluator + observer-dispatcher read are populated; everything else * throws if touched so accidental reliance on unsupported surface * breaks loudly. * * The `entries` array mimics `sessionManager.getEntries()` output — * tests that want cross-handler `findEntries` visibility pass * `host.entries` (from {@link makeTrackedHost}) here so the host's * `appendEntry` writes show up on subsequent reads. */ export function makeCtx( cwd: string, entries: ReadonlyArray = [], ): ExtensionContext { return { cwd, sessionManager: { getEntries: () => entries, // Other SessionManager methods are stubbed to throw via the // unknown-cast below; any accidental dependency surfaces as a // clear TypeError rather than silently passing. } as unknown as ExtensionContext["sessionManager"], } as ExtensionContext; } // --------------------------------------------------------------------------- // Tracked EvaluatorHost // --------------------------------------------------------------------------- /** * Tracked {@link EvaluatorHost} recording every exec / appendEntry * call so tests can assert memoization + audit logging. * * `entries` is the backing array `makeCtx` wraps when tests want the * host's `appendEntry` writes visible to a later `findEntries` read. * Timestamps are monotonically-incrementing second-level ISO strings * so ordering asserts stay stable inside the same millisecond. */ export interface TrackedHost extends EvaluatorHost { readonly execCalls: Array<{ cmd: string; args: string[]; cwd: string }>; readonly appended: Array<{ type: string; data: unknown }>; readonly entries: CustomEntry[]; } /** * Build a {@link TrackedHost}. Optional `exec` override lets evaluator * tests count real invocations against the cache (the default exec * returns `{ stdout: "", stderr: "", code: 0, killed: false }`). */ export function makeTrackedHost(options?: { exec?: (cmd: string, args: string[], cwd: string) => Promise; }): TrackedHost { const execCalls: TrackedHost["execCalls"] = []; const appended: TrackedHost["appended"] = []; const entries: CustomEntry[] = []; let idCounter = 0; return { execCalls, appended, entries, exec: async (cmd, args, opts) => { const cwd = opts?.cwd ?? "/"; execCalls.push({ cmd, args: [...args], cwd }); if (options?.exec) { return options.exec(cmd, args, cwd); } return { stdout: "", stderr: "", code: 0, killed: false }; }, appendEntry: (type, data) => { appended.push({ type, data }); entries.push({ type: "custom", customType: type, data, timestamp: new Date( Date.UTC(2026, 0, 1, 0, 0, idCounter++), ).toISOString(), id: `entry-${idCounter}`, parentId: null, }); }, }; }