@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/LICENSE +21 -0
  3. package/README.md +673 -0
  4. package/dist/diagnostics.d.ts +11 -0
  5. package/dist/diagnostics.d.ts.map +1 -0
  6. package/dist/diagnostics.js +61 -0
  7. package/dist/diagnostics.js.map +1 -0
  8. package/dist/events.d.ts +6 -0
  9. package/dist/events.d.ts.map +1 -0
  10. package/dist/events.js +33 -0
  11. package/dist/events.js.map +1 -0
  12. package/dist/index.d.ts +14 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +19 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/mock-pi-script.mjs +176 -0
  17. package/dist/mock-pi.d.ts +32 -0
  18. package/dist/mock-pi.d.ts.map +1 -0
  19. package/dist/mock-pi.js +150 -0
  20. package/dist/mock-pi.js.map +1 -0
  21. package/dist/mock-tools.d.ts +51 -0
  22. package/dist/mock-tools.d.ts.map +1 -0
  23. package/dist/mock-tools.js +192 -0
  24. package/dist/mock-tools.js.map +1 -0
  25. package/dist/mock-ui.d.ts +13 -0
  26. package/dist/mock-ui.d.ts.map +1 -0
  27. package/dist/mock-ui.js +159 -0
  28. package/dist/mock-ui.js.map +1 -0
  29. package/dist/pi-loader-parity.d.ts +36 -0
  30. package/dist/pi-loader-parity.d.ts.map +1 -0
  31. package/dist/pi-loader-parity.js +60 -0
  32. package/dist/pi-loader-parity.js.map +1 -0
  33. package/dist/playbook.d.ts +44 -0
  34. package/dist/playbook.d.ts.map +1 -0
  35. package/dist/playbook.js +143 -0
  36. package/dist/playbook.js.map +1 -0
  37. package/dist/sandbox.d.ts +27 -0
  38. package/dist/sandbox.d.ts.map +1 -0
  39. package/dist/sandbox.js +269 -0
  40. package/dist/sandbox.js.map +1 -0
  41. package/dist/session.d.ts +13 -0
  42. package/dist/session.d.ts.map +1 -0
  43. package/dist/session.js +187 -0
  44. package/dist/session.js.map +1 -0
  45. package/dist/types.d.ts +171 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +5 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/utils.d.ts +32 -0
  50. package/dist/utils.d.ts.map +1 -0
  51. package/dist/utils.js +46 -0
  52. package/dist/utils.js.map +1 -0
  53. package/package.json +84 -0
  54. package/skills/pi-test-harness/SKILL.md +451 -0
  55. package/skills/pi-test-harness/evals/evals.json +26 -0
  56. package/skills/pi-test-harness/references/api-reference.md +480 -0
  57. package/skills/pi-test-harness/references/mock-pi-cli.md +135 -0
  58. package/skills/pi-test-harness/references/mock-tools.md +176 -0
  59. package/skills/pi-test-harness/references/mock-ui.md +170 -0
  60. package/skills/pi-test-harness/references/playbook-dsl.md +209 -0
  61. package/skills/pi-test-harness/references/sandbox-install.md +113 -0
  62. package/src/diagnostics.ts +90 -0
  63. package/src/events.ts +43 -0
  64. package/src/index.ts +42 -0
  65. package/src/mock-pi-script.mjs +176 -0
  66. package/src/mock-pi.ts +169 -0
  67. package/src/mock-tools.ts +252 -0
  68. package/src/mock-ui.ts +196 -0
  69. package/src/pi-loader-parity.ts +61 -0
  70. package/src/playbook.ts +189 -0
  71. package/src/sandbox.ts +334 -0
  72. package/src/session.ts +249 -0
  73. package/src/types.ts +203 -0
  74. package/src/utils.ts +46 -0
