@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
@@ -0,0 +1,480 @@
1
+ # API reference
2
+
3
+ Type definitions and the event-collection API for `@abdwhb-png/pi-test-harness` v0.7.0 (Pi 0.85.x). Use this as a reference when authoring assertions or tracing through snippets.
4
+
5
+ ## Table of contents
6
+
7
+ - [API reference](#api-reference)
8
+ - [Table of contents](#table-of-contents)
9
+ - [Entry points](#entry-points)
10
+ - [`createTestSession(options?)`](#createtestsessionoptions)
11
+ - [`TestSession`](#testsession)
12
+ - [`run(...turns)`](#runturns)
13
+ - [`events`](#events)
14
+ - [`playbook`](#playbook)
15
+ - [`dispose()`](#dispose)
16
+ - [`TestEvents`](#testevents)
17
+ - [Method cheatsheet](#method-cheatsheet)
18
+ - [Additional accessors](#additional-accessors)
19
+ - [UI method names](#ui-method-names)
20
+ - [`ToolCallRecord`](#toolcallrecord)
21
+ - [Reading it](#reading-it)
22
+ - [Pairing calls with results](#pairing-calls-with-results)
23
+ - [Asserting on blocks](#asserting-on-blocks)
24
+ - [`ToolResultRecord`](#toolresultrecord)
25
+ - [Reading it](#reading-it-1)
26
+ - [`.mocked` vs `.isError`](#mocked-vs-iserror)
27
+ - [`UICallRecord`](#uicallrecord)
28
+ - [Reading it](#reading-it-2)
29
+ - [Reading bodies safely](#reading-bodies-safely)
30
+ - [`MockToolHandler`](#mocktoolhandler)
31
+ - [`MockUIConfig`](#mockuiconfig)
32
+ - [`verifySandboxInstall(options)`](#verifysandboxinstalloptions)
33
+ - [`createMockPi()`](#createmockpi)
34
+ - [`MockPiCall`](#mockpicall)
35
+ - [`MockPi`](#mockpi)
36
+ - [`ToolBlockedError`](#toolblockederror)
37
+ - [`safeRmSync(filePath)`](#safermsyncfilepath)
38
+
39
+ ---
40
+
41
+ ## Entry points
42
+
43
+ The package's top-level exports are:
44
+
45
+ ```typescript
46
+ import {
47
+ // Session testing
48
+ createTestSession,
49
+ // Playbook builders
50
+ when, calls, says,
51
+ // Sandbox / subprocess
52
+ verifySandboxInstall,
53
+ createMockPi,
54
+ // Errors and helpers
55
+ ToolBlockedError,
56
+ safeRmSync,
57
+ // Types (type-only)
58
+ type TestSession,
59
+ type ToolResultRecord,
60
+ type MockToolHandler,
61
+ type MockUIConfig,
62
+ type MockPi,
63
+ type MockPiCall,
64
+ type Turn,
65
+ } from "@abdwhb-png/pi-test-harness";
66
+ ```
67
+
68
+ The three playbook builders (`when`, `calls`, `says`) are covered in depth in `playbook-dsl.md`; this file documents the rest.
69
+
70
+ ---
71
+
72
+ ## `createTestSession(options?)`
73
+
74
+ Creates a test session with a real Pi environment.
75
+
76
+ ```typescript
77
+ function createTestSession(options?: TestSessionOptions): Promise<TestSession>;
78
+ ```
79
+
80
+ | Option | Type | Default | Notes |
81
+ | -------------------- | --------------------------------- | ------------- | --------------------------------------------------- |
82
+ | `extensions` | `string[]` | `[]` | Extension file paths to load (via real jiti, in Pi's shipped loader configuration) |
83
+ | `extensionFactories` | `Function[]` | `[]` | Inline extension factory functions (no file needed) |
84
+ | `cwd` | `string` | auto temp dir | Working directory (auto-cleanup on `dispose()`) |
85
+ | `systemPrompt` | `string` | (default) | Override the system prompt |
86
+ | `mockTools` | `Record<string, MockToolHandler>` | — | Tool execution interceptors (see `mock-tools.md`) |
87
+ | `mockUI` | `MockUIConfig` | defaults | UI mock configuration (see `mock-ui.md`) |
88
+ | `propagateErrors` | `boolean` | `true` | If true, real tool errors abort the test |
89
+
90
+ Returns `Promise<TestSession>`.
91
+
92
+ ---
93
+
94
+ ## `TestSession`
95
+
96
+ The session object returned by `createTestSession`.
97
+
98
+ ```typescript
99
+ interface TestSession {
100
+ run(...turns: Turn[]): Promise<void>;
101
+ session: AgentSession; // the real Pi session underneath
102
+ cwd: string; // working directory
103
+ events: TestEvents; // all collected events
104
+ playbook: { consumed: number; remaining: number };
105
+ dispose(): void; // cleanup temp dir and session
106
+ }
107
+ ```
108
+
109
+ ### `run(...turns)`
110
+
111
+ Runs the conversation script. Each turn is the output of `when(prompt, actions)` from the playbook DSL.
112
+
113
+ ```typescript
114
+ await t.run(
115
+ when("Prompt A", [calls("tool", {...}), says("...")]),
116
+ when("Prompt B", [calls("tool", {...}), says("...")]),
117
+ );
118
+ ```
119
+
120
+ `run` is async; resolve it before asserting on `t.events`.
121
+
122
+ ### `events`
123
+
124
+ See [`TestEvents`](#testevents) below.
125
+
126
+ ### `playbook`
127
+
128
+ After `run()`, you can inspect how much of the script was consumed:
129
+
130
+ - `consumed`: number of actions the agent loop pulled from the playbook.
131
+ - `remaining`: number of actions still queued.
132
+
133
+ If `remaining > 0`, the harness will also throw a diagnostic — see "Playbook not fully consumed" in `playbook-dsl.md`. This field is mostly useful for debugging mid-test where you'd want to assert `remaining === 0`.
134
+
135
+ ### `dispose()`
136
+
137
+ Cleans up the session resources and (if auto-generated) the temp `cwd`. Always call in `afterEach`. Note that `dispose()` does **not** fire `session_shutdown` — that event fires on process exit. See `safeRmSync` for why this matters on Windows.
138
+
139
+ ---
140
+
141
+ ## `TestEvents`
142
+
143
+ The collected-event accessor. Every tool call, tool result, UI interaction, and message flows through here.
144
+
145
+ ```typescript
146
+ interface TestEvents {
147
+ // Tool interactions
148
+ toolCallsFor(name: string): ToolCallRecord[];
149
+ toolResultsFor(name: string): ToolResultRecord[];
150
+ blockedCalls(): ToolCallRecord[]; // subset of toolCallsFor where blocked === true
151
+
152
+ // UI interactions
153
+ uiCallsFor(name: UIMethodName): UICallRecord[];
154
+
155
+ // Raw history
156
+ messages: AgentMessage[];
157
+ all: AgentSessionEvent[];
158
+ }
159
+ ```
160
+
161
+ ### Method cheatsheet
162
+
163
+ | Method | Returns | Purpose |
164
+ | ------------------------ | --------------------- | ----------------------------------------------------------- |
165
+ | `toolCallsFor("bash")` | `ToolCallRecord[]` | All invocations of `bash` |
166
+ | `toolResultsFor("bash")` | `ToolResultRecord[]` | All results returned by `bash` (real or mocked) |
167
+ | `blockedCalls()` | `ToolCallRecord[]` | Calls blocked by extension hooks (where `blocked === true`) |
168
+ | `uiCallsFor("confirm")` | `UICallRecord[]` | All calls to that UI method |
169
+ | `messages` | `AgentMessage[]` | The conversation history |
170
+ | `all` | `AgentSessionEvent[]` | Every event, in order |
171
+
172
+ ### Additional accessors
173
+
174
+ `TestEvents` also exposes:
175
+
176
+ ```typescript
177
+ toolSequence(): string[]; // ordered list of tool names as they were called
178
+ ```
179
+
180
+ Useful when the *order* of tool invocations matters more than their params, e.g. asserting your extension always calls `plan_mode` before `plan_propose`.
181
+
182
+ ### UI method names
183
+
184
+ Pass any of these to `uiCallsFor`:
185
+
186
+ - `"confirm"`
187
+ - `"select"`
188
+ - `"input"`
189
+ - `"editor"`
190
+ - `"notify"`
191
+
192
+ ---
193
+
194
+ ## `ToolCallRecord`
195
+
196
+ The shape returned by `t.events.toolCallsFor(name)`. Use it when you want to assert *what was asked*, not what came back — that goes through [`ToolResultRecord`](#toolresultrecord). Also returned by `blockedCalls()`.
197
+
198
+ ```typescript
199
+ interface ToolCallRecord {
200
+ step: number; // playbook step index
201
+ toolName: string;
202
+ input: Record<string, unknown>; // the params object from calls(tool, params)
203
+ blocked: boolean; // true if an extension hook blocked this call
204
+ blockReason?: string; // the reason string if blocked
205
+ }
206
+ ```
207
+
208
+ **Source**: verified against `dist/types.d.ts` in v0.7.0. Note the field is `input` (not `params`); it carries the params you passed via `calls(tool, params)`. There is no `toolCallId` on `ToolCallRecord` — pair with results by index (e.g. `toolCallsFor(name)[i]` ↔ `toolResultsFor(name)[i]`) since both arrays are in call order.
209
+
210
+ ### Reading it
211
+
212
+ ```typescript
213
+ const bashCalls = t.events.toolCallsFor("bash");
214
+ expect(bashCalls).toHaveLength(1);
215
+
216
+ const c = bashCalls[0];
217
+ expect(c.toolName).toBe("bash");
218
+ expect(c.input).toEqual({ command: "ls -la" });
219
+ expect(c.blocked).toBe(false); // no hook blocked
220
+ ```
221
+
222
+ ### Pairing calls with results
223
+
224
+ Pair `toolCallsFor(name)` with `toolResultsFor(name)` by index — both arrays are in call order:
225
+
226
+ ```typescript
227
+ const call = t.events.toolCallsFor("summarize_doc")[0];
228
+ const result = t.events.toolResultsFor("summarize_doc")[0];
229
+
230
+ expect(call.input).toEqual({ path: "README.md" });
231
+ expect(result.mocked).toBe(false); // extension's real code ran
232
+ ```
233
+
234
+ ### Asserting on blocks
235
+
236
+ `ToolCallRecord.blocked` + `blockReason` (since v0.5.0) are the canonical way to assert a hook did the blocking. `blockedCalls()` returns the subset where `blocked === true`:
237
+
238
+ ```typescript
239
+ await t.run(when("Try write", [calls("bash", { command: "rm -rf /" }), says("Done.")]));
240
+
241
+ const blocked = t.events.blockedCalls();
242
+ expect(blocked).toHaveLength(1);
243
+ expect(blocked[0].toolName).toBe("bash");
244
+ expect(blocked[0].blocked).toBe(true);
245
+ expect(blocked[0].blockReason).toBeTruthy();
246
+ ```
247
+
248
+ This is the lower-friction alternative to catching `ToolBlockedError` — see `mock-tools.md` for when to prefer each.
249
+
250
+ ---
251
+
252
+ ## `ToolResultRecord`
253
+
254
+ The primary shape you'll assert on for tool results.
255
+
256
+ ```typescript
257
+ interface ToolResultRecord {
258
+ step: number; // playbook step index
259
+ toolName: string;
260
+ toolCallId: string;
261
+ text: string; // concatenated text content
262
+ content: Array<{ type: string; text?: string }>;
263
+ isError: boolean;
264
+ details?: unknown; // optional details object (mock-provided or real)
265
+ mocked: boolean; // true if mockTools handled it
266
+ }
267
+ ```
268
+
269
+ **Source**: verified against `dist/types.d.ts` in v0.7.0.
270
+
271
+ ### Reading it
272
+
273
+ ```typescript
274
+ const bashResults = t.events.toolResultsFor("bash");
275
+ expect(bashResults).toHaveLength(1);
276
+
277
+ const r = bashResults[0];
278
+ expect(r.mocked).toBe(true); // mockTools handled this one
279
+ expect(r.isError).toBe(false); // no error
280
+ expect(r.text).toContain("file1.txt"); // text content
281
+ expect(r.step).toBe(0); // first playbook step
282
+ ```
283
+
284
+ ### `.mocked` vs `.isError`
285
+
286
+ - `.mocked === true` → the mock handler produced this result. Hooks still fired, but no real tool code ran.
287
+ - `.isError === true` → the result is an error (either produced by the extension hook blocking the call, or captured from a real tool throw when `propagateErrors: false`).
288
+
289
+ ---
290
+
291
+ ## `UICallRecord`
292
+
293
+ The shape returned by `t.events.uiCallsFor(method)`. Note: there is no `step` or `title` field — `args` carries the positional arguments (typically `[title, messageOrItemsOrPlaceholder]` depending on method).
294
+
295
+ ```typescript
296
+ interface UICallRecord {
297
+ method: string; // "confirm" | "select" | "input" | "editor" | "notify"
298
+ args: unknown[]; // positional args the extension passed
299
+ returnValue?: unknown; // what the mock returned (omitted for outbound-only methods)
300
+ }
301
+ ```
302
+
303
+ **Source**: verified against `dist/types.d.ts` in v0.7.0. `notify` calls are recorded here too — `returnValue` is typically omitted for outbound methods.
304
+
305
+ ### Reading it
306
+
307
+ ```typescript
308
+ const confirms = t.events.uiCallsFor("confirm");
309
+ expect(confirms).toHaveLength(1);
310
+
311
+ const c = confirms[0];
312
+ expect(c.method).toBe("confirm");
313
+ expect(c.args[0]).toContain("Delete"); // args[0] is typically the title
314
+ expect(c.returnValue).toBe(false); // what the user (mock) answered
315
+
316
+ // `notify` is outbound-only and not in MockUIConfig, but it IS recorded —
317
+ // so uiCallsFor("notify") is the canonical way to assert "the user saw X".
318
+ const notifies = t.events.uiCallsFor("notify");
319
+ expect(notifies).toHaveLength(1);
320
+ expect(JSON.stringify(notifies[0].args)).toContain("Plan approved");
321
+ ```
322
+
323
+ ### Reading bodies safely
324
+
325
+ The `notify` body lives in `args` (positional, no pinned shape beyond method). Stringify the whole record if your assertion is substring-based and you don't want to commit to a specific arg index:
326
+
327
+ ```typescript
328
+ const text = JSON.stringify(t.events.uiCallsFor("notify"));
329
+ expect(text).not.toMatch(/agent crashed/i); // raw stderr never leaks
330
+ expect(text).toMatch(/couldn't|unable|sorry/i); // friendly message present
331
+ ```
332
+
333
+ ---
334
+
335
+ ## `MockToolHandler`
336
+
337
+ ```typescript
338
+ type MockToolHandler =
339
+ | string
340
+ | ToolResult
341
+ | ((params: Record<string, unknown>) => string | ToolResult);
342
+ ```
343
+
344
+ | Variant | Returns | Use case |
345
+ | ------------ | ---------------------------------------------- | ----------------------------------- |
346
+ | `string` | `{ content: [{ type: "text", text: "..." }] }` | Static, canned response |
347
+ | `ToolResult` | (the object itself) | Precise control of content/details |
348
+ | function | `string` or `ToolResult` (resolved per call) | Dynamic, params-dependent responses |
349
+
350
+ The function form receives `Record<string, unknown>` — cast or narrow as needed for your test.
351
+
352
+ ---
353
+
354
+ ## `MockUIConfig`
355
+
356
+ ```typescript
357
+ interface MockUIConfig {
358
+ confirm?: boolean | ((title: string, message: string) => boolean);
359
+ select?: number | string | ((title: string, items: string[]) => string | undefined);
360
+ input?: string | ((title: string, placeholder?: string) => string | undefined);
361
+ editor?: string | ((title: string, prefilled?: string) => string | undefined);
362
+ }
363
+ ```
364
+
365
+ Each field accepts a static value (same for every call) or a dynamic function (computed per call). Defaults when no `mockUI` is provided: `confirm → true`, `select → 0`, `input → ""`, `editor → ""`.
366
+
367
+ See `mock-ui.md` for the full behavior table and when to override defaults.
368
+
369
+ ---
370
+
371
+ ## `verifySandboxInstall(options)`
372
+
373
+ ```typescript
374
+ function verifySandboxInstall(options: SandboxInstallOptions): Promise<SandboxInstallResult>;
375
+ ```
376
+
377
+ | Option | Type | Required | Purpose |
378
+ | ------------------- | --------------------------------- | -------- | -------------------------------------- |
379
+ | `packageDir` | `string` | yes | Package directory (must have pkg.json) |
380
+ | `expect.extensions` | `number` | — | Expected extension count |
381
+ | `expect.tools` | `string[]` | — | Required tool names |
382
+ | `expect.skills` | `number` | — | Expected skill count |
383
+ | `smoke.mockTools` | `Record<string, MockToolHandler>` | — | Mock tools for the smoke test |
384
+ | `smoke.script` | `Turn[]` | — | Playbook turns for the smoke test |
385
+
386
+ Returns `{ loaded, smoke? }` — `loaded` includes `extensionErrors`, `tools`, `extensions`, `skills`; `smoke` (if configured) exposes the `TestEvents` API for the in-sandbox run. See `sandbox-install.md`.
387
+
388
+ ---
389
+
390
+ ## `createMockPi()`
391
+
392
+ ```typescript
393
+ function createMockPi(): MockPi;
394
+ ```
395
+
396
+ Returns a `MockPi` instance. Doesn't install anything until `.install()` is called. See `mock-pi-cli.md` for lifecycle and response shapes.
397
+
398
+ ---
399
+
400
+ ## `MockPiCall`
401
+
402
+ Response object accepted by `MockPi.onCall(response)`.
403
+
404
+ ```typescript
405
+ interface MockPiCall {
406
+ output?: string; // default: echo of task text
407
+ exitCode?: number; // default: 0
408
+ stderr?: string; // default: empty
409
+ delay?: number; // default: 0 (ms)
410
+ jsonl?: object[]; // default: none — replaces default event
411
+ writeFiles?: Record<string, string>; // default: none
412
+ }
413
+ ```
414
+
415
+ All fields optional. Key-validated at `onCall` time — typos like `{ ouptut: "..." }` throw.
416
+
417
+ ---
418
+
419
+ ## `MockPi`
420
+
421
+ ```typescript
422
+ interface MockPi {
423
+ install(): void; // create shim, prepend to PATH
424
+ uninstall(): void; // restore PATH, delete temp dir
425
+ onCall(response: MockPiCall): void; // queue one response
426
+ reset(): void; // clear queue and counter
427
+ callCount(): number; // number of times mock pi was invoked
428
+ readonly dir: string; // temp directory path
429
+ }
430
+ ```
431
+
432
+ ---
433
+
434
+ ## `ToolBlockedError`
435
+
436
+ Exported for source compatibility with upstream consumers, but **not promised** by normal Pi 0.84 runs through `AgentSession`. The canonical block signal is the event records: `blocked: true`/`blockReason` on the `ToolCallRecord` and `isError: true` + result text on the `ToolResultRecord` — assert those instead.
437
+
438
+ ```typescript
439
+ import {
440
+ createTestSession, when, calls, says,
441
+ } from "@abdwhb-png/pi-test-harness";
442
+
443
+ const t = await createTestSession({ mockTools: { bash: "ok" } });
444
+ await t.run(when("Try write", [calls("bash", { command: "rm -rf /" }), says("Done.")]));
445
+
446
+ const call = t.events.toolCallsFor("bash")[0];
447
+ expect(call.blocked).toBe(true);
448
+ expect(call.blockReason).toMatch(/blocked/i);
449
+ const result = t.events.toolResultsFor("bash")[0];
450
+ expect(result.isError).toBe(true);
451
+ ```
452
+
453
+ Do not rely on `instanceof ToolBlockedError` to detect a hook block from a normal Pi 0.84 run. See `mock-tools.md` for the event-assertion patterns.
454
+
455
+ ---
456
+
457
+ ## `safeRmSync(filePath)`
458
+
459
+ ```typescript
460
+ function safeRmSync(filePath: string): void;
461
+ ```
462
+
463
+ Removes a file, swallowing `EPERM` and `EBUSY` errors only. All other errors propagate.
464
+
465
+ Use it in `afterEach` on Windows when an extension opened a SQLite database in `session_start` (memory, brainiac, etc.) and `session.dispose()` doesn't release the file lock (because `session_shutdown` only fires on process exit).
466
+
467
+ ```typescript
468
+ import { safeRmSync } from "@abdwhb-png/pi-test-harness";
469
+
470
+ let dbPath: string;
471
+
472
+ afterEach(() => {
473
+ t?.dispose(); // dispose first
474
+ safeRmSync(dbPath);
475
+ safeRmSync(dbPath + "-wal");
476
+ safeRmSync(dbPath + "-shm");
477
+ });
478
+ ```
479
+
480
+ For isolation across tests, give each one a unique DB path (typically `mkdtempSync` + test name). Files get cleaned by the OS at process exit; this is just about avoiding `EPERM` mid-suite.
@@ -0,0 +1,135 @@
1
+ # Mock Pi CLI reference
2
+
3
+ `createMockPi` is for extensions that **spawn** `pi --mode json -p` as a subprocess to delegate work to a child agent — common in subagent orchestrators, fan-out task runners, and reflection/replan loops.
4
+
5
+ In tests, those subprocesses would call a real LLM (non-deterministic, slow, possibly requiring credentials). `createMockPi` solves this by putting a *fake* `pi` binary on `PATH` that returns controllable responses from a queue.
6
+
7
+ `createMockPi` replaces only the executable boundary. Load the actual extension with `createTestSession`, or import its exported helper, so the test exercises production code. Do not recreate the subprocess helper inside the test.
8
+
9
+ ## How it works
10
+
11
+ 1. `install()` creates a temp directory with a platform-specific shim:
12
+ - `pi.cmd` on Windows
13
+ - shell script `pi` on Linux/macOS
14
+ 2. The shim is prepended to `PATH` so `child_process.spawn("pi", ...)` resolves to it.
15
+ 3. Each invocation reads the next response from a file-based queue (`queue.json` + a counter file).
16
+ 4. When the queue is exhausted, the last response repeats.
17
+ 5. If no responses are queued, the mock echoes back the task text.
18
+
19
+ You read & assert the queue from your test; the spawned subprocesses read it from disk. That decoupling is what makes this safe across worker boundaries.
20
+
21
+ ## Full lifecycle
22
+
23
+ ```typescript
24
+ import { createMockPi } from "@abdwhb-png/pi-test-harness";
25
+
26
+ const mockPi = createMockPi();
27
+ mockPi.install(); // 1. enable
28
+
29
+ mockPi.onCall({ output: "Hello from agent", exitCode: 0 }); // 2. queue responses
30
+ mockPi.onCall({ stderr: "agent crashed", exitCode: 1 });
31
+ mockPi.onCall({
32
+ jsonl: [
33
+ { type: "tool_execution_start", toolName: "bash" },
34
+ { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
35
+ ],
36
+ });
37
+
38
+ expect(mockPi.callCount()).toBe(0); // 3. assert before
39
+
40
+ // ...playbook that triggers subprocess spawns via your extension...
41
+
42
+ // ...then assert after:
43
+ expect(mockPi.callCount()).toBe(3);
44
+
45
+ mockPi.uninstall(); // 4. cleanup: restore PATH, delete temp dir
46
+ ```
47
+
48
+ ## Response options: `MockPiCall`
49
+
50
+ Each call to `onCall(response)` queues one `MockPiCall`. All fields are optional:
51
+
52
+ | Field | Type | Default | Purpose |
53
+ |--------------|-------------------------|-----------------------|---------------------------------------------------------------------------|
54
+ | `output` | `string` | echo of the task text | Text emitted in a default `message_end` event |
55
+ | `exitCode` | `number` | `0` | Process exit code |
56
+ | `stderr` | `string` | (empty) | Written to the child process's stderr |
57
+ | `delay` | `number` | `0` | Delay (ms) before responding — used to test timeout/abort paths |
58
+ | `jsonl` | `object[]` | (none) | Raw JSONL event objects; if set, replaces the default `message_end` |
59
+ | `writeFiles` | `Record<string,string>` | (none) | Files to create in the spawned process's CWD (path → content) |
60
+
61
+ ### Choosing between `output` and `jsonl`
62
+
63
+ - **`output: "Hello"`** — quick, common case. The mock wraps your string in a default `message_end` event that Pi's subprocess protocol expects.
64
+ - **`jsonl: [...]`** — for tests that care about specific event types being emitted (e.g., simulating `tool_execution_start` before `message_end`, or simulating an error event midway).
65
+
66
+ Reach for `jsonl` when your extension parses the subprocess's event stream for specific shapes, not just the final text.
67
+
68
+ ### The `writeFiles` field
69
+
70
+ Some real Pi subprocesses don't return text — they write to disk (chain_dir output, plan files, etc.). Simulate that with `writeFiles`:
71
+
72
+ ```typescript
73
+ mockPi.onCall({
74
+ output: "Result written",
75
+ writeFiles: { "/tmp/output.md": "# Result\nDone." },
76
+ });
77
+ ```
78
+
79
+ The mock writes those files to the spawned process's CWD during the call. Combined with `output` so your extension reads both channels.
80
+
81
+ ## Safety features
82
+
83
+ These exist because tests crash, OSes differ, and typos happen:
84
+
85
+ - **Exit handler restores `PATH`.** On Node process exit — even if `uninstall()` was never called (test crash) — `PATH` is restored automatically. No permanent pollution of your environment.
86
+ - **Key validation.** Typos like `{ ouptut: "..." }` throw immediately at `onCall()`. Without this, the misspelled response would silently use the default and your test would pass for the wrong reason.
87
+ - **30-second timeout.** If something goes wrong (deadlocked consumer, etc.), the mock subprocess self-terminates after 30s rather than hanging your whole test run.
88
+
89
+ ## The mock's control surface
90
+
91
+ | Method | Returns | Purpose |
92
+ |-----------------|------------|------------------------------------------------|
93
+ | `install()` | `void` | Create shim, prepend to `PATH` |
94
+ | `uninstall()` | `void` | Restore `PATH`, delete temp dir |
95
+ | `onCall(r)` | `void` | Queue a `MockPiCall` response |
96
+ | `reset()` | `void` | Clear queue and counter (use between tests) |
97
+ | `callCount()` | `number` | Number of times the mock `pi` was invoked |
98
+ | `dir` | `string` | Temp directory path (read-only accessor) |
99
+
100
+ Always call `install()` before queuing or running tests. Always call `uninstall()` (or rely on the exit handler) in cleanup. For multi-test files, `reset()` between tests so queued responses from one test don't leak into the next.
101
+
102
+ ## The concurrency caveat (read this)
103
+
104
+ `createMockPi` is designed for **serial spawns within a single test**. Concurrency is intentionally limited:
105
+
106
+ - Responses are consumed in queue order from a shared file.
107
+ - If your extension spawns multiple `pi` subprocesses concurrently, responses may be consumed out of order — whichever spawned process grabs the queue first wins.
108
+
109
+ For tests of fan-out parallel patterns, this can produce flaky results. Workarounds:
110
+
111
+ 1. **Run your test serial** at the extension level so only one subprocess is ever in flight.
112
+ 2. **If your extension genuinely parallelizes**, the responses you queue should be order-independent (e.g., all return "ok") so order shuffling doesn't change the result.
113
+ 3. **Long-term**: the harness tracks the addition of a grouped/batched call action that would let test authors assert by `toolCallId` rather than completion order. Not yet supported.
114
+
115
+ If true parallel-subprocess testing is the critical path for your extension, that's a known intentional gap — call it out in your extension's own test plan rather than fighting the mock.
116
+
117
+ ## Choosing `createMockPi` vs `createTestSession`
118
+
119
+ | Diagnostic question | Use |
120
+ |--------------------------------------------------------------|--------------------|
121
+ | Does my extension's `execute()` produce the right output? | `createTestSession`|
122
+ | Do my hooks fire / block correctly? | `createTestSession`|
123
+ | Does my extension load when installed from npm? | `verifySandboxInstall` |
124
+ | **Does my extension handle the `pi` subprocess crashing?** | **`createMockPi`** |
125
+ | **Does my extension parse `pi`'s JSONL streaming output?** | **`createMockPi`** |
126
+ | **Does my extension react to files the subprocess wrote?** | **`createMockPi`** |
127
+
128
+ `createMockPi` is the layer you reach for when your test questions are about the *subprocess integration* itself, not the agent loop running inside the child.
129
+
130
+ ## Pitfalls
131
+
132
+ - **Forgetting `install()`**. Without it, `pi` resolves to your real system Pi (or fails to resolve), and your test doesn't exercise anything meaningful.
133
+ - **Queueing fewer responses than spawns**. The last response repeats — which can mask the case where you spawn N times but expect N distinct outputs. Always assert `callCount()`.
134
+ - **Forgetting `uninstall()` in cleanup**. The exit handler catches you, but leaving `PATH` modified during a test run can interfere with other tests. Use it.
135
+ - **Test flakiness from concurrent spawns**. See the concurrency caveat above.