| name | test-pi-extension |
| description | Como testar extensões pi com @marcfargas/pi-test-harness. Use quando o usuário quiser testar uma extensão, criar testes automatizados, ou validar que um pacote pi funciona após publish.
|
Testando Extensões Pi
O @marcfargas/pi-test-harness permite testar extensões pi com o runtime real — sem LLM. Tudo roda de verdade (loading, hooks, tools, eventos), exceto o modelo que é substituído por um playbook scriptado.
Instalação
npm install --save-dev @marcfargas/pi-test-harness vitest
Peer dependencies: @earendil-works/pi-coding-agent, @earendil-works/pi-ai, @earendil-works/pi-agent-core.
Teste Básico
import { describe, it, expect, afterEach } from "vitest";
import { createTestSession, when, calls, says, type TestSession } from "@marcfargas/pi-test-harness";
describe("minha extensão", () => {
let t: TestSession;
afterEach(() => t?.dispose());
it("registra e executa uma tool", async () => {
t = await createTestSession({
extensions: ["./src/index.ts"],
mockTools: {
bash: (params) => `$ ${params.command}\noutput`,
read: "conteúdo do arquivo",
write: "ok",
edit: "ok",
},
});
await t.run(
when("Liste os arquivos", [
calls("bash", { command: "ls" }),
says("Encontrei os arquivos."),
]),
);
expect(t.events.toolResultsFor("bash")).toHaveLength(1);
expect(t.events.toolResultsFor("bash")[0].text).toContain("output");
});
});
Arquitetura
┌───────────────────────────────────────┐
│ Ambiente pi REAL │
│ │
│ Extensions ─── carregadas de verdade │
│ Tool registry ─ hooks reais │
│ Session state ─ persistência real │
│ │
│ ┌─────────────────────────────────┐ │
│ │ streamFn ── SUBSTITUÍDO │ │ ← playbook (when/calls/says)
│ │ tool.execute ── INTERCEPTADO │ │ ← mockTools
│ │ ctx.ui.* ── INTERCEPTADO │ │ ← mockUI
│ └─────────────────────────────────┘ │
└───────────────────────────────────────┘
Só 3 pontos são substituídos — todo o resto é pi real.
Playbook DSL
when(prompt, actions) — define um turno de conversa
when("Faça o deploy", [
calls("bash", { command: "pnpm run build" }),
calls("bash", { command: "pnpm run deploy" }),
says("Deploy concluído."),
])
calls(tool, params) — o modelo chama uma tool
Hooks do pi disparam normalmente. A tool executa (real ou mock) e o resultado volta.
says(text) — o modelo emite texto
O turno termina aqui.
Multi-turno
await t.run(
when("O que tem no projeto?", [
calls("bash", { command: "ls" }),
says("3 arquivos."),
]),
when("Leia o README", [
calls("read", { path: "README.md" }),
says("Aqui está o conteúdo..."),
]),
);
Mock Tools
Controla o que as tools retornam sem afetar o fluxo de hooks:
mockTools: {
bash: "command output",
read: (params) => `conteúdo de ${params.path}`,
write: {
content: [{ type: "text", text: "Escrito" }],
details: { bytesWritten: 42 },
},
}
Tools da extensão executam de verdade a menos que estejam em mockTools.
Mock UI
Para extensões que usam ctx.ui.confirm(), ctx.ui.select(), etc:
const t = await createTestSession({
extensions: ["./src/index.ts"],
mockUI: {
confirm: false,
select: 0,
input: "texto do usuário",
editor: "conteúdo editado",
},
});
expect(t.events.uiCallsFor("confirm")).toHaveLength(1);
Handlers dinâmicos:
mockUI: {
confirm: (title, message) => title.includes("Deletar") ? false : true,
select: (title, items) => items.find(i => i.includes("staging")),
}
Capturando Valores entre Steps
Use .then() para capturar resultados e () => params para late binding:
let planId = "";
await t.run(
when("Crie um plano", [
calls("plan_propose", {
title: "Deploy v2",
steps: [{ description: "Build", tool: "bash", operation: "build" }],
}).then((result) => {
planId = result.text.match(/PLAN-[a-f0-9]+/)![0];
}),
calls("plan_approve", () => ({ id: planId })),
says("Plano aprovado."),
]),
);
expect(planId).toMatch(/^PLAN-/);
Verificação de Pacote (Sandbox Install)
Valida que npm pack → install → load funciona antes de publicar:
import { verifySandboxInstall } from "@marcfargas/pi-test-harness";
const result = await verifySandboxInstall({
packageDir: "./packages/minha-extensao",
expect: {
extensions: 1,
tools: ["minha_tool"],
},
});
expect(result.loaded.extensionErrors).toEqual([]);
Com smoke test:
const result = await verifySandboxInstall({
packageDir: "./packages/minha-extensao",
expect: { extensions: 1 },
smoke: {
mockTools: { bash: "ok", read: "ok", write: "ok", edit: "ok" },
script: [
when("Teste", [
calls("minha_tool", { value: "teste" }),
says("Funcionou."),
]),
],
},
});
Mock Pi CLI (Subprocessos)
Para extensões que disparam pi como subprocesso:
import { createMockPi } from "@marcfargas/pi-test-harness";
const mockPi = createMockPi();
mockPi.install();
mockPi.onCall({ output: "Resposta do agente", exitCode: 0 });
mockPi.onCall({ stderr: "erro", exitCode: 1 });
expect(mockPi.callCount()).toBe(0);
mockPi.uninstall();
Coleção de Eventos
Toda a execução é registrada para asserções:
t.events.toolCallsFor("bash")
t.events.toolResultsFor("bash")
t.events.blockedCalls()
t.events.uiCallsFor("notify")
t.events.uiCallsFor("confirm")
t.events.messages
t.events.all
Diagnósticos Automáticos
O harness detecta problemas no playbook automaticamente:
- Playbook esgotado cedo — o agent loop pediu mais ações do que o scriptado
- Playbook não consumido — sobrou ação porque uma tool foi bloqueada ou retornou cedo
Notas de Plataforma
Windows + SQLite
Extensões com SQLite mantêm arquivos locked. Use safeRmSync na limpeza:
import { safeRmSync } from "@marcfargas/pi-test-harness";
afterEach(() => {
t?.dispose();
safeRmSync(dbPath);
});
Exemplo Real — Testando Extensão de Guard
import { createTestSession, when, calls, says } from "@marcfargas/pi-test-harness";
import * as path from "node:path";
const EXT = path.resolve(__dirname, "../../extensions/safe-guard.ts");
describe("safe-guard", () => {
let t;
afterEach(() => t?.dispose());
it("bloqueia rm -rf /", async () => {
t = await createTestSession({
extensions: [EXT],
mockTools: {
bash: "ok",
read: "ok",
write: "ok",
edit: "ok",
},
mockUI: { confirm: false },
});
await t.run(
when("Delete tudo", [
calls("bash", { command: "rm -rf /" }),
says("Comando bloqueado."),
]),
);
const result = t.events.toolResultsFor("bash")[0];
expect(result.isError).toBe(true);
});
});
Referências