Files
my-pi/pi-subagents/test/helpers/boot-extension.ts
T

116 lines
4.0 KiB
TypeScript

/**
* boot-extension.ts — the mock `pi` / `ctx` pair the wiring tests use to boot the
* REAL extension (src/index.ts) and drive its registered tools directly.
*
* The 14 pre-existing wiring tests each inline their own near-identical copy of
* this. They are deliberately NOT migrated: `test/` is outside the tsconfig
* `include`, so that churn would be unchecked by `tsc`, and those copies have
* small divergences that would have to be reconciled blind. New files use this.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
export interface BootedPi {
pi: any;
tools: Map<string, any>;
lifecycle: Map<string, any>;
}
/** A mock ExtensionAPI that records every tool and lifecycle handler registered. */
export function makePi(): BootedPi {
const tools = new Map<string, any>();
const lifecycle = new Map<string, any>();
const pi = {
registerMessageRenderer: vi.fn(),
registerTool: vi.fn((t: any) => tools.set(t.name, t)),
registerCommand: vi.fn(),
on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)),
events: {
emit: vi.fn(),
on: vi.fn(() => vi.fn()),
},
appendEntry: vi.fn(),
sendMessage: vi.fn(),
} as any;
return { pi, tools, lifecycle };
}
/** A mock ExtensionContext — the second half of what a tool's `execute` receives. */
export function ctx(overrides: Record<string, unknown> = {}) {
return {
// The interactive mode extensions normally run in. Set explicitly because
// the `@handle` input hook is TUI-only, so an absent mode would make every
// mention test exercise the headless fall-through instead.
mode: "tui",
hasUI: false,
ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn() },
cwd: process.cwd(),
model: undefined,
modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) },
sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) },
getSystemPrompt: vi.fn(() => "parent"),
...overrides,
} as any;
}
/** Text of a tool result. */
export const textOf = (r: any): string => r.content[0].text;
/** Let queued microtasks run — enough for the manager's internal chaining. */
export const flush = async () => {
await new Promise((r) => setImmediate(r));
await new Promise((r) => setImmediate(r));
};
export interface Hermetic {
dir: string;
restore: () => void;
}
/**
* Redirect cwd, `PI_CODING_AGENT_DIR` and `HOME` into a fresh temp dir, so the
* developer's real settings and agent files can't reach the extension under
* test. Call BEFORE instantiating the extension — settings are read at boot.
*/
export function hermeticDir(opts: {
settings?: Record<string, unknown>;
agentFiles?: Record<string, string>;
} = {}): Hermetic {
const dir = mkdtempSync(join(tmpdir(), "pi-boot-"));
const agentDir = mkdtempSync(join(tmpdir(), "pi-boot-agentdir-"));
const prevCwd = process.cwd();
const prevAgentDir = process.env.PI_CODING_AGENT_DIR;
const prevHome = process.env.HOME;
mkdirSync(join(dir, ".pi"), { recursive: true });
if (opts.settings) {
writeFileSync(join(dir, ".pi", "subagents.json"), JSON.stringify(opts.settings));
}
if (opts.agentFiles) {
mkdirSync(join(dir, ".pi", "agents"), { recursive: true });
for (const [name, content] of Object.entries(opts.agentFiles)) {
writeFileSync(join(dir, ".pi", "agents", `${name}.md`), content);
}
}
process.env.PI_CODING_AGENT_DIR = agentDir;
process.env.HOME = agentDir;
process.chdir(dir);
return {
dir,
restore() {
process.chdir(prevCwd);
if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = prevAgentDir;
if (prevHome == null) delete process.env.HOME;
else process.env.HOME = prevHome;
rmSync(dir, { recursive: true, force: true });
rmSync(agentDir, { recursive: true, force: true });
},
};
}