package/README.md ADDED
@@ -0,0 +1,673 @@
1
+ # @abdwhb-png/pi-test-harness
2
+
3
+ Test harness for [pi](https://github.com/earendil-works/pi-coding-agent) extensions — in-process session testing with playbook-driven model mocking, package install verification, and subprocess mocking.
4
+
5
+ **Pi 0.85.x only.** This fork targets Pi 0.85 and requires `@earendil-works/pi-agent-core@^0.85.0`, `@earendil-works/pi-ai@^0.85.0`, and `@earendil-works/pi-coding-agent@^0.85.0`.
6
+
7
+ ## Why
8
+
9
+ Testing pi extensions is hard. Extensions register tools, subscribe to hooks, intercept tool calls, use UI — all deeply integrated with pi's runtime. Mocking everything produces tests that don't reflect reality. Not testing produces extensions that break in production.
10
+
11
+ pi-test-harness takes a different approach: **let pi be pi.** Everything runs for real — extension loading, tool registration, hooks, event lifecycle, session state. Only the model is replaced (via `streamFunction`), and optionally tool execution is intercepted for tools you don't want to run for real.
12
+
13
+ The result: tests that exercise real code paths, in ~10 lines of setup, with zero LLM calls.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install --save-dev @abdwhb-png/pi-test-harness
19
+ ```
20
+
21
+ ### Bundled skill
22
+
23
+ The repository and published package include the canonical `pi-test-harness` skill at `skills/pi-test-harness/`. Install that directory with your skill manager, or load the package as a Pi package, to make the skill available to agents. Installing the harness only as an npm/Bun development dependency does not activate the skill automatically.
24
+
25
+ ### Peer dependencies
26
+
27
+ - `@earendil-works/pi-coding-agent` ^0.85.0
28
+ - `@earendil-works/pi-ai` ^0.85.0
29
+ - `@earendil-works/pi-agent-core` ^0.85.0
30
+
31
+ ### Supported Pi line
32
+
33
+ The harness version and the Pi version are independent tracks — do not compare them numerically. Which Pi line a harness release supports is declared in `peerDependencies` above; the table records it per release.
34
+
35
+ | Harness | Supported Pi | Notes |
36
+ | ------- | ------------ | -------------------------------------------------------- |
37
+ | 0.8.x | 0.85.x | Loader parity for top-level-await entrypoints |
38
+ | 0.7.x | 0.84.x | Pi 0.84 support |
39
+
40
+ ### Ways to install it
41
+
42
+ 1. **From npm (normal case):** `npm install --save-dev @abdwhb-png/pi-test-harness@0.8.0`. This is the packed artifact a stranger receives.
43
+ 2. **Pre-release:** changes that need testing before a final version are published under the `next` dist-tag, so `npm install --save-dev @abdwhb-png/pi-test-harness@next` picks one up without inventing a `0.x` release.
44
+ 3. **Local checkout (harness development):** `bun link` in the harness repo, then `bun link @abdwhb-png/pi-test-harness` in the consumer, or point the dependency at a `file:` path. Consumers read `dist/`, so run `npm run build` after editing `src/`, and never commit a lockfile that resolves to the link.
45
+
46
+ ## Quick Start
47
+
48
+ ```typescript
49
+ import { describe, it, expect, afterEach } from "vitest";
50
+ import {
51
+ createTestSession,
52
+ when, calls, says,
53
+ type TestSession,
54
+ } from "@abdwhb-png/pi-test-harness";
55
+
56
+ describe("my extension", () => {
57
+ let t: TestSession;
58
+ afterEach(() => t?.dispose());
59
+
60
+ it("calls a tool and responds", async () => {
61
+ t = await createTestSession({
62
+ extensions: ["./src/index.ts"],
63
+ mockTools: {
64
+ bash: (params) => `$ ${params.command}\nfile1.txt\nfile2.txt`,
65
+ read: "file contents here",
66
+ write: "written",
67
+ edit: "edited",
68
+ },
69
+ });
70
+
71
+ await t.run(
72
+ when("List files in the project", [
73
+ calls("bash", { command: "ls" }),
74
+ says("Found 2 files: file1.txt and file2.txt"),
75
+ ]),
76
+ );
77
+
78
+ expect(t.events.toolResultsFor("bash")).toHaveLength(1);
79
+ expect(t.events.toolResultsFor("bash")[0].text).toContain("file1.txt");
80
+ expect(t.events.toolResultsFor("bash")[0].mocked).toBe(true);
81
+ });
82
+ });
83
+ ```
84
+
85
+ ## Architecture
86
+
87
+ ```
88
+ ┌───────────────────────────────────────────┐
89
+ │ Real pi environment │
90
+ │ │
91
+ │ ModelRuntime (isolated, no ~/.pi touch) │
92
+ │ Extensions ─── loaded for real │
93
+ │ Tool registry ─ real hooks + wrapping │
94
+ │ Session state ─ in-memory persistence │
95
+ │ │
96
+ │ ┌─────────────────────────────────────┐ │
97
+ │ │ Agent Loop │ │
98
+ │ │ │ │
99
+ │ │ streamFunction ── REPLACED by play │ │
100
+ │ │ tool.execute() INTERCEPTED if mock│ │
101
+ │ │ ctx.ui.* INTERCEPTED + log │ │
102
+ │ │ tool_call/result AGENTSESSION hook │ │
103
+ │ └─────────────────────────────────────┘ │
104
+ └───────────────────────────────────────────┘
105
+ ```
106
+
107
+ Three substitution points at the boundary — everything else runs through pi's real code:
108
+
109
+ | What | Substituted with | Purpose |
110
+ | ------ | ----------------- | --------- |
111
+ | `streamFunction` | Playbook | Scripts what the model "decides" |
112
+ | `tool.execute()` | Mock handler | Controls what tools "return" |
113
+ | `ctx.ui.*` | Mock UI | Controls what the user "answers" |
114
+
115
+ **Hook pipeline.** Pi 0.84 AgentSession installs `beforeToolCall`/`afterToolCall` on the Agent, which drive extension `tool_call`/`tool_result` events. The harness mock does **not** re-emit these hooks — each fires exactly once per tool call. Tool result modification via `tool_result` hook return values works because the session subscriber reads the finalized result from the `tool_execution_end` event.
116
+
117
+ **ModelRuntime isolation.** The session creates an isolated `ModelRuntime` with `authPath` under the working directory and `modelsPath: null`. No credentials file is read from `~/.pi/agent`. A dummy API key is injected to satisfy AgentSession auth checks (the model is never called).
118
+
119
+ ## Playbook DSL
120
+
121
+ The playbook replaces the LLM. Instead of calling a model, the agent loop consumes scripted actions in order.
122
+
123
+ ### `when(prompt, actions)`
124
+
125
+ Defines a conversation turn — the prompt you'll send and what the model does in response:
126
+
127
+ ```typescript
128
+ when("Deploy the app", [
129
+ calls("bash", { command: "npm run build" }),
130
+ calls("bash", { command: "gcloud run deploy" }),
131
+ says("Deployed successfully."),
132
+ ])
133
+ ```
134
+
135
+ ### `calls(tool, params)`
136
+
137
+ The model calls a tool. Pi's hooks fire, the tool executes (real or mocked), result feeds back:
138
+
139
+ ```typescript
140
+ calls("plan_mode", { enable: true })
141
+ calls("bash", { command: "ls -la" })
142
+ ```
143
+
144
+ ### `says(text)`
145
+
146
+ The model emits text. The agent turn ends:
147
+
148
+ ```typescript
149
+ says("All done. The deployment is complete.")
150
+ ```
151
+
152
+ ### Multi-turn conversations
153
+
154
+ Pass multiple turns to `run()`:
155
+
156
+ ```typescript
157
+ await t.run(
158
+ when("What files are in the project?", [
159
+ calls("bash", { command: "ls" }),
160
+ says("Found 3 files."),
161
+ ]),
162
+ when("Now read the README", [
163
+ calls("read", { path: "README.md" }),
164
+ says("Here's what it says..."),
165
+ ]),
166
+ );
167
+ ```
168
+
169
+ ## Mock Tools
170
+
171
+ `mockTools` intercepts `tool.execute()` for specific tools. Pi's tool registry and event flow remain untouched. Extension hooks (`tool_call`, `tool_result`) fire through AgentSession's `beforeToolCall`/`afterToolCall` — the harness does not re-emit them.
172
+
173
+ ```typescript
174
+ const t = await createTestSession({
175
+ extensions: ["./src/index.ts"],
176
+ mockTools: {
177
+ // Static string → becomes { content: [{ type: "text", text: "..." }] }
178
+ bash: "command output here",
179
+
180
+ // Dynamic function → receives params, returns string or ToolResult
181
+ read: (params) => `contents of ${params.path}`,
182
+
183
+ // Full ToolResult for precise control
184
+ write: {
185
+ content: [{ type: "text", text: "Written successfully" }],
186
+ details: { bytesWritten: 42 },
187
+ },
188
+ },
189
+ });
190
+ ```
191
+
192
+ **Extension-registered tools execute for real** unless they appear in `mockTools`. This lets you test your extension's actual tool logic while controlling the built-in tools.
193
+
194
+ ## Late-bound Params & `.then()`
195
+
196
+ When one tool call produces a value needed by the next, use `.then()` to capture it and `() => params` for late binding:
197
+
198
+ ```typescript
199
+ let planId = "";
200
+
201
+ await t.run(
202
+ when("Create and approve a plan", [
203
+ calls("plan_propose", {
204
+ title: "Send invoice",
205
+ steps: [{ description: "Send email", tool: "go-easy", operation: "send" }],
206
+ }).then((result) => {
207
+ // Extract the plan ID from the tool result
208
+ planId = result.text.match(/PLAN-[a-f0-9]+/)![0];
209
+ }),
210
+ // Late-bound: params resolved at call time, after .then() has fired
211
+ calls("plan_approve", () => ({ id: planId })),
212
+ says("Plan approved and executing."),
213
+ ]),
214
+ );
215
+
216
+ expect(planId).toMatch(/^PLAN-/);
217
+ ```
218
+
219
+ ## Mock UI
220
+
221
+ Extensions that call `ctx.ui.confirm()`, `ctx.ui.select()`, etc. get mock responses. All calls are recorded for assertions.
222
+
223
+ ```typescript
224
+ const t = await createTestSession({
225
+ extensions: ["./src/index.ts"],
226
+ mockUI: {
227
+ confirm: false, // deny all confirmations
228
+ select: 0, // always pick first item
229
+ input: "user input text", // return fixed string
230
+ editor: "edited content", // return fixed string
231
+ },
232
+ });
233
+
234
+ // ... run playbook ...
235
+
236
+ // Assert the extension asked for confirmation
237
+ expect(t.events.uiCallsFor("confirm")).toHaveLength(1);
238
+ expect(t.events.uiCallsFor("confirm")[0].returnValue).toBe(false);
239
+ ```
240
+
241
+ Dynamic handlers are also supported:
242
+
243
+ ```typescript
244
+ mockUI: {
245
+ confirm: (title, message) => title.includes("Delete") ? false : true,
246
+ select: (title, items) => items.find(i => i.includes("staging")),
247
+ }
248
+ ```
249
+
250
+ **Defaults** (when no mock config is provided): `confirm → true`, `select → first item`, `input → ""`, `editor → ""`.
251
+
252
+ The mock UI context implements the full Pi 0.84 `ExtensionUIContext` interface, including `setWorkingVisible`, `setWorkingIndicator`, `setHiddenThinkingLabel`, `addAutocompleteProvider`, and `getEditorComponent` (returns `undefined`). All calls are logged in `t.events.ui`.
253
+
254
+ ## Event Collection
255
+
256
+ Every session event, tool call, tool result, message, and UI interaction is collected:
257
+
258
+ ```typescript
259
+ // Tool events
260
+ t.events.toolCallsFor("bash") // ToolCallRecord[] for "bash"
261
+ t.events.toolResultsFor("bash") // ToolResultRecord[] for "bash"
262
+ t.events.blockedCalls() // tools blocked by hooks (e.g., plan mode)
263
+
264
+ // UI events
265
+ t.events.uiCallsFor("notify") // UICallRecord[] for notify()
266
+ t.events.uiCallsFor("confirm") // UICallRecord[] for confirm()
267
+
268
+ // Messages and raw events
269
+ t.events.messages // AgentMessage[]
270
+ t.events.all // AgentSessionEvent[] (everything)
271
+ ```
272
+
273
+ ### ToolResultRecord
274
+
275
+ ```typescript
276
+ interface ToolResultRecord {
277
+ step: number; // playbook step index
278
+ toolName: string;
279
+ toolCallId: string;
280
+ text: string; // concatenated text content
281
+ content: Array<{ type: string; text?: string }>;
282
+ isError: boolean;
283
+ mocked: boolean; // true if mockTools handled it
284
+ }
285
+ ```
286
+
287
+ ## Error Propagation
288
+
289
+ By default (`propagateErrors: true`), real tool errors abort the test with a diagnostic pointing to the exact playbook step:
290
+
291
+ ```
292
+ Error during tool execution at playbook step 3 (call "bash"):
293
+ ENOENT: no such file or directory '/foo/bar'
294
+ at Object.readFileSync (node:fs:...)
295
+
296
+ This error was thrown by the real tool execution, not by the playbook.
297
+ To capture errors as tool results instead of aborting, set:
298
+ createTestSession({ propagateErrors: false })
299
+ ```
300
+
301
+ Set `propagateErrors: false` to capture errors as `isError: true` in the result instead:
302
+
303
+ ```typescript
304
+ const t = await createTestSession({
305
+ propagateErrors: false,
306
+ // ...
307
+ });
308
+ ```
309
+
310
+ ## Playbook Diagnostics
311
+
312
+ The harness auto-asserts that all playbook actions are consumed after `run()` completes. If the playbook is exhausted early or has remaining unconsumed actions, you get a clear diagnostic:
313
+
314
+ ```
315
+ Playbook exhausted unexpectedly.
316
+ Consumed 2 action(s).
317
+ Last consumed: calls("bash", {"command":"ls"}) at step 2
318
+
319
+ The agent loop called streamFn but no more playbook actions were available.
320
+ This usually means a tool call produced an unexpected result that caused
321
+ additional streamFn calls (retries, error handling).
322
+ ```
323
+
324
+ ```
325
+ Playbook not fully consumed after run() completed.
326
+ Consumed 1 of 3 action(s).
327
+ Remaining:
328
+ - calls("write", {"path":"out.txt","content":"hello"})
329
+ - says("Done writing.")
330
+
331
+ The agent loop ended before all playbook actions were used.
332
+ This usually means a tool was blocked by a hook or returned early,
333
+ causing fewer streamFn calls than expected.
334
+ ```
335
+
336
+ ## Sandbox Install Verification
337
+
338
+ Catches broken packages before publish — verifies that `npm pack` → install → load actually works:
339
+
340
+ ```typescript
341
+ import { verifySandboxInstall } from "@abdwhb-png/pi-test-harness";
342
+
343
+ const result = await verifySandboxInstall({
344
+ packageDir: "./packages/my-extension",
345
+ expect: {
346
+ extensions: 1,
347
+ tools: ["my_tool", "my_other_tool"],
348
+ skills: 0,
349
+ },
350
+ });
351
+
352
+ expect(result.loaded.extensionErrors).toEqual([]);
353
+ expect(result.loaded.tools).toContain("my_tool");
354
+ ```
355
+
356
+ Optionally run a smoke test inside the sandbox:
357
+
358
+ ```typescript
359
+ const result = await verifySandboxInstall({
360
+ packageDir: "./packages/my-extension",
361
+ expect: { extensions: 1 },
362
+ smoke: {
363
+ mockTools: { bash: "ok", read: "contents", write: "written", edit: "edited" },
364
+ script: [
365
+ when("Test", [
366
+ calls("my_tool", { value: "test" }),
367
+ says("Works."),
368
+ ]),
369
+ ],
370
+ },
371
+ });
372
+ ```
373
+
374
+ ### Custom npm command (`npmCommand`)
375
+
376
+ Use a non-default npm executable for pack/install:
377
+
378
+ ```typescript
379
+ const result = await verifySandboxInstall({
380
+ packageDir: "./packages/my-extension",
381
+ npmCommand: ["sfw", "npm"], // route through Socket Firewall
382
+ expect: { extensions: 1 },
383
+ });
384
+ ```
385
+
386
+ Default: `["npm"]` (or `["npm.cmd"]` on Windows). Pass any `execFileSync`-compatible argv.
387
+
388
+ ## Mock Pi CLI
389
+
390
+ For extensions that spawn `pi --mode json -p` as a subprocess (e.g., subagent orchestrators), `createMockPi()` puts a fake `pi` binary in PATH that returns controllable responses.
391
+
392
+ ```typescript
393
+ import { createMockPi } from "@abdwhb-png/pi-test-harness";
394
+
395
+ const mockPi = createMockPi();
396
+ mockPi.install(); // creates temp dir with pi shim, prepends PATH
397
+
398
+ // Queue responses (consumed in order, last one repeats)
399
+ mockPi.onCall({ output: "Hello from agent", exitCode: 0 });
400
+ mockPi.onCall({ stderr: "agent crashed", exitCode: 1 });
401
+ mockPi.onCall({
402
+ jsonl: [
403
+ { type: "tool_execution_start", toolName: "bash" },
404
+ { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
405
+ ],
406
+ });
407
+
408
+ // Write files during execution (e.g., chain_dir output simulation)
409
+ mockPi.onCall({
410
+ output: "Result written",
411
+ writeFiles: { "/tmp/output.md": "# Result\nDone." },
412
+ });
413
+
414
+ // Reset queue between tests
415
+ mockPi.reset();
416
+
417
+ // Check invocation count
418
+ expect(mockPi.callCount()).toBe(0);
419
+
420
+ // Cleanup
421
+ mockPi.uninstall(); // restores PATH, deletes temp dir
422
+ ```
423
+
424
+ ### How it works
425
+
426
+ 1. `install()` creates a temp directory with a platform-specific shim (`pi.cmd` on Windows, `pi` shell script on Linux)
427
+ 2. The shim is prepended to PATH so `child_process.spawn("pi", ...)` resolves to it
428
+ 3. Each invocation reads the next response from a file-based queue (`queue.json` + `counter`)
429
+ 4. When the queue is exhausted, the last response repeats
430
+ 5. If no responses are queued, the mock echoes the task text
431
+
432
+ ### Response options
433
+
434
+ | Field | Type | Default | Description |
435
+ | ------- | ------ | --------- | ------------- |
436
+ | `output` | `string` | echo task | Text in the `message_end` event |
437
+ | `exitCode` | `number` | `0` | Process exit code |
438
+ | `stderr` | `string` | — | Written to stderr |
439
+ | `delay` | `number` | `0` | Delay in ms before responding |
440
+ | `jsonl` | `object[]` | — | Raw JSONL events (replaces default `message_end`) |
441
+ | `writeFiles` | `Record<string, string>` | — | Files to create (path → content) |
442
+
443
+ ### Safety features
444
+
445
+ - **Exit handler**: PATH is restored on process exit even if `uninstall()` isn't called (test crash safety)
446
+ - **Key validation**: Typos like `{ ouptut: "..." }` throw immediately instead of silently passing
447
+ - **Timeout**: Mock script exits after 30s to prevent hanging tests
448
+
449
+ ### Concurrency
450
+
451
+ Designed for **serial subprocess spawns** within a single test. If your test spawns multiple pi processes concurrently, responses may be consumed out of order.
452
+
453
+ ### Test layer summary
454
+
455
+ | Layer | What it mocks | Use when |
456
+ | ------- | -------------- | ---------- |
457
+ | `createTestSession` | LLM (`streamFunction`) | Testing extension logic in-process |
458
+ | `verifySandboxInstall` | Nothing (real install) | Verifying npm package works |
459
+ | `createMockPi` | pi CLI binary | Testing subprocess-spawning extensions |
460
+
461
+ ## API Reference
462
+
463
+ ### `createTestSession(options?)`
464
+
465
+ Creates a test session with a real pi environment.
466
+
467
+ | Option | Type | Default | Description |
468
+ | -------- | ------ | --------- | ------------- |
469
+ | `extensions` | `string[]` | `[]` | Extension file paths to load |
470
+ | `extensionFactories` | `Function[]` | `[]` | Inline extension factory functions |
471
+ | `cwd` | `string` | auto temp dir | Working directory (cleaned on dispose if auto) |
472
+ | `systemPrompt` | `string` | — | Override the system prompt |
473
+ | `mockTools` | `Record<string, MockToolHandler>` | — | Tool execution interceptors |
474
+ | `mockUI` | `MockUIConfig` | defaults | UI mock configuration |
475
+ | `propagateErrors` | `boolean` | `true` | Abort test on real tool throw |
476
+
477
+ Returns `Promise<TestSession>`.
478
+
479
+ ### `TestSession`
480
+
481
+ | Property / Method | Type | Description |
482
+ | ------------------- | ------ | ------------- |
483
+ | `run(...turns)` | `Promise<void>` | Run the conversation script |
484
+ | `session` | `AgentSession` | The real pi session underneath (typed) |
485
+ | `cwd` | `string` | Working directory |
486
+ | `events` | `TestEvents` | All collected events |
487
+ | `playbook` | `{ consumed, remaining }` | Playbook consumption state |
488
+ | `dispose()` | `void` | Cleanup temp dir and session |
489
+
490
+ ### `verifySandboxInstall(options)`
491
+
492
+ | Option | Type | Description |
493
+ | -------- | ------ | ------------- |
494
+ | `packageDir` | `string` | Package directory (must have `package.json`) |
495
+ | `npmCommand` | `string[]` | Custom npm command argv (default: platform npm) |
496
+ | `expect.extensions` | `number` | Expected extension count |
497
+ | `expect.tools` | `string[]` | Expected tool names |
498
+ | `expect.skills` | `number` | Expected skill count |
499
+ | `smoke.mockTools` | `Record<string, MockToolHandler>` | Mock tools for smoke test |
500
+ | `smoke.script` | `Turn[]` | Playbook script for smoke test |
501
+
502
+ ### `createMockPi()`
503
+
504
+ Creates a mock pi CLI with file-based response queue.
505
+
506
+ Returns `MockPi`:
507
+
508
+ | Property / Method | Type | Description |
509
+ | ------------------- | ------ | ------------- |
510
+ | `install()` | `void` | Create shim, prepend to PATH |
511
+ | `uninstall()` | `void` | Restore PATH, delete temp dir |
512
+ | `onCall(response)` | `void` | Queue a `MockPiCall` response |
513
+ | `reset()` | `void` | Clear queue and counter |
514
+ | `callCount()` | `number` | Number of times mock pi was invoked |
515
+ | `dir` | `string` | Temp directory path |
516
+
517
+ ### `MockToolHandler`
518
+
519
+ ```typescript
520
+ type MockToolHandler =
521
+ | string // static text
522
+ | ToolResult // full result object
523
+ | ((params: Record<string, unknown>) => string | ToolResult); // dynamic
524
+ ```
525
+
526
+ ### `MockUIConfig`
527
+
528
+ ```typescript
529
+ interface MockUIConfig {
530
+ confirm?: boolean | ((title: string, message: string) => boolean);
531
+ select?: number | string | ((title: string, items: string[]) => string | undefined);
532
+ input?: string | ((title: string, placeholder?: string) => string | undefined);
533
+ editor?: string | ((title: string, prefilled?: string) => string | undefined);
534
+ }
535
+ ```
536
+
537
+ ### `ToolBlockedError`
538
+
539
+ Kept for backward compatibility. In Pi 0.84, tool blocking is handled by AgentSession's `beforeToolCall` before `execute()` is reached, so `ToolBlockedError` is no longer thrown by the mock flow. It remains exported for instanceof checks on errors from event callbacks:
540
+
541
+ ```typescript
542
+ import { ToolBlockedError } from "@abdwhb-png/pi-test-harness";
543
+
544
+ const err = new ToolBlockedError("tool was blocked");
545
+ expect(err instanceof ToolBlockedError).toBe(true);
546
+ ```
547
+
548
+ ### `safeRmSync(filePath)`
549
+
550
+ Removes a file, swallowing `EPERM`/`EBUSY` errors only. Intended for `afterEach` cleanup of extension-owned SQLite files on Windows. See [Platform Notes](#platform-notes).
551
+
552
+ ## Real-World Example: Testing pi-planner
553
+
554
+ Testing an extension that registers 8 tools, blocks writes in plan mode, and manages plan lifecycle:
555
+
556
+ ```typescript
557
+ import { createTestSession, when, calls, says, type TestSession } from "@abdwhb-png/pi-test-harness";
558
+ import * as path from "node:path";
559
+
560
+ const EXTENSION = path.resolve(__dirname, "../../src/index.ts");
561
+ const MOCKS = {
562
+ bash: (p: Record<string, unknown>) => `mock: ${p.command}`,
563
+ read: "mock contents", write: "mock written", edit: "mock edited",
564
+ };
565
+
566
+ describe("pi-planner", () => {
567
+ let t: TestSession;
568
+ afterEach(() => t?.dispose());
569
+
570
+ it("enters plan mode and proposes a plan", async () => {
571
+ t = await createTestSession({
572
+ extensions: [EXTENSION],
573
+ mockTools: MOCKS,
574
+ });
575
+
576
+ let planId = "";
577
+
578
+ await t.run(
579
+ when("Plan the deployment", [
580
+ calls("plan_mode", { enable: true }),
581
+ calls("plan_propose", {
582
+ title: "Deploy v2",
583
+ steps: [
584
+ { description: "Build", tool: "bash", operation: "build" },
585
+ { description: "Deploy", tool: "gcloud", operation: "deploy" },
586
+ ],
587
+ }).then((r) => {
588
+ planId = r.text.match(/PLAN-[a-f0-9]+/)![0];
589
+ }),
590
+ says("Plan proposed."),
591
+ ]),
592
+ );
593
+
594
+ expect(planId).toMatch(/^PLAN-/);
595
+ expect(t.events.toolResultsFor("plan_mode")[0].text).toContain("enabled");
596
+ expect(t.events.uiCallsFor("notify")).toHaveLength(1);
597
+ });
598
+ });
599
+ ```
600
+
601
+ ## Platform Notes
602
+
603
+ ### Windows + SQLite (EPERM in afterEach)
604
+
605
+ `session.dispose()` does **not** fire `session_shutdown`. That event fires at Node.js process exit. Extensions that open SQLite databases in `session_start` (e.g., brainiac, memory extensions) keep those files locked for the entire test runner lifetime.
606
+
607
+ On Windows, this means `rmSync(dbPath)` in `afterEach` throws `EPERM`. Use `safeRmSync` instead:
608
+
609
+ ```typescript
610
+ import { safeRmSync } from "@abdwhb-png/pi-test-harness";
611
+
612
+ afterEach(() => {
613
+ // Dispose session first, then attempt file cleanup
614
+ t?.dispose();
615
+ safeRmSync(dbPath);
616
+ safeRmSync(dbPath + "-wal");
617
+ safeRmSync(dbPath + "-shm");
618
+ });
619
+ ```
620
+
621
+ Files are cleaned by the OS when the process exits. Use unique DB paths per test (e.g., `mkdtempSync` + test name) for isolation.
622
+
623
+ `safeRmSync` only swallows `EPERM` and `EBUSY` — all other errors still propagate.
624
+
625
+ ---
626
+
627
+ ## Design Philosophy
628
+
629
+ > **Let pi be pi.** The less we fake, the more real the test.
630
+
631
+ The harness minimizes substitution. Extensions load through pi's real loader (jiti). Tools go through pi's real wrapping pipeline. Hooks fire through AgentSession's `beforeToolCall`/`afterToolCall`. Events flow through pi's real event system.
632
+
633
+ Loading runs in the same loader configuration pi's shipped runtimes use. Pi builds its jiti options from how pi itself is running, and the branch an in-process harness lands on leaves jiti's native import fast-path at a default that depends on the test runner (enabled under Bun, off under Node). That default changes what a path-loaded extension observes — an entrypoint with a module-level `await import(...)` could be handed to pi before its module body finished evaluating. `withoutJitiNativeImport` pins the choice the shipped runtimes make, so `extensions: [...]` behaves the same under vitest and `bun test`.
634
+
635
+ Only the LLM boundary is replaced — because that's the one thing you **can't** run in a deterministic test. Real-provider smoke tests belong in the application or extension that owns the provider configuration, not in this harness.
636
+
637
+ ## Testing Scope
638
+
639
+ CI runs in three stages:
640
+
641
+ 1. **Verify** on Linux/Node 24: lint, typecheck, unit tests, build, audit (`sfw npm audit`), and a packed-consumer import smoke test (`sfw npm install` with the peers declared in `package.json`).
642
+ 2. **Integration matrix** after verify passes: Linux + Windows, Node 22 + 24, Pi 0.85.x (locked by `devDependencies`).
643
+ 3. **Extension loading under Bun** after verify passes: Linux, Bun 1.3.14, running the path-loaded extension cases (`npm run test:bun`). Bun enables jiti's native import fast-path, so this is the only runner that catches a regression in top-level-await entrypoint loading.
644
+
645
+ The unit suite covers the playbook DSL and subprocess `createMockPi()` shim. The integration suite covers real in-process Pi sessions, extension loading, tool registration/execution, hooks, UI mocking, sandbox package install verification, regression cases, and Windows-safe cleanup behavior.
646
+
647
+ Known intentional gaps:
648
+
649
+ - No real LLM/provider calls; the harness replaces the model boundary by design.
650
+ - No compatibility testing for the deprecated `@mariozechner/*` Pi packages or Pi <0.85.0.
651
+ - Concurrent/parallel tool execution is not yet deeply exercised. Today the playbook emits one tool call per assistant message, which is deterministic and good for most extension tests. To test true Pi parallelism, the harness should grow a grouped/batched call action that emits multiple `toolCall` blocks in one assistant message, then assert result collection by `toolCallId` rather than completion order.
652
+ - Edge cases still worth adding over time: command/input/before-agent hooks, tool-result hook mutation, multiple extensions interacting, install failure modes, malformed package metadata, ESM/CJS fixture packages, cleanup failure paths, and concurrent `createMockPi()` subprocess consumers.
653
+
654
+ ## Releasing
655
+
656
+ Releases are driven by [changesets](https://github.com/changesets/changesets) and published from CI with npm Trusted Publishing (OIDC) — the repository holds no `NPM_TOKEN`.
657
+
658
+ 1. Add a changeset for the change (`npx changeset`) and merge it alongside the work.
659
+ 2. Run the **Release** workflow (`workflow_dispatch`) on `main` — the base branch changesets reads. With pending changesets it opens a `chore: version packages` PR carrying the version bump and the changelog.
660
+ 3. Merge that PR, then run the workflow again: nothing is left to version, so it publishes to npm, creates the `v<version>` tag, and creates the GitHub Release from that version's changelog entry.
661
+ 4. changesets additionally tags `<pkg>@<version>`; the human-facing ref is `v<version>`.
662
+
663
+ One-time prerequisite: on npmjs.com, the package's trusted publisher must name this repository and the `release` workflow. A local `npm publish` still works as a manual escape hatch, but it carries no provenance.
664
+
665
+ Consumer-side this changes nothing: install the published version, or keep a `file:`/linked dependency while developing the harness itself (see [Ways to install it](#ways-to-install-it)).
666
+
667
+ ## Upstream
668
+
669
+ Forked from [@marcfargas/pi-test-harness](https://github.com/marcfargas/pi-test-harness). Upstream history is preserved in the CHANGELOG.
670
+
671
+ ## License
672
+
673
+ MIT
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Playbook diagnostic messages — clear errors when things diverge.
3
+ */
4
+ import type { PlaybookAction } from "./types.js";
5
+ export declare function formatPlaybookDiagnostic(type: "exhausted" | "remaining", state: {
6
+ consumed: number;
7
+ remaining: number;
8
+ consumedActions: PlaybookAction[];
9
+ }, remainingActions?: PlaybookAction[]): string;
10
+ export declare function formatToolError(step: number, toolName: string, error: unknown): string;
11
+ //# sourceMappingURL=diagnostics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAcjD,wBAAgB,wBAAwB,CACvC,IAAI,EAAE,WAAW,GAAG,WAAW,EAC/B,KAAK,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,eAAe,EAAE,cAAc,EAAE,CAAA;CAAE,EACjF,gBAAgB,CAAC,EAAE,cAAc,EAAE,GACjC,MAAM,CAyCR;AAED,wBAAgB,eAAe,CAC9B,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,OAAO,GACZ,MAAM,CAoBR"}