@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,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.
|