@abdwhb-png/pi-test-harness 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +161 -0
- package/LICENSE +21 -0
- package/README.md +673 -0
- package/dist/diagnostics.d.ts +11 -0
- package/dist/diagnostics.d.ts.map +1 -0
- package/dist/diagnostics.js +61 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/events.d.ts +6 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +33 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-pi-script.mjs +176 -0
- package/dist/mock-pi.d.ts +32 -0
- package/dist/mock-pi.d.ts.map +1 -0
- package/dist/mock-pi.js +150 -0
- package/dist/mock-pi.js.map +1 -0
- package/dist/mock-tools.d.ts +51 -0
- package/dist/mock-tools.d.ts.map +1 -0
- package/dist/mock-tools.js +192 -0
- package/dist/mock-tools.js.map +1 -0
- package/dist/mock-ui.d.ts +13 -0
- package/dist/mock-ui.d.ts.map +1 -0
- package/dist/mock-ui.js +159 -0
- package/dist/mock-ui.js.map +1 -0
- package/dist/pi-loader-parity.d.ts +36 -0
- package/dist/pi-loader-parity.d.ts.map +1 -0
- package/dist/pi-loader-parity.js +60 -0
- package/dist/pi-loader-parity.js.map +1 -0
- package/dist/playbook.d.ts +44 -0
- package/dist/playbook.d.ts.map +1 -0
- package/dist/playbook.js +143 -0
- package/dist/playbook.js.map +1 -0
- package/dist/sandbox.d.ts +27 -0
- package/dist/sandbox.d.ts.map +1 -0
- package/dist/sandbox.js +269 -0
- package/dist/sandbox.js.map +1 -0
- package/dist/session.d.ts +13 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +187 -0
- package/dist/session.js.map +1 -0
- package/dist/types.d.ts +171 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +32 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +46 -0
- package/dist/utils.js.map +1 -0
- package/package.json +84 -0
- package/skills/pi-test-harness/SKILL.md +451 -0
- package/skills/pi-test-harness/evals/evals.json +26 -0
- package/skills/pi-test-harness/references/api-reference.md +480 -0
- package/skills/pi-test-harness/references/mock-pi-cli.md +135 -0
- package/skills/pi-test-harness/references/mock-tools.md +176 -0
- package/skills/pi-test-harness/references/mock-ui.md +170 -0
- package/skills/pi-test-harness/references/playbook-dsl.md +209 -0
- package/skills/pi-test-harness/references/sandbox-install.md +113 -0
- package/src/diagnostics.ts +90 -0
- package/src/events.ts +43 -0
- package/src/index.ts +42 -0
- package/src/mock-pi-script.mjs +176 -0
- package/src/mock-pi.ts +169 -0
- package/src/mock-tools.ts +252 -0
- package/src/mock-ui.ts +196 -0
- package/src/pi-loader-parity.ts +61 -0
- package/src/playbook.ts +189 -0
- package/src/sandbox.ts +334 -0
- package/src/session.ts +249 -0
- package/src/types.ts +203 -0
- package/src/utils.ts +46 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-test-harness
|
|
3
|
+
description: >
|
|
4
|
+
Write deterministic tests for Pi (earendil-works/pi-coding-agent) extensions using
|
|
5
|
+
@abdwhb-png/pi-test-harness — assert on tools, hooks, and UI without calling an LLM.
|
|
6
|
+
Works with any JavaScript/TypeScript test runner (Vitest, bun:test, Jest). Use when
|
|
7
|
+
the user mentions pi-test-harness, createTestSession, createMockPi, verifySandboxInstall, "test my pi extension", "playbook mocking", pi-coding-agent, earendil pi, or writes a Pi extension test in Vitest/bun:test/Jest. Prefer this over generic test-runner guidance whenever Pi runtime types appear (AgentSession, ExtensionUIContext, ctx.ui, ToolResultRecord).
|
|
8
|
+
metadata:
|
|
9
|
+
package_author: "abdwhb-png (maintained fork of marcfargas' MIT-licensed pi-test-harness; pi itself is by Mario Zechner / earendil-works)"
|
|
10
|
+
skill_author: abdwhb-png
|
|
11
|
+
source: https://github.com/abdwhb-png/pi-test-harness
|
|
12
|
+
version: "0.7.0"
|
|
13
|
+
languages: [TypeScript, JavaScript]
|
|
14
|
+
category: testing
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
# pi-test-harness
|
|
18
|
+
|
|
19
|
+
`@abdwhb-png/pi-test-harness` is a maintained fork of Marc Fargas' MIT-licensed [pi-test-harness](https://github.com/marcfargas/pi-test-harness) for [Pi](https://github.com/earendil-works/pi-coding-agent) extensions, targeting **Pi 0.85.x only**. It keeps the upstream API and credits Marc Fargas' original work (pi-powershell, pi-tramp, pi-planner, etc.; note that pi itself is maintained by Mario Zechner / earendil-works, not the same person). Its job: let you exercise **real** extension code paths (tool registration, hooks, session events, UI prompts) in any test runner with zero LLM calls and full determinism.
|
|
20
|
+
|
|
21
|
+
> **Verify before use**: this skill snapshots the fork README as of harness v0.7.0 (Pi 0.85.x). If the API ends up looking subtly different, re-fetch the README from <https://github.com/abdwhb-png/pi-test-harness> before trusting any snippet here. Note: the harness version (e.g. `0.7.0`) and the Pi version (e.g. `0.85.x`) are **independent release tracks** — do not compare them numerically.
|
|
22
|
+
|
|
23
|
+
## The mental model: "let pi be pi"
|
|
24
|
+
|
|
25
|
+
The harness minimizes faking. Everything runs through Pi's real code (extension loader via jiti, real tool wrapping pipeline, `AgentSession`'s hook pipeline, real event system). Only three substitution points are intercepted, all at boundaries:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
+-------------------------------------------+
|
|
29
|
+
| Real Pi environment |
|
|
30
|
+
| |
|
|
31
|
+
| Extensions --- loaded for real |
|
|
32
|
+
| Tool registry - real hooks + wrapping |
|
|
33
|
+
| Session state - in-memory persistence |
|
|
34
|
+
| |
|
|
35
|
+
| +-------------------------------------+ |
|
|
36
|
+
| | Agent Loop | |
|
|
37
|
+
| | | |
|
|
38
|
+
| | streamFn ---- REPLACED by playbook | |
|
|
39
|
+
| | tool.execute() INTERCEPTED if mock | |
|
|
40
|
+
| | ctx.ui.* ---- INTERCEPTED + logged | |
|
|
41
|
+
| +-------------------------------------+ |
|
|
42
|
+
+-------------------------------------------+
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| Boundary | Replaced by | Why |
|
|
46
|
+
|-----------------|----------------|--------------------------------------------------|
|
|
47
|
+
| `streamFn` | Playbook | The LLM is the only non-deterministic piece |
|
|
48
|
+
| `tool.execute()`| Mock handler | Control what built-in tools "return" (hooks still fire) |
|
|
49
|
+
| `ctx.ui.*` | Mock UI | Control what the user "answers" |
|
|
50
|
+
|
|
51
|
+
Read this as: **only the LLM is non-deterministic, so only the LLM is faked.** Real-provider smoke tests belong in app code with the provider configured, not here.
|
|
52
|
+
|
|
53
|
+
## Which layer do I need?
|
|
54
|
+
|
|
55
|
+
The package ships three independent test layers. Pick by what you want to verify:
|
|
56
|
+
|
|
57
|
+
| Goal | Use | See reference |
|
|
58
|
+
|---------------------------------------------------|---------------------------|-----------------------------|
|
|
59
|
+
| Test extension logic in-process (most common) | `createTestSession` | `references/playbook-dsl.md` + `mock-tools.md` + `mock-ui.md` |
|
|
60
|
+
| Verify an npm package installs + loads correctly | `verifySandboxInstall` | `references/sandbox-install.md` |
|
|
61
|
+
| Test an extension that **spawns** `pi` as a subprocess | `createMockPi` | `references/mock-pi-cli.md` |
|
|
62
|
+
|
|
63
|
+
For type shapes (`ToolResultRecord`, `MockUIConfig`, `MockToolHandler`, `MockPiCall`, `TestSession`, `TestEvents`), see `references/api-reference.md`.
|
|
64
|
+
|
|
65
|
+
## Test runner: agnostic, not Vitest-only
|
|
66
|
+
|
|
67
|
+
The harness is **test-runner-agnostic**. Its peer dependencies are only the three `@earendil-works/*` Pi packages — no test runner is declared. Every export (`createTestSession`, `when`/`calls`/`says`, `t.events.*`, `createMockPi`, `verifySandboxInstall`, `safeRmSync`, `ToolBlockedError`) is plain TypeScript that produces promises, events, and recorded state. It runs unchanged under **Vitest**, **bun:test**, or **Jest**.
|
|
68
|
+
|
|
69
|
+
The `describe / it / expect / afterEach / beforeEach` idioms used in the snippets below are Jest-compatible names — Vitest, bun:test, and Jest all expose them with identical signatures. Only the **import line** varies:
|
|
70
|
+
|
|
71
|
+
| Stack | Import |
|
|
72
|
+
|------------|---------------------------------------------------------------------------------------|
|
|
73
|
+
| Vitest | `import { describe, it, expect, afterEach } from "vitest";` |
|
|
74
|
+
| bun:test | `import { describe, it, expect, afterEach } from "bun:test";` |
|
|
75
|
+
| Jest | `import { describe, it, expect, afterEach } from "@jest/globals";` |
|
|
76
|
+
|
|
77
|
+
This skill's snippets use the Vitest import for concreteness (the upstream README uses Vitest and it's the most common Pi-extension convention). Swap that one line if your project uses a different runner.
|
|
78
|
+
|
|
79
|
+
**Things to verify if you go off-script (bun:test in particular)**: process-exit timing for `safeRmSync`'s `EPERM`/`EBUSY` swallow (the harness assumes Node-like exit behavior — bun's process-exit timing can differ for SQLite lock release); and the `createMockPi` PATH-shim mechanism uses `child_process.spawn` under the hood, which bun handles via Node-compat but is worth a smoke test. Both work in theory; neither is in the package's upstream CI matrix (which is Vitest-only).
|
|
80
|
+
|
|
81
|
+
## When NOT to use this skill
|
|
82
|
+
|
|
83
|
+
This harness pays the cost of booting a real Pi session (jiti, tool wrapping, hook runner, event system). That cost is justified when you actually need it and pure noise when you don't.
|
|
84
|
+
|
|
85
|
+
**Use plain `bun:test` / Vitest / Jest instead when:**
|
|
86
|
+
|
|
87
|
+
- Testing **pure helpers** (string formatting, JSON transforms, schema validation, math) that don't touch `ctx`, the tool registry, or hooks.
|
|
88
|
+
- Testing **imports** in isolation — `mock.module()` + `await import()` is faster and sufficient.
|
|
89
|
+
- Testing **type shapes** — `tsc --noEmit` or a `expectTypeOf` check needs no runtime.
|
|
90
|
+
|
|
91
|
+
**Reach for this harness when the test must exercise:**
|
|
92
|
+
|
|
93
|
+
- Tool registration (your extension calls `ctx.registerTool(...)`).
|
|
94
|
+
- Hook behavior (`tool_call`, `tool_result`, `session_start`, plan-mode gating).
|
|
95
|
+
- Multi-step agent flow (tool A output feeds tool B input).
|
|
96
|
+
- UI prompts (`ctx.ui.confirm()`, `.select()`, `.input()`).
|
|
97
|
+
- Real tool wrapping pipeline (your `beforeExecute` / `renderResult` overrides).
|
|
98
|
+
|
|
99
|
+
Rule of thumb: if dropping Pi's runtime would make the test trivially pass without exercising the bug you're guarding against — use this harness. Otherwise, don't.
|
|
100
|
+
|
|
101
|
+
## Quick start
|
|
102
|
+
|
|
103
|
+
A full first test, ~15 lines:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { describe, it, expect, afterEach } from "vitest"; // <- swap for "bun:test" or "@jest/globals"
|
|
107
|
+
import {
|
|
108
|
+
createTestSession,
|
|
109
|
+
when, calls, says,
|
|
110
|
+
type TestSession,
|
|
111
|
+
} from "@abdwhb-png/pi-test-harness";
|
|
112
|
+
|
|
113
|
+
describe("my extension", () => {
|
|
114
|
+
let t: TestSession;
|
|
115
|
+
afterEach(() => t?.dispose());
|
|
116
|
+
|
|
117
|
+
it("calls a tool and responds", async () => {
|
|
118
|
+
t = await createTestSession({
|
|
119
|
+
extensions: ["./src/index.ts"],
|
|
120
|
+
mockTools: {
|
|
121
|
+
bash: (params) => `$ ${params.command}\nfile1.txt\nfile2.txt`,
|
|
122
|
+
read: "file contents here",
|
|
123
|
+
write: "written",
|
|
124
|
+
edit: "edited",
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await t.run(
|
|
129
|
+
when("List files in the project", [
|
|
130
|
+
calls("bash", { command: "ls" }),
|
|
131
|
+
says("Found 2 files: file1.txt and file2.txt."),
|
|
132
|
+
]),
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(t.events.toolResultsFor("bash")).toHaveLength(1);
|
|
136
|
+
expect(t.events.toolResultsFor("bash")[0].text).toContain("file1.txt");
|
|
137
|
+
expect(t.events.toolResultsFor("bash")[0].mocked).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Read it like this: `createTestSession` boots a real Pi session, loads `./src/index.ts` as the extension under test, and intercepts the four built-in tools listed in `mockTools`. The playbook passed to `t.run(...)` **is** what the model "says" — first a user prompt, then the model calls `bash` with `{ command: "ls" }`, then the model emits the text `"Found 2 files..."`. After the run, you assert against the recorded events.
|
|
143
|
+
|
|
144
|
+
#### Extension specifiers — what `extensions: [...]` accepts
|
|
145
|
+
|
|
146
|
+
Each entry in the `extensions` array follows Pi's normal extension resolution rules. The harness itself does not invent a new format. Pi's loader accepts **literal paths only — no glob expansion** (`*`, `?`, `{}` are treated as literal characters and the entry is silently skipped if the resulting path doesn't exist). Any of these work:
|
|
147
|
+
|
|
148
|
+
| Form | Example | Resolves to |
|
|
149
|
+
| ------------ | ------------------------------------------ | ----------------------------------------------- |
|
|
150
|
+
| Relative path | `"./src/index.ts"` | Relative to the test process CWD |
|
|
151
|
+
| Absolute path | `"/abs/path/to/index.ts"` | Used as-is |
|
|
152
|
+
| Tilde path | `"~/path/to/ext.ts"` | Expanded via tilde, then used as-is |
|
|
153
|
+
| Directory path | `"./extensions"` | Pi enumerates `*.ts` / `*.js` direct children (one level deep, not glob) |
|
|
154
|
+
|
|
155
|
+
If your test runs from your package root, `"./src/index.ts"` is correct. Paths are resolved by Pi's real extension loader (via jiti), so anything Pi's CLI accepts in `-e` or `settings.json` `extensions:` config is accepted here too. Entrypoints are evaluated in the loader configuration Pi's shipped runtimes use, so `extensions: [...]` behaves the same under vitest and `bun test` — including entrypoints that use a module-level `await import(...)`.
|
|
156
|
+
|
|
157
|
+
**Distributed packages are a different field.** Don't put them in `extensions:`. Use Pi's `packages:` config (with `npm:`/`git:`/`github:` prefixes) or `pi install <pkg>`, then let the package's `package.json` `pi.extensions` field point at the entry file(s):
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"packages": ["npm:@foo/bar@1.0.0", "git:github.com/user/repo@v1"]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
To test a local package with the harness, `pi install` it first (or `npm pack` + install into the test repo), then rely on Pi's auto-discovery of the `pi.extensions` field in the installed `package.json` — don't try to reference the package's internal entry file path directly from `extensions:[]`. For pre-publish smoke testing of the file layout, see `verifySandboxInstall` below instead.
|
|
166
|
+
|
|
167
|
+
## The playbook DSL in one paragraph
|
|
168
|
+
|
|
169
|
+
A playbook is one or more **turns**, each built with `when(prompt, actions)`. An action is either `calls(tool, params)` (the model invokes a tool) or `says(text)` (the model emits text and the turn ends). Multi-turn conversations are just multiple `when(...)` passed to `run()`. When one tool's output feeds the next call, chain `.then(result => ...)` to capture it and pass a `() => params` function for late binding. Full coverage including `multi-turn`, `.then()`, late-bound params, and playbook-exhaustion diagnostics lives in `references/playbook-dsl.md`.
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
await t.run(
|
|
173
|
+
when("What files are in the project?", [
|
|
174
|
+
calls("bash", { command: "ls" }),
|
|
175
|
+
says("Found 3 files."),
|
|
176
|
+
]),
|
|
177
|
+
when("Now read the README", [
|
|
178
|
+
calls("read", { path: "README.md" }),
|
|
179
|
+
says("Here's what it says..."),
|
|
180
|
+
]),
|
|
181
|
+
);
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Mocking tools
|
|
185
|
+
|
|
186
|
+
`mockTools` intercepts `tool.execute()` for **specific tools only**. Extension-registered tools still execute for real — that's how you test your own tool logic while keeping the built-ins deterministic. Each value can be one of three forms:
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
mockTools: {
|
|
190
|
+
// 1. Static string → becomes `{ content: [{ type: "text", text: "..." }] }`
|
|
191
|
+
bash: "command output here",
|
|
192
|
+
|
|
193
|
+
// 2. Dynamic function → receives params, returns string or full ToolResult
|
|
194
|
+
read: (params) => `contents of ${params.path}`,
|
|
195
|
+
|
|
196
|
+
// 3. Full ToolResult for precise control
|
|
197
|
+
write: {
|
|
198
|
+
content: [{ type: "text", text: "Written successfully" }],
|
|
199
|
+
details: { bytesWritten: 42 },
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Critical correctness note
|
|
205
|
+
|
|
206
|
+
Pi's `tool_call` and `tool_result` hooks **still fire** for mocked tools through `AgentSession`'s `beforeToolCall`/`afterToolCall` pipeline. This means extension-side gating (plan mode, allowlists, block-on-dangerous-command) works correctly even when the tool itself is mocked. If your extension blocks a call, the mock handler never runs and the block is recorded canonically as `blocked: true`/`blockReason` on the `ToolCallRecord` and `isError: true` on the `ToolResultRecord`.
|
|
207
|
+
|
|
208
|
+
### Error propagation
|
|
209
|
+
|
|
210
|
+
By default, real tool errors abort the test (`propagateErrors: true`) with a diagnostic pointing to the exact playbook step. Set `propagateErrors: false` to capture them as `isError: true` results instead — useful when your extension logic is supposed to recover from tool errors.
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
const t = await createTestSession({ propagateErrors: false, /* ... */ });
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Full decision tree for "what do I mock vs leave real", plus `ToolBlockedError` patterns and the two diagnostic modes, is in `references/mock-tools.md`.
|
|
217
|
+
|
|
218
|
+
## Mocking the UI
|
|
219
|
+
|
|
220
|
+
Extensions that call `ctx.ui.confirm()`, `ctx.ui.select()`, `ctx.ui.input()`, `ctx.ui.editor()` get controlled responses. All calls are recorded for assertions afterwards. Each field accepts a static value (same answer every time) or a function (dynamic per prompt).
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
const t = await createTestSession({
|
|
224
|
+
extensions: ["./src/index.ts"],
|
|
225
|
+
mockUI: {
|
|
226
|
+
confirm: false, // deny all confirmations
|
|
227
|
+
select: 0, // always pick first item
|
|
228
|
+
input: "user input text", // return a fixed string
|
|
229
|
+
editor: "edited content", // return a fixed string
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ...run playbook...
|
|
234
|
+
|
|
235
|
+
expect(t.events.uiCallsFor("confirm")).toHaveLength(1);
|
|
236
|
+
expect(t.events.uiCallsFor("confirm")[0].returnValue).toBe(false);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Defaults when no `mockUI` is provided: `confirm → true`, `select → first item`, `input → ""`, `editor → ""`.
|
|
240
|
+
|
|
241
|
+
Dynamic handlers are also supported, e.g. to return `false` only for prompts mentioning "Delete":
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
mockUI: {
|
|
245
|
+
confirm: (title, _message) => !title.includes("Delete"),
|
|
246
|
+
select: (_title, items) => items.find(i => i.includes("staging")),
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Full signatures and decision guidance are in `references/mock-ui.md`.
|
|
251
|
+
|
|
252
|
+
## Reading what happened: the events API
|
|
253
|
+
|
|
254
|
+
Every interaction is collected in `t.events`.
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
// Tool interaction
|
|
258
|
+
t.events.toolCallsFor("bash") // ToolCallRecord[] (has .input — read what was asked)
|
|
259
|
+
t.events.toolResultsFor("bash") // ToolResultRecord[] (has .mocked, .isError, .text)
|
|
260
|
+
t.events.blockedCalls() // tool calls that hooks blocked (e.g. plan mode)
|
|
261
|
+
|
|
262
|
+
// UI interaction
|
|
263
|
+
t.events.uiCallsFor("notify") // UICallRecord[]
|
|
264
|
+
t.events.uiCallsFor("confirm")
|
|
265
|
+
|
|
266
|
+
// Raw history
|
|
267
|
+
t.events.messages // AgentMessage[]
|
|
268
|
+
t.events.all // AgentSessionEvent[] (everything)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
### Reading call params and pairing with results
|
|
272
|
+
|
|
273
|
+
Use `toolCallsFor(name)` when you want to assert on what was *asked* (the input passed to `calls(...)`). `ToolCallRecord` has no `toolCallId`, so pair filtered calls and results by array index:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
const call = t.events.toolCallsFor("summarize_doc")[0];
|
|
277
|
+
const result = t.events.toolResultsFor("summarize_doc")[0];
|
|
278
|
+
|
|
279
|
+
expect(call.input).toEqual({ path: "README.md" });
|
|
280
|
+
expect(result.mocked).toBe(false); // extension's real code ran
|
|
281
|
+
expect(result.isError).toBe(false);
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
For `ToolCallRecord`, `ToolResultRecord`, and `UICallRecord` shapes — and the pairing pattern in full — see `references/api-reference.md`.
|
|
285
|
+
|
|
286
|
+
## Handling blocked tools
|
|
287
|
+
|
|
288
|
+
When an extension hook blocks a mocked tool, the canonical signal is the event record: `blocked: true` + `blockReason` on the `ToolCallRecord`, and `isError: true` + result text on the `ToolResultRecord`. Assert those fields — `ToolBlockedError` is still exported for source compatibility, but normal Pi 0.84 runs through `AgentSession` do not promise to throw it:
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import {
|
|
292
|
+
createTestSession, when, calls, says,
|
|
293
|
+
} from "@abdwhb-png/pi-test-harness";
|
|
294
|
+
|
|
295
|
+
const t = await createTestSession({ mockTools: { bash: "ok" } });
|
|
296
|
+
await t.run(when("Try write", [
|
|
297
|
+
calls("bash", { command: "rm -rf /" }),
|
|
298
|
+
says("Done."),
|
|
299
|
+
]));
|
|
300
|
+
|
|
301
|
+
const call = t.events.toolCallsFor("bash")[0];
|
|
302
|
+
expect(call.blocked).toBe(true);
|
|
303
|
+
expect(call.blockReason).toMatch(/blocked/i);
|
|
304
|
+
const result = t.events.toolResultsFor("bash")[0];
|
|
305
|
+
expect(result.isError).toBe(true);
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## Verifying a publishable package
|
|
309
|
+
|
|
310
|
+
Before publishing an extension package, `verifySandboxInstall` does a real `npm pack` → temp-dir install → dynamic import and checks that extensions/tools/skills load cleanly. This catches broken `exports`, missing dependencies, and bad `main`/`types` fields that only bite *after* install.
|
|
311
|
+
|
|
312
|
+
```typescript
|
|
313
|
+
import { verifySandboxInstall } from "@abdwhb-png/pi-test-harness";
|
|
314
|
+
|
|
315
|
+
const result = await verifySandboxInstall({
|
|
316
|
+
packageDir: "./packages/my-extension",
|
|
317
|
+
expect: {
|
|
318
|
+
extensions: 1,
|
|
319
|
+
tools: ["my_tool", "my_other_tool"],
|
|
320
|
+
skills: 0,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
expect(result.loaded.extensionErrors).toEqual([]);
|
|
325
|
+
expect(result.loaded.tools).toContain("my_tool");
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
You can also run a smoke playbook inside the sandbox install to make sure tools actually execute when the package is loaded from disk — see `references/sandbox-install.md`.
|
|
329
|
+
|
|
330
|
+
## Testing subprocess-spawning extensions
|
|
331
|
+
|
|
332
|
+
If your extension shells out to `pi --mode json -p` (typical for subagent orchestrators, parallel-task runners, etc.), `createMockPi` puts a fake `pi` binary on PATH that returns controllable responses. Keep the extension itself real: load its actual entry point with `createTestSession` (or import its exported helper) and drive that code rather than copying the subprocess helper into the test. The full lifecycle is install → queue responses → run test → uninstall.
|
|
333
|
+
|
|
334
|
+
```typescript
|
|
335
|
+
import { createMockPi } from "@abdwhb-png/pi-test-harness";
|
|
336
|
+
|
|
337
|
+
const mockPi = createMockPi();
|
|
338
|
+
mockPi.install(); // create shim, prepend to PATH
|
|
339
|
+
|
|
340
|
+
mockPi.onCall({ output: "Hello from agent", exitCode: 0 });
|
|
341
|
+
mockPi.onCall({ stderr: "agent crashed", exitCode: 1 });
|
|
342
|
+
mockPi.onCall({
|
|
343
|
+
jsonl: [
|
|
344
|
+
{ type: "tool_execution_start", toolName: "bash" },
|
|
345
|
+
{ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
346
|
+
],
|
|
347
|
+
// Optional: write files during execution (e.g., chain_dir output simulation)
|
|
348
|
+
// writeFiles: { "/tmp/output.md": "# Result\nDone." },
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Reset the response queue between tests (preserves the installed shim, clears queued responses)
|
|
352
|
+
mockPi.reset();
|
|
353
|
+
|
|
354
|
+
expect(mockPi.callCount()).toBe(0);
|
|
355
|
+
|
|
356
|
+
// ...run playbook that triggers those subprocesses...
|
|
357
|
+
// When the queue is exhausted, the LAST response repeats.
|
|
358
|
+
// If no responses are queued at all, the mock echoes the task text.
|
|
359
|
+
|
|
360
|
+
mockPi.uninstall(); // restore PATH, delete temp dir
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
| Field | Type | Default | Description |
|
|
364
|
+
| ------- | ------ | --------- | ------------- |
|
|
365
|
+
| `output` | `string` | echo task | Text in the `message_end` event |
|
|
366
|
+
| `exitCode` | `number` | `0` | Process exit code |
|
|
367
|
+
| `stderr` | `string` | — | Written to stderr |
|
|
368
|
+
| `delay` | `number` | `0` | Delay in ms before responding |
|
|
369
|
+
| `jsonl` | `object[]` | — | Raw JSONL events (replaces default `message_end`) |
|
|
370
|
+
| `writeFiles` | `Record<string, string>` | — | Files written during execution (e.g., to simulate chain_dir output) |
|
|
371
|
+
|
|
372
|
+
Lifecycle: `install()` → `onCall(...)` (queue N responses) → `reset()` (clears queue, preserves shim) → `uninstall()` (restores PATH, deletes temp dir). When the queue is exhausted the last response repeats; if empty, the mock echoes the task text. `callCount()` returns the number of invocations since install.
|
|
373
|
+
|
|
374
|
+
The key-validation behavior (throws on typos like `{ ouptut: ... }`), the concurrency caveat (serial spawns only — queued responses are consumed FIFO across spawned subprocesses), and the safety features (exit handler auto-restores PATH; 30s timeout) are documented in `references/mock-pi-cli.md`.
|
|
375
|
+
|
|
376
|
+
## Windows + SQLite: use `safeRmSync` in `afterEach`
|
|
377
|
+
|
|
378
|
+
This is the single biggest Windows footgun, and it always bites users who haven't seen it before. `session.dispose()` does **not** fire `session_shutdown`; that event fires on Node process exit. Extensions that open SQLite databases in `session_start` (memory extensions, brainiac, etc.) keep those files locked for the entire test runner lifetime.
|
|
379
|
+
|
|
380
|
+
On Windows, `rmSync(dbPath)` in `afterEach` will throw `EPERM` because the file is still locked. Use the bundled `safeRmSync` instead, which swallows only `EPERM`/`EBUSY`:
|
|
381
|
+
|
|
382
|
+
```typescript
|
|
383
|
+
import { safeRmSync } from "@abdwhb-png/pi-test-harness";
|
|
384
|
+
|
|
385
|
+
let dbPath: string;
|
|
386
|
+
|
|
387
|
+
afterEach(() => {
|
|
388
|
+
t?.dispose(); // dispose session first
|
|
389
|
+
safeRmSync(dbPath);
|
|
390
|
+
safeRmSync(dbPath + "-wal");
|
|
391
|
+
safeRmSync(dbPath + "-shm");
|
|
392
|
+
});
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Files get cleaned up by the OS when the test process exits anyway. For isolation across tests, give each one a unique DB path (typically `mkdtempSync` + the test name).
|
|
396
|
+
|
|
397
|
+
`safeRmSync` only suppresses `EPERM` and `EBUSY` — every other error still propagates.
|
|
398
|
+
|
|
399
|
+
## Common pitfalls / anti-patterns
|
|
400
|
+
|
|
401
|
+
**1. Mocking your own extension's tools.**
|
|
402
|
+
If your extension registers a tool named `my_tool`, don't also put `my_tool` in `mockTools`. The mock intercepts `tool.execute()` and your tool's real logic never runs — the test passes vacuously. Mock **built-in** tools (`bash`, `read`, `write`, `edit`); let your extension's own tools execute for real.
|
|
403
|
+
|
|
404
|
+
**2. Forgetting `afterEach(() => t?.dispose())`.**
|
|
405
|
+
Pi extensions that open a SQLite DB (memory extensions, etc.) keep the file handle for the test process's lifetime. Skipping `dispose()` + `safeRmSync()` causes cross-test contamination and `EPERM` on Windows. See the "Windows + SQLite" section.
|
|
406
|
+
|
|
407
|
+
**3. Mocking a tool that an extension hook will block.**
|
|
408
|
+
When your hook blocks a call (plan mode, dangerous command), the mock handler never executes. Assert the block via the event records — `blocked: true`/`blockReason` on the `ToolCallRecord` and `isError: true` on the `ToolResultRecord` — rather than expecting a `ToolBlockedError` throw from normal Pi 0.84 runs.
|
|
409
|
+
|
|
410
|
+
**4. Assuming hooks fire on the mock path.**
|
|
411
|
+
Pi's `tool_call` / `tool_result` hooks **do** fire for mocked tools through `AgentSession`'s hook pipeline, but lifecycle hooks (`session_start`, `session_shutdown`) follow Pi's normal timing. `session_shutdown` only fires on process exit — `dispose()` will not trigger it. Plan accordingly.
|
|
412
|
+
|
|
413
|
+
**5. Reimplementing extension logic inside the test.**
|
|
414
|
+
`createMockPi` replaces only the `pi` executable. Load or import the real extension code that spawns it; never paste a local copy of the subprocess helper into the test, because that can pass while the extension is broken.
|
|
415
|
+
|
|
416
|
+
**6. Trusting the snapshot blindly.**
|
|
417
|
+
The `description` and code in this skill are pinned to fork v0.7.0 (Pi 0.85.x). On any minor bump, re-fetch the fork README and reconcile before trusting snippets.
|
|
418
|
+
|
|
419
|
+
## Test-layer summary
|
|
420
|
+
|
|
421
|
+
| Function | Replaces | Use case |
|
|
422
|
+
|-----------------------|---------------------------|--------------------------------------------|
|
|
423
|
+
| `createTestSession` | LLM (`streamFn`) | Testing extension logic in-process |
|
|
424
|
+
| `verifySandboxInstall`| Nothing (real install) | Verifying the npm package works |
|
|
425
|
+
| `createMockPi` | `pi` CLI binary | Testing subprocess-spawning extensions |
|
|
426
|
+
|
|
427
|
+
## Install
|
|
428
|
+
|
|
429
|
+
```bash
|
|
430
|
+
npm install --save-dev @abdwhb-png/pi-test-harness
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
In `~/.pi/agent`, the fork is consumed as a local dev dependency:
|
|
434
|
+
`bun add --dev file:../../projects/pi-integrations/pi-test-harness/dist/package.tgz`
|
|
435
|
+
|
|
436
|
+
Peer dependencies (Pi line `0.85.x` only):
|
|
437
|
+
|
|
438
|
+
- `@earendil-works/pi-coding-agent` ^0.85.0
|
|
439
|
+
- `@earendil-works/pi-ai` ^0.85.0
|
|
440
|
+
- `@earendil-works/pi-agent-core` ^0.85.0
|
|
441
|
+
|
|
442
|
+
## Reference index
|
|
443
|
+
|
|
444
|
+
For deeper coverage without bloating this entrypoint, read:
|
|
445
|
+
|
|
446
|
+
- `references/playbook-dsl.md` — full playbook DSL: multi-turn, `.then()`, late-bound params, both exhaustion diagnostic modes and their fixes.
|
|
447
|
+
- `references/mock-tools.md` — MockToolHandler union, hook interaction under mocking, error propagation modes, `ToolBlockedError` patterns, what-to-mock decision tree.
|
|
448
|
+
- `references/mock-ui.md` — `MockUIConfig` full reference, static-vs-dynamic form signatures, when to override defaults.
|
|
449
|
+
- `references/sandbox-install.md` — `verifySandboxInstall` options, the smoke sub-config, pre-publish CI integration patterns.
|
|
450
|
+
- `references/mock-pi-cli.md` — `createMockPi` full lifecycle, every `MockPiCall` response field, safety features, concurrency caveat.
|
|
451
|
+
- `references/api-reference.md` — type definitions (`ToolResultRecord`, `MockUIConfig`, `MockToolHandler`, `MockPiCall`, `TestSession`, `TestEvents`) and the complete event-collection API.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skill_name": "pi-test-harness",
|
|
3
|
+
"evals": [
|
|
4
|
+
{
|
|
5
|
+
"id": 1,
|
|
6
|
+
"name": "in-process-common-case",
|
|
7
|
+
"prompt": "I'm writing a Pi extension that registers a tool called `summarize_doc`. When the user asks 'Summarize README.md', I want to verify my tool is invoked with { path: 'README.md' } and the assistant returns a citation-rich summary string — without ever calling an LLM or hitting the filesystem. Show me the test for that, using @abdwhb-png/pi-test-harness.",
|
|
8
|
+
"expected_output": "A working test spec (runner-agnostic — Vitest, bun:test, or Jest all fine) that uses createTestSession with the extension path, includes the tool the extension registers outside mockTools so its real execute runs, mocks built-ins only, drives the conversation with when/calls/says, and asserts the requested path through toolCallsFor('summarize_doc')[0].input. It may pair the filtered call/result arrays by index. It must not use ToolCallRecord.params or assume ToolCallRecord has toolCallId.",
|
|
9
|
+
"files": []
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": 2,
|
|
13
|
+
"name": "hook-blocking-vs-error",
|
|
14
|
+
"prompt": "My Pi 0.84 extension registers a `tool_call` hook that blocks `bash` whenever the command contains `rm -rf`. Show me a deterministic test that proves the hook blocked the call before the mocked bash handler ran and distinguishes that block from an ordinary tool error using @abdwhb-png/pi-test-harness.",
|
|
15
|
+
"expected_output": "A test that loads the real extension, supplies a bash mock handler with an observable call counter, drives the blocked command through when/calls/says, and asserts the canonical event records: ToolCallRecord.blocked is true, blockReason explains the block, the matching ToolResultRecord.isError is true, and its text contains the reason. It also proves the mock handler count stayed zero. It must not expect createTestSession.run() to throw ToolBlockedError; that class remains exported only for source compatibility in normal Pi 0.84 AgentSession runs.",
|
|
16
|
+
"files": []
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": 3,
|
|
20
|
+
"name": "subprocess-extension-mock_pi",
|
|
21
|
+
"prompt": "I have a Pi extension that delegates high-risk work to a child Pi agent via `pi --mode json -p`. I want a test that simulates two consecutive subprocess calls where the first returns 'Task done' (exit 0) and the second crashes with stderr='agent crashed' and exit code 1, then verifies my extension surfaces a friendly error to the user rather than crashing. The fake pi shim must be installed and uninstalled cleanly.",
|
|
22
|
+
"expected_output": "A test that uses createMockPi(), calls install(), queues { output: 'Task done', exitCode: 0 } then { stderr: 'agent crashed', exitCode: 1 }, drives the extension via createTestSession plus a playbook, asserts on the assistant's final text containing a friendly message rather than raw stderr, and disposes the test session plus calls mockPi.uninstall() in cleanup. It must load or import the real extension code, must not recreate its subprocess helper inside the test, and must not use createTestSession alone; createMockPi is required for subprocess simulation.",
|
|
23
|
+
"files": []
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|