@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,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Playbook diagnostic messages — clear errors when things diverge.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PlaybookAction } from "./types.js";
|
|
6
|
+
|
|
7
|
+
function formatAction(action: PlaybookAction): string {
|
|
8
|
+
if (action.type === "say") {
|
|
9
|
+
return `says("${action.text?.slice(0, 60)}${(action.text?.length ?? 0) > 60 ? "..." : ""}")`;
|
|
10
|
+
}
|
|
11
|
+
if (action.type === "call") {
|
|
12
|
+
const params = typeof action.params === "function" ? "<late-bound>" : JSON.stringify(action.params);
|
|
13
|
+
const truncated = params.length > 80 ? params.slice(0, 80) + "..." : params;
|
|
14
|
+
return `calls("${action.toolName}", ${truncated})`;
|
|
15
|
+
}
|
|
16
|
+
return `unknown(${action.type})`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatPlaybookDiagnostic(
|
|
20
|
+
type: "exhausted" | "remaining",
|
|
21
|
+
state: { consumed: number; remaining: number; consumedActions: PlaybookAction[] },
|
|
22
|
+
remainingActions?: PlaybookAction[],
|
|
23
|
+
): string {
|
|
24
|
+
if (type === "exhausted") {
|
|
25
|
+
const last = state.consumedActions[state.consumedActions.length - 1];
|
|
26
|
+
const lines = [
|
|
27
|
+
`Playbook exhausted unexpectedly.`,
|
|
28
|
+
` Consumed ${state.consumed} action(s).`,
|
|
29
|
+
];
|
|
30
|
+
if (last) {
|
|
31
|
+
lines.push(` Last consumed: ${formatAction(last)} at step ${state.consumed}`);
|
|
32
|
+
}
|
|
33
|
+
lines.push(
|
|
34
|
+
"",
|
|
35
|
+
" The agent loop called streamFn but no more playbook actions were available.",
|
|
36
|
+
" This usually means a tool call produced an unexpected result that caused",
|
|
37
|
+
" additional streamFn calls (retries, error handling).",
|
|
38
|
+
);
|
|
39
|
+
return lines.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (type === "remaining" && remainingActions) {
|
|
43
|
+
const lines = [
|
|
44
|
+
`Playbook not fully consumed after run() completed.`,
|
|
45
|
+
` Consumed ${state.consumed} of ${state.consumed + remainingActions.length} action(s).`,
|
|
46
|
+
` Remaining:`,
|
|
47
|
+
];
|
|
48
|
+
for (const action of remainingActions.slice(0, 5)) {
|
|
49
|
+
lines.push(` - ${formatAction(action)}`);
|
|
50
|
+
}
|
|
51
|
+
if (remainingActions.length > 5) {
|
|
52
|
+
lines.push(` ... +${remainingActions.length - 5} more`);
|
|
53
|
+
}
|
|
54
|
+
lines.push(
|
|
55
|
+
"",
|
|
56
|
+
" The agent loop ended before all playbook actions were used.",
|
|
57
|
+
" This usually means a tool was blocked by a hook or returned early,",
|
|
58
|
+
" causing fewer streamFn calls than expected.",
|
|
59
|
+
);
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return "Unknown playbook diagnostic.";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatToolError(
|
|
67
|
+
step: number,
|
|
68
|
+
toolName: string,
|
|
69
|
+
error: unknown,
|
|
70
|
+
): string {
|
|
71
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
72
|
+
const stack = error instanceof Error ? error.stack : undefined;
|
|
73
|
+
const lines = [
|
|
74
|
+
`Error during tool execution at playbook step ${step} (call "${toolName}"):`,
|
|
75
|
+
` ${message}`,
|
|
76
|
+
];
|
|
77
|
+
if (stack) {
|
|
78
|
+
const stackLines = stack.split("\n").slice(1, 4);
|
|
79
|
+
for (const line of stackLines) {
|
|
80
|
+
lines.push(` ${line.trim()}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
lines.push(
|
|
84
|
+
"",
|
|
85
|
+
"This error was thrown by the real tool execution, not by the playbook.",
|
|
86
|
+
"To capture errors as tool results instead of aborting, set:",
|
|
87
|
+
" createTestSession({ propagateErrors: false })",
|
|
88
|
+
);
|
|
89
|
+
return lines.join("\n");
|
|
90
|
+
}
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event collection — passively collects all events during a test run.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
7
|
+
import type { TestEvents, ToolCallRecord, ToolResultRecord, UICallRecord } from "./types.js";
|
|
8
|
+
|
|
9
|
+
export function createEventCollector(): TestEvents {
|
|
10
|
+
const all: AgentSessionEvent[] = [];
|
|
11
|
+
const toolCalls: ToolCallRecord[] = [];
|
|
12
|
+
const toolResults: ToolResultRecord[] = [];
|
|
13
|
+
const messages: AgentMessage[] = [];
|
|
14
|
+
const ui: UICallRecord[] = [];
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
all,
|
|
18
|
+
toolCalls,
|
|
19
|
+
toolResults,
|
|
20
|
+
messages,
|
|
21
|
+
ui,
|
|
22
|
+
|
|
23
|
+
toolCallsFor(name: string): ToolCallRecord[] {
|
|
24
|
+
return toolCalls.filter((tc) => tc.toolName === name);
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
toolResultsFor(name: string): ToolResultRecord[] {
|
|
28
|
+
return toolResults.filter((tr) => tr.toolName === name);
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
blockedCalls(): ToolCallRecord[] {
|
|
32
|
+
return toolCalls.filter((tc) => tc.blocked);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
uiCallsFor(method: string): UICallRecord[] {
|
|
36
|
+
return ui.filter((u) => u.method === method);
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
toolSequence(): string[] {
|
|
40
|
+
return toolCalls.map((tc) => tc.toolName);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @abdwhb-png/pi-test-harness
|
|
3
|
+
*
|
|
4
|
+
* Test harness for pi extensions — playbook-based model mocking,
|
|
5
|
+
* session testing, sandbox install verification.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// DSL builders
|
|
9
|
+
export { when, calls, says } from "./playbook.js";
|
|
10
|
+
|
|
11
|
+
// Session
|
|
12
|
+
export { createTestSession } from "./session.js";
|
|
13
|
+
|
|
14
|
+
// Sandbox
|
|
15
|
+
export { verifySandboxInstall } from "./sandbox.js";
|
|
16
|
+
|
|
17
|
+
// Mock Pi
|
|
18
|
+
export { createMockPi } from "./mock-pi.js";
|
|
19
|
+
|
|
20
|
+
// Types
|
|
21
|
+
export type {
|
|
22
|
+
TestSession,
|
|
23
|
+
TestSessionOptions,
|
|
24
|
+
TestEvents,
|
|
25
|
+
ToolCallRecord,
|
|
26
|
+
ToolResultRecord,
|
|
27
|
+
UICallRecord,
|
|
28
|
+
MockToolHandler,
|
|
29
|
+
MockUIConfig,
|
|
30
|
+
SandboxOptions,
|
|
31
|
+
SandboxResult,
|
|
32
|
+
MockPi,
|
|
33
|
+
MockPiCall,
|
|
34
|
+
Turn,
|
|
35
|
+
PlaybookAction,
|
|
36
|
+
} from "./types.js";
|
|
37
|
+
|
|
38
|
+
// Errors
|
|
39
|
+
export { ToolBlockedError } from "./mock-tools.js";
|
|
40
|
+
|
|
41
|
+
// Utilities
|
|
42
|
+
export { safeRmSync } from "./utils.js";
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Mock pi CLI for integration tests.
|
|
4
|
+
*
|
|
5
|
+
* Reads response queue from MOCK_PI_QUEUE_DIR (set by the shim created by createMockPi).
|
|
6
|
+
* Each invocation consumes the next entry from the queue. When the queue is exhausted,
|
|
7
|
+
* the last entry repeats. If no entries are queued, outputs a default echo response.
|
|
8
|
+
*
|
|
9
|
+
* Queue protocol:
|
|
10
|
+
* {queueDir}/queue.json — JSON array of MockPiCall objects
|
|
11
|
+
* {queueDir}/counter — integer: current call index (auto-incremented)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
// Safety timeout — prevent hanging tests if something goes wrong
|
|
18
|
+
const TIMEOUT_MS = 30_000;
|
|
19
|
+
setTimeout(() => {
|
|
20
|
+
process.stderr.write("mock-pi-script: timeout after 30s\n");
|
|
21
|
+
process.exit(124);
|
|
22
|
+
}, TIMEOUT_MS).unref();
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Queue directory (set by the shim script)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
const queueDir = process.env.MOCK_PI_QUEUE_DIR;
|
|
28
|
+
if (!queueDir) {
|
|
29
|
+
process.stderr.write("mock-pi-script: MOCK_PI_QUEUE_DIR not set\n");
|
|
30
|
+
process.exit(99);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Parse CLI arguments (matches what pi-subagents passes to pi)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
let task = "";
|
|
38
|
+
let sessionDir = null;
|
|
39
|
+
|
|
40
|
+
let i = 0;
|
|
41
|
+
while (i < args.length) {
|
|
42
|
+
const arg = args[i];
|
|
43
|
+
|
|
44
|
+
// Flags with a value — skip the value
|
|
45
|
+
if (
|
|
46
|
+
arg === "--session-dir" ||
|
|
47
|
+
arg === "--mode" ||
|
|
48
|
+
arg === "--models" ||
|
|
49
|
+
arg === "--tools" ||
|
|
50
|
+
arg === "--extension" ||
|
|
51
|
+
arg === "--append-system-prompt"
|
|
52
|
+
) {
|
|
53
|
+
if (arg === "--session-dir") sessionDir = args[i + 1] ?? null;
|
|
54
|
+
i += 2;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Flags without a value
|
|
59
|
+
if (arg === "-p" || arg === "--no-session" || arg === "--no-extensions") {
|
|
60
|
+
i++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// @file — read task from file
|
|
65
|
+
if (arg?.startsWith("@")) {
|
|
66
|
+
try {
|
|
67
|
+
task = fs.readFileSync(arg.slice(1), "utf-8");
|
|
68
|
+
} catch {
|
|
69
|
+
task = "(could not read " + arg.slice(1) + ")";
|
|
70
|
+
}
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Positional — treat as task text
|
|
76
|
+
if (arg && !arg.startsWith("-")) {
|
|
77
|
+
task = arg;
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Read queue and counter
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
let queue = [];
|
|
86
|
+
const queueFile = path.join(queueDir, "queue.json");
|
|
87
|
+
if (fs.existsSync(queueFile)) {
|
|
88
|
+
try {
|
|
89
|
+
queue = JSON.parse(fs.readFileSync(queueFile, "utf-8"));
|
|
90
|
+
} catch {
|
|
91
|
+
// Malformed queue — treat as empty
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const counterFile = path.join(queueDir, "counter");
|
|
96
|
+
let counter = 0;
|
|
97
|
+
if (fs.existsSync(counterFile)) {
|
|
98
|
+
try {
|
|
99
|
+
counter = parseInt(fs.readFileSync(counterFile, "utf-8").trim(), 10) || 0;
|
|
100
|
+
} catch {
|
|
101
|
+
// Missing or unreadable — start at 0
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Increment counter for next invocation
|
|
106
|
+
fs.writeFileSync(counterFile, String(counter + 1));
|
|
107
|
+
|
|
108
|
+
// Get the current entry (repeat last when exhausted, null if no queue)
|
|
109
|
+
const entry =
|
|
110
|
+
queue.length > 0 ? queue[Math.min(counter, queue.length - 1)] : null;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Delay
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
if (entry?.delay > 0) {
|
|
116
|
+
await new Promise((r) => setTimeout(r, entry.delay));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Stderr
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
if (entry?.stderr) {
|
|
123
|
+
process.stderr.write(entry.stderr + "\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Write files (for chain_dir output simulation)
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
if (entry?.writeFiles) {
|
|
130
|
+
for (const [filePath, content] of Object.entries(entry.writeFiles)) {
|
|
131
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
132
|
+
fs.writeFileSync(filePath, content);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// JSONL output
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
if (entry?.jsonl) {
|
|
140
|
+
for (const event of entry.jsonl) {
|
|
141
|
+
console.log(typeof event === "string" ? event : JSON.stringify(event));
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const taskClean = task.replace(/^Task:\s*/i, "").slice(0, 500);
|
|
145
|
+
const output = entry?.output ?? "Mock output for: " + taskClean;
|
|
146
|
+
console.log(
|
|
147
|
+
JSON.stringify({
|
|
148
|
+
type: "message_end",
|
|
149
|
+
message: {
|
|
150
|
+
role: "assistant",
|
|
151
|
+
content: [{ type: "text", text: output }],
|
|
152
|
+
model: "mock/test-model",
|
|
153
|
+
usage: {
|
|
154
|
+
input: 100,
|
|
155
|
+
output: 50,
|
|
156
|
+
cacheRead: 0,
|
|
157
|
+
cacheWrite: 0,
|
|
158
|
+
cost: { total: 0.001 },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// Session file (if requested)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
if (sessionDir) {
|
|
169
|
+
fs.mkdirSync(sessionDir, { recursive: true });
|
|
170
|
+
fs.writeFileSync(
|
|
171
|
+
path.join(sessionDir, "session-" + Date.now() + ".jsonl"),
|
|
172
|
+
JSON.stringify({ type: "session_start" }) + "\n",
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
process.exit(entry?.exitCode ?? 0);
|
package/src/mock-pi.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createMockPi — mock pi CLI for testing extensions that spawn pi as a subprocess.
|
|
3
|
+
*
|
|
4
|
+
* Creates a temp directory with a `pi` shim (`.cmd` on Windows, shell script on Linux)
|
|
5
|
+
* that prepends to PATH. The shim invokes a mock-pi-script.mjs that reads queued
|
|
6
|
+
* responses from a file-based queue. Responses are consumed in order; the last one
|
|
7
|
+
* repeats when the queue is exhausted.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* const mockPi = createMockPi();
|
|
11
|
+
* mockPi.install();
|
|
12
|
+
*
|
|
13
|
+
* mockPi.onCall({ output: "Hello from agent" });
|
|
14
|
+
* mockPi.onCall({ stderr: "crashed", exitCode: 1 });
|
|
15
|
+
*
|
|
16
|
+
* // ... test code that spawns pi ...
|
|
17
|
+
*
|
|
18
|
+
* mockPi.reset();
|
|
19
|
+
* mockPi.uninstall();
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as fs from "node:fs";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import type { MockPi, MockPiCall } from "./types.js";
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
|
|
31
|
+
/** Valid keys for MockPiCall — used for runtime validation. */
|
|
32
|
+
const VALID_MOCK_PI_CALL_KEYS = new Set([
|
|
33
|
+
"output",
|
|
34
|
+
"exitCode",
|
|
35
|
+
"stderr",
|
|
36
|
+
"delay",
|
|
37
|
+
"jsonl",
|
|
38
|
+
"writeFiles",
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the mock-pi-script.mjs path.
|
|
43
|
+
*
|
|
44
|
+
* During dev (vitest): src/mock-pi.ts → sibling src/mock-pi-script.mjs
|
|
45
|
+
* After build (dist): dist/mock-pi.js → ../src/mock-pi-script.mjs
|
|
46
|
+
*/
|
|
47
|
+
function findMockPiScript(): string {
|
|
48
|
+
// Direct sibling (running from src/ via vitest)
|
|
49
|
+
const sibling = path.join(__dirname, "mock-pi-script.mjs");
|
|
50
|
+
if (fs.existsSync(sibling)) return sibling;
|
|
51
|
+
|
|
52
|
+
// One level up to package root, then into src/ (running from dist/)
|
|
53
|
+
const fromDist = path.join(__dirname, "..", "src", "mock-pi-script.mjs");
|
|
54
|
+
if (fs.existsSync(fromDist)) return fromDist;
|
|
55
|
+
|
|
56
|
+
throw new Error(
|
|
57
|
+
"Could not find mock-pi-script.mjs. Searched:\n" +
|
|
58
|
+
` ${sibling}\n` +
|
|
59
|
+
` ${fromDist}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a mock pi CLI for testing extensions that spawn pi as a subprocess.
|
|
65
|
+
*
|
|
66
|
+
* **Concurrency constraint**: Designed for serial subprocess spawns within a single test.
|
|
67
|
+
* If your test spawns multiple pi processes concurrently, responses may be consumed
|
|
68
|
+
* out of order. Use separate `createMockPi()` instances for concurrent scenarios,
|
|
69
|
+
* or ensure your test logic doesn't depend on response ordering.
|
|
70
|
+
*/
|
|
71
|
+
export function createMockPi(): MockPi {
|
|
72
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-mock-"));
|
|
73
|
+
let originalPath: string | undefined;
|
|
74
|
+
let installed = false;
|
|
75
|
+
|
|
76
|
+
const queueFile = path.join(dir, "queue.json");
|
|
77
|
+
const counterFile = path.join(dir, "counter");
|
|
78
|
+
|
|
79
|
+
// Initialize empty queue
|
|
80
|
+
fs.writeFileSync(queueFile, "[]");
|
|
81
|
+
fs.writeFileSync(counterFile, "0");
|
|
82
|
+
|
|
83
|
+
const scriptPath = findMockPiScript();
|
|
84
|
+
const nodeExe = process.execPath;
|
|
85
|
+
|
|
86
|
+
// Safety net: restore PATH if process exits without uninstall()
|
|
87
|
+
const exitHandler = () => {
|
|
88
|
+
if (originalPath !== undefined) {
|
|
89
|
+
process.env.PATH = originalPath;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
dir,
|
|
95
|
+
|
|
96
|
+
install() {
|
|
97
|
+
if (installed) return;
|
|
98
|
+
originalPath = process.env.PATH;
|
|
99
|
+
|
|
100
|
+
if (process.platform === "win32") {
|
|
101
|
+
// Windows: .cmd batch file shim
|
|
102
|
+
const cmd = [
|
|
103
|
+
"@echo off",
|
|
104
|
+
`set "MOCK_PI_QUEUE_DIR=${dir}"`,
|
|
105
|
+
`"${nodeExe}" "${scriptPath}" %*`,
|
|
106
|
+
].join("\r\n") + "\r\n";
|
|
107
|
+
fs.writeFileSync(path.join(dir, "pi.cmd"), cmd);
|
|
108
|
+
} else {
|
|
109
|
+
// Linux/macOS: shell script shim
|
|
110
|
+
const sh = [
|
|
111
|
+
"#!/bin/sh",
|
|
112
|
+
`MOCK_PI_QUEUE_DIR="${dir}" exec "${nodeExe}" "${scriptPath}" "$@"`,
|
|
113
|
+
].join("\n") + "\n";
|
|
114
|
+
const piPath = path.join(dir, "pi");
|
|
115
|
+
fs.writeFileSync(piPath, sh);
|
|
116
|
+
fs.chmodSync(piPath, 0o755);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
process.env.PATH = `${dir}${path.delimiter}${originalPath}`;
|
|
120
|
+
process.on("exit", exitHandler);
|
|
121
|
+
installed = true;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
uninstall() {
|
|
125
|
+
if (!installed) return;
|
|
126
|
+
process.removeListener("exit", exitHandler);
|
|
127
|
+
if (originalPath !== undefined) {
|
|
128
|
+
process.env.PATH = originalPath;
|
|
129
|
+
originalPath = undefined;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
133
|
+
} catch {
|
|
134
|
+
// Best-effort cleanup
|
|
135
|
+
}
|
|
136
|
+
installed = false;
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
onCall(response: MockPiCall) {
|
|
140
|
+
// Validate keys to catch typos early
|
|
141
|
+
const unknown = Object.keys(response).filter(
|
|
142
|
+
(k) => !VALID_MOCK_PI_CALL_KEYS.has(k),
|
|
143
|
+
);
|
|
144
|
+
if (unknown.length > 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Unknown MockPiCall key(s): ${unknown.join(", ")}. ` +
|
|
147
|
+
`Valid keys: ${[...VALID_MOCK_PI_CALL_KEYS].join(", ")}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const queue: MockPiCall[] = JSON.parse(fs.readFileSync(queueFile, "utf-8"));
|
|
152
|
+
queue.push(response);
|
|
153
|
+
fs.writeFileSync(queueFile, JSON.stringify(queue));
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
reset() {
|
|
157
|
+
fs.writeFileSync(queueFile, "[]");
|
|
158
|
+
fs.writeFileSync(counterFile, "0");
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
callCount(): number {
|
|
162
|
+
try {
|
|
163
|
+
return parseInt(fs.readFileSync(counterFile, "utf-8").trim(), 10) || 0;
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|