pi-herdr-subagents 0.1.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/.github/workflows/publish.yml +55 -0
- package/.pi/settings.json +8 -0
- package/.pi/skills/run-integration-tests/SKILL.md +28 -0
- package/LICENSE +21 -0
- package/README.md +483 -0
- package/RELEASING.md +103 -0
- package/agents/planner.md +546 -0
- package/agents/reviewer.md +150 -0
- package/agents/scout.md +104 -0
- package/agents/visual-tester.md +197 -0
- package/agents/worker.md +103 -0
- package/config.json.example +5 -0
- package/package.json +34 -0
- package/pi-extension/subagents/activity.ts +511 -0
- package/pi-extension/subagents/completion.ts +114 -0
- package/pi-extension/subagents/herdr.ts +200 -0
- package/pi-extension/subagents/index.ts +2182 -0
- package/pi-extension/subagents/plan-skill.md +203 -0
- package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
- package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
- package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
- package/pi-extension/subagents/session.ts +180 -0
- package/pi-extension/subagents/status.ts +513 -0
- package/pi-extension/subagents/subagent-done.ts +324 -0
- package/pi-extension/subagents/terminal.ts +106 -0
- package/test/integration/agents/test-echo.md +13 -0
- package/test/integration/agents/test-ping.md +11 -0
- package/test/integration/harness.ts +319 -0
- package/test/integration/mux-surface.test.ts +225 -0
- package/test/integration/subagent-lifecycle.test.ts +329 -0
- package/test/system-prompt-mode.test.ts +163 -0
- package/test/test.ts +2190 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration test harness for pi-herdr-subagents.
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities to:
|
|
5
|
+
* - Detect whether herdr is available
|
|
6
|
+
* - Create isolated test environments with test agent definitions
|
|
7
|
+
* - Start real pi sessions in herdr panes
|
|
8
|
+
* - Poll for file creation and screen output
|
|
9
|
+
* - Clean up surfaces and temp files after tests
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
mkdtempSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
cpSync,
|
|
16
|
+
readdirSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
existsSync,
|
|
19
|
+
readFileSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from "node:fs";
|
|
22
|
+
import { join, resolve, dirname } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import {
|
|
26
|
+
isTerminalAvailable,
|
|
27
|
+
createSubagentPane,
|
|
28
|
+
runInPane,
|
|
29
|
+
runScriptInPane,
|
|
30
|
+
readPane,
|
|
31
|
+
readPaneAsync,
|
|
32
|
+
closePane,
|
|
33
|
+
interruptPane,
|
|
34
|
+
shellQuote,
|
|
35
|
+
} from "../../pi-extension/subagents/terminal.ts";
|
|
36
|
+
|
|
37
|
+
type MuxBackend = "herdr";
|
|
38
|
+
|
|
39
|
+
// Re-export mux primitives for tests
|
|
40
|
+
export {
|
|
41
|
+
createSubagentPane,
|
|
42
|
+
runInPane,
|
|
43
|
+
runScriptInPane,
|
|
44
|
+
readPane,
|
|
45
|
+
readPaneAsync,
|
|
46
|
+
closePane,
|
|
47
|
+
interruptPane,
|
|
48
|
+
shellQuote,
|
|
49
|
+
};
|
|
50
|
+
export type { MuxBackend };
|
|
51
|
+
|
|
52
|
+
// ── Paths ──
|
|
53
|
+
|
|
54
|
+
const HARNESS_DIR = dirname(fileURLToPath(import.meta.url));
|
|
55
|
+
const PROJECT_ROOT = resolve(HARNESS_DIR, "../..");
|
|
56
|
+
const TEST_AGENTS_SRC = join(HARNESS_DIR, "agents");
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Absolute path to the extension source in the working tree.
|
|
60
|
+
*
|
|
61
|
+
* Integration tests must exercise the code on the current branch — NOT the
|
|
62
|
+
* version installed as a pi-package under `~/.pi/agent/git/...` or the project
|
|
63
|
+
* mirror under `.pi/git/...`, which stays pinned to the last released tag.
|
|
64
|
+
*
|
|
65
|
+
* We force-load this file via `pi -ne -e <path>` in startPi() below so local
|
|
66
|
+
* edits are always the code under test, regardless of what pi-packages are
|
|
67
|
+
* installed on the host.
|
|
68
|
+
*/
|
|
69
|
+
const EXTENSION_SOURCE = join(PROJECT_ROOT, "pi-extension", "subagents", "index.ts");
|
|
70
|
+
|
|
71
|
+
// ── Configuration ──
|
|
72
|
+
|
|
73
|
+
/** Model used for integration tests. Override with PI_TEST_MODEL env var. */
|
|
74
|
+
export const TEST_MODEL = process.env.PI_TEST_MODEL ?? "openrouter/free";
|
|
75
|
+
|
|
76
|
+
/** Per-test timeout in ms. Override with PI_TEST_TIMEOUT env var. */
|
|
77
|
+
export const PI_TIMEOUT = Number(process.env.PI_TEST_TIMEOUT ?? "120000");
|
|
78
|
+
|
|
79
|
+
// ── Backend detection ──
|
|
80
|
+
|
|
81
|
+
/** Detect whether the required herdr backend is available. */
|
|
82
|
+
export function getAvailableBackends(): MuxBackend[] {
|
|
83
|
+
return isTerminalAvailable() ? ["herdr"] : [];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function setBackend(_backend: MuxBackend): undefined {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function restoreBackend(_prev: string | undefined): void {}
|
|
91
|
+
|
|
92
|
+
export function focusSurface(_backend: MuxBackend, surface: string): void {
|
|
93
|
+
// Focus the tab containing the pane — herdr has no direct "focus pane X"
|
|
94
|
+
// CLI, but focusing the tab brings it to the foreground.
|
|
95
|
+
const info = execFileSync("herdr", ["pane", "get", surface], { encoding: "utf8" });
|
|
96
|
+
const tabId = JSON.parse(info)?.result?.pane?.tab_id;
|
|
97
|
+
if (tabId) execFileSync("herdr", ["tab", "focus", tabId], { encoding: "utf8" });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getFocusedSurface(_backend: MuxBackend): string | null {
|
|
101
|
+
try {
|
|
102
|
+
const info = execFileSync("herdr", ["pane", "current"], { encoding: "utf8" });
|
|
103
|
+
return JSON.parse(info)?.result?.pane?.pane_id ?? null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getSurfacePane(_backend: MuxBackend, surface: string): string | null {
|
|
110
|
+
return surface;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function waitForFocusedSurface(
|
|
114
|
+
backend: MuxBackend,
|
|
115
|
+
surface: string,
|
|
116
|
+
timeout: number = PI_TIMEOUT,
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const start = Date.now();
|
|
119
|
+
while (Date.now() - start < timeout) {
|
|
120
|
+
if (getFocusedSurface(backend) === surface) return;
|
|
121
|
+
await sleep(200);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Timeout (${timeout}ms) waiting for focused ${backend} surface ${surface}; ` +
|
|
126
|
+
`current focus is ${getFocusedSurface(backend) ?? "unknown"}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Test environment ──
|
|
131
|
+
|
|
132
|
+
export interface TestEnv {
|
|
133
|
+
/** Temp directory serving as the test project root */
|
|
134
|
+
dir: string;
|
|
135
|
+
/** Active mux backend for this test run */
|
|
136
|
+
backend: MuxBackend;
|
|
137
|
+
/** Surfaces created during the test (cleaned up automatically) */
|
|
138
|
+
surfaces: string[];
|
|
139
|
+
/** Temp files to clean up */
|
|
140
|
+
tempFiles: string[];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Create an isolated test environment with test agent definitions.
|
|
145
|
+
* The temp dir has `.pi/agents/` containing copies of all test agents.
|
|
146
|
+
*/
|
|
147
|
+
export function createTestEnv(backend: MuxBackend): TestEnv {
|
|
148
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-integ-"));
|
|
149
|
+
const agentsDir = join(dir, ".pi", "agents");
|
|
150
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
151
|
+
|
|
152
|
+
// Copy test agent definitions into the project-local agents dir
|
|
153
|
+
if (existsSync(TEST_AGENTS_SRC)) {
|
|
154
|
+
for (const file of readdirSync(TEST_AGENTS_SRC)) {
|
|
155
|
+
if (file.endsWith(".md")) {
|
|
156
|
+
cpSync(join(TEST_AGENTS_SRC, file), join(agentsDir, file));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { dir, backend, surfaces: [], tempFiles: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Clean up all resources created during the test.
|
|
166
|
+
*/
|
|
167
|
+
export function cleanupTestEnv(env: TestEnv): void {
|
|
168
|
+
for (const surface of env.surfaces) {
|
|
169
|
+
try {
|
|
170
|
+
closePane(surface);
|
|
171
|
+
} catch {}
|
|
172
|
+
}
|
|
173
|
+
for (const file of env.tempFiles) {
|
|
174
|
+
try {
|
|
175
|
+
unlinkSync(file);
|
|
176
|
+
} catch {}
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
rmSync(env.dir, { recursive: true, force: true });
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Create a surface and register it for automatic cleanup.
|
|
185
|
+
*/
|
|
186
|
+
export function createTrackedSurface(env: TestEnv, name: string): string {
|
|
187
|
+
const surface = createSubagentPane(name);
|
|
188
|
+
env.surfaces.push(surface);
|
|
189
|
+
return surface;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Remove a surface from tracking (after manual close).
|
|
194
|
+
*/
|
|
195
|
+
export function untrackSurface(env: TestEnv, surface: string): void {
|
|
196
|
+
env.surfaces = env.surfaces.filter((s) => s !== surface);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── Pi session management ──
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Start a pi session in a herdr pane with the subagents extension loaded.
|
|
203
|
+
* Returns immediately — the pi process runs asynchronously in the surface.
|
|
204
|
+
*
|
|
205
|
+
* The command ends with a sentinel so we can detect when pi exits:
|
|
206
|
+
* `pi ...; echo '__TEST_DONE_'$?'__'`
|
|
207
|
+
*/
|
|
208
|
+
export function startPi(
|
|
209
|
+
surface: string,
|
|
210
|
+
testDir: string,
|
|
211
|
+
task: string,
|
|
212
|
+
opts?: { model?: string; extraArgs?: string },
|
|
213
|
+
): void {
|
|
214
|
+
const model = opts?.model ?? TEST_MODEL;
|
|
215
|
+
const extra = opts?.extraArgs ?? "";
|
|
216
|
+
|
|
217
|
+
// Force pi to load the working-tree extension (not an installed pi-package
|
|
218
|
+
// snapshot). `-ne` disables extension auto-discovery, `-e <path>` loads the
|
|
219
|
+
// current branch's source directly. Without this, the tests silently run
|
|
220
|
+
// against whatever version is checked out under `~/.pi/agent/git/...`.
|
|
221
|
+
const cmd = [
|
|
222
|
+
`cd ${shellQuote(testDir)} &&`,
|
|
223
|
+
`pi`,
|
|
224
|
+
`-ne`,
|
|
225
|
+
`-e ${shellQuote(EXTENSION_SOURCE)}`,
|
|
226
|
+
`--model ${shellQuote(model)}`,
|
|
227
|
+
extra,
|
|
228
|
+
shellQuote(task),
|
|
229
|
+
]
|
|
230
|
+
.filter(Boolean)
|
|
231
|
+
.join(" ");
|
|
232
|
+
|
|
233
|
+
runScriptInPane(surface, `${cmd}; echo '__TEST_DONE_'$?'__'`, {
|
|
234
|
+
scriptPath: join(testDir, `test-launch-${Date.now()}.sh`),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Polling helpers ──
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Poll until a regex pattern appears in the surface's screen output.
|
|
242
|
+
* Throws on timeout with the last screen contents for debugging.
|
|
243
|
+
*/
|
|
244
|
+
export async function waitForScreen(
|
|
245
|
+
surface: string,
|
|
246
|
+
pattern: RegExp,
|
|
247
|
+
timeout: number = PI_TIMEOUT,
|
|
248
|
+
lines: number = 200,
|
|
249
|
+
): Promise<string> {
|
|
250
|
+
const start = Date.now();
|
|
251
|
+
while (Date.now() - start < timeout) {
|
|
252
|
+
try {
|
|
253
|
+
const screen = await readPaneAsync(surface, lines);
|
|
254
|
+
if (pattern.test(screen)) return screen;
|
|
255
|
+
} catch {}
|
|
256
|
+
await sleep(2000);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let finalScreen = "";
|
|
260
|
+
try {
|
|
261
|
+
finalScreen = readPane(surface, lines);
|
|
262
|
+
} catch {}
|
|
263
|
+
throw new Error(
|
|
264
|
+
`Timeout (${timeout}ms) waiting for pattern ${pattern}.\nLast screen:\n${finalScreen.slice(-1000)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Poll until a file exists and optionally matches a content pattern.
|
|
270
|
+
* Returns the file content on success.
|
|
271
|
+
*/
|
|
272
|
+
export async function waitForFile(
|
|
273
|
+
path: string,
|
|
274
|
+
timeout: number = PI_TIMEOUT,
|
|
275
|
+
contentPattern?: RegExp,
|
|
276
|
+
): Promise<string> {
|
|
277
|
+
const start = Date.now();
|
|
278
|
+
while (Date.now() - start < timeout) {
|
|
279
|
+
if (existsSync(path)) {
|
|
280
|
+
const content = readFileSync(path, "utf8");
|
|
281
|
+
if (!contentPattern || contentPattern.test(content)) return content;
|
|
282
|
+
}
|
|
283
|
+
await sleep(2000);
|
|
284
|
+
}
|
|
285
|
+
throw new Error(
|
|
286
|
+
`Timeout (${timeout}ms) waiting for file: ${path}` +
|
|
287
|
+
(contentPattern ? ` matching ${contentPattern}` : ""),
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Wait for the pi process in a surface to exit (sentinel detection).
|
|
293
|
+
* Returns the exit code.
|
|
294
|
+
*/
|
|
295
|
+
export async function waitForPiExit(
|
|
296
|
+
surface: string,
|
|
297
|
+
timeout: number = PI_TIMEOUT,
|
|
298
|
+
): Promise<number> {
|
|
299
|
+
const screen = await waitForScreen(surface, /__TEST_DONE_(\d+)__/, timeout);
|
|
300
|
+
const match = screen.match(/__TEST_DONE_(\d+)__/);
|
|
301
|
+
return match ? parseInt(match[1], 10) : -1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── Utilities ──
|
|
305
|
+
|
|
306
|
+
export function sleep(ms: number): Promise<void> {
|
|
307
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function uniqueId(): string {
|
|
311
|
+
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Register a temp file for cleanup.
|
|
316
|
+
*/
|
|
317
|
+
export function trackTempFile(env: TestEnv, path: string): void {
|
|
318
|
+
env.tempFiles.push(path);
|
|
319
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for herdr terminal operations.
|
|
3
|
+
*
|
|
4
|
+
* These tests exercise real herdr operations: creating panes,
|
|
5
|
+
* sending commands, reading output, preserving focus, and closing panes.
|
|
6
|
+
* No LLM calls — fast and free.
|
|
7
|
+
*
|
|
8
|
+
* Run `npm run test:integration` from inside herdr.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, before, after } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { unlinkSync } from "node:fs";
|
|
13
|
+
import {
|
|
14
|
+
getAvailableBackends,
|
|
15
|
+
setBackend,
|
|
16
|
+
restoreBackend,
|
|
17
|
+
createTestEnv,
|
|
18
|
+
cleanupTestEnv,
|
|
19
|
+
createTrackedSurface,
|
|
20
|
+
getFocusedSurface,
|
|
21
|
+
untrackSurface,
|
|
22
|
+
runInPane,
|
|
23
|
+
runScriptInPane,
|
|
24
|
+
readPane,
|
|
25
|
+
readPaneAsync,
|
|
26
|
+
closePane,
|
|
27
|
+
interruptPane,
|
|
28
|
+
sleep,
|
|
29
|
+
uniqueId,
|
|
30
|
+
trackTempFile,
|
|
31
|
+
waitForFile,
|
|
32
|
+
waitForScreen,
|
|
33
|
+
type TestEnv,
|
|
34
|
+
} from "./harness.ts";
|
|
35
|
+
|
|
36
|
+
const backends = getAvailableBackends();
|
|
37
|
+
const FOCUS_TEST_SHELL_READY_DELAY_MS = Number(process.env.PI_SUBAGENT_SHELL_READY_DELAY_MS ?? "2500");
|
|
38
|
+
|
|
39
|
+
if (backends.length === 0) {
|
|
40
|
+
console.log("⚠️ herdr is unavailable — skipping terminal integration tests");
|
|
41
|
+
console.log(" Run inside herdr to enable these tests.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const backend of backends) {
|
|
45
|
+
describe(`herdr terminal [${backend}]`, { timeout: 60_000 }, () => {
|
|
46
|
+
let prevMux: string | undefined;
|
|
47
|
+
let env: TestEnv;
|
|
48
|
+
|
|
49
|
+
before(() => {
|
|
50
|
+
prevMux = setBackend(backend);
|
|
51
|
+
env = createTestEnv(backend);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
after(() => {
|
|
55
|
+
cleanupTestEnv(env);
|
|
56
|
+
restoreBackend(prevMux);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("keeps focus on the current pane while creating and targeting subagent tabs", async () => {
|
|
60
|
+
const focusedPane = getFocusedSurface(backend);
|
|
61
|
+
assert.ok(focusedPane, "Expected herdr to report the currently focused pane");
|
|
62
|
+
|
|
63
|
+
const childA = createTrackedSurface(env, "focus-child-a");
|
|
64
|
+
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
|
65
|
+
assert.equal(getFocusedSurface(backend), focusedPane);
|
|
66
|
+
|
|
67
|
+
const childB = createTrackedSurface(env, "focus-child-b");
|
|
68
|
+
await sleep(FOCUS_TEST_SHELL_READY_DELAY_MS);
|
|
69
|
+
assert.equal(getFocusedSurface(backend), focusedPane);
|
|
70
|
+
|
|
71
|
+
const markerA = uniqueId();
|
|
72
|
+
const markerB = uniqueId();
|
|
73
|
+
runInPane(childA, `echo "FOCUS_A_${markerA}"`);
|
|
74
|
+
runInPane(childB, `echo "FOCUS_B_${markerB}"`);
|
|
75
|
+
|
|
76
|
+
await Promise.all([
|
|
77
|
+
waitForScreen(childA, new RegExp(`FOCUS_A_${markerA}`), 20_000, 50),
|
|
78
|
+
waitForScreen(childB, new RegExp(`FOCUS_B_${markerB}`), 20_000, 50),
|
|
79
|
+
]);
|
|
80
|
+
assert.equal(getFocusedSurface(backend), focusedPane);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("creates a surface, sends a command, reads output, and closes it", async () => {
|
|
84
|
+
const surface = createTrackedSurface(env, "echo-test");
|
|
85
|
+
await sleep(1000);
|
|
86
|
+
|
|
87
|
+
const marker = uniqueId();
|
|
88
|
+
runInPane(surface, `echo "MARKER_${marker}"`);
|
|
89
|
+
await sleep(1500);
|
|
90
|
+
|
|
91
|
+
const screen = readPane(surface, 50);
|
|
92
|
+
assert.ok(
|
|
93
|
+
screen.includes(`MARKER_${marker}`),
|
|
94
|
+
`Expected screen to contain MARKER_${marker}. Got:\n${screen}`,
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
closePane(surface);
|
|
98
|
+
untrackSurface(env, surface);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("preserves shell special characters in echo output", async () => {
|
|
102
|
+
const surface = createTrackedSurface(env, "escape-test");
|
|
103
|
+
await sleep(1000);
|
|
104
|
+
|
|
105
|
+
const marker = uniqueId();
|
|
106
|
+
// Single-quoted string — $ and " are literal inside single quotes
|
|
107
|
+
runInPane(surface, `echo 'SPEC_${marker}_$HOME_"quotes"_done'`);
|
|
108
|
+
await sleep(1500);
|
|
109
|
+
|
|
110
|
+
const screen = readPane(surface, 50);
|
|
111
|
+
assert.ok(
|
|
112
|
+
screen.includes(`SPEC_${marker}`),
|
|
113
|
+
`Expected special-char output. Got:\n${screen}`,
|
|
114
|
+
);
|
|
115
|
+
// $ should be literal inside single quotes
|
|
116
|
+
assert.ok(
|
|
117
|
+
screen.includes("$HOME"),
|
|
118
|
+
`Expected literal $HOME in output. Got:\n${screen}`,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("sends a long command via script file without truncation", async () => {
|
|
123
|
+
const surface = createTrackedSurface(env, "long-cmd-test");
|
|
124
|
+
await sleep(1000);
|
|
125
|
+
|
|
126
|
+
const marker = uniqueId();
|
|
127
|
+
const longValue = "X".repeat(500);
|
|
128
|
+
const command = `echo "LONG_${marker}_${longValue}_END"`;
|
|
129
|
+
|
|
130
|
+
runScriptInPane(surface, command);
|
|
131
|
+
await sleep(2000);
|
|
132
|
+
|
|
133
|
+
const screen = readPane(surface, 50);
|
|
134
|
+
assert.ok(
|
|
135
|
+
screen.includes(`LONG_${marker}`),
|
|
136
|
+
`Expected long command output. Got:\n${screen.slice(0, 300)}...`,
|
|
137
|
+
);
|
|
138
|
+
assert.ok(
|
|
139
|
+
screen.includes("_END"),
|
|
140
|
+
`Expected full output (not truncated). Got:\n${screen.slice(-300)}`,
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("reads screen asynchronously", async () => {
|
|
145
|
+
const surface = createTrackedSurface(env, "async-read-test");
|
|
146
|
+
await sleep(1000);
|
|
147
|
+
|
|
148
|
+
const marker = uniqueId();
|
|
149
|
+
runInPane(surface, `echo "ASYNC_${marker}"`);
|
|
150
|
+
await sleep(1500);
|
|
151
|
+
|
|
152
|
+
const screen = await readPaneAsync(surface, 50);
|
|
153
|
+
assert.ok(
|
|
154
|
+
screen.includes(`ASYNC_${marker}`),
|
|
155
|
+
`Async read should find marker. Got:\n${screen}`,
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("manages multiple surfaces concurrently", async () => {
|
|
160
|
+
const s1 = createTrackedSurface(env, "multi-1");
|
|
161
|
+
const s2 = createTrackedSurface(env, "multi-2");
|
|
162
|
+
await sleep(1500);
|
|
163
|
+
|
|
164
|
+
const m1 = uniqueId();
|
|
165
|
+
const m2 = uniqueId();
|
|
166
|
+
runInPane(s1, `echo "S1_${m1}"`);
|
|
167
|
+
runInPane(s2, `echo "S2_${m2}"`);
|
|
168
|
+
await sleep(1500);
|
|
169
|
+
|
|
170
|
+
const screen1 = readPane(s1, 50);
|
|
171
|
+
const screen2 = readPane(s2, 50);
|
|
172
|
+
|
|
173
|
+
assert.ok(screen1.includes(`S1_${m1}`), `Surface 1 missing marker. Got:\n${screen1}`);
|
|
174
|
+
assert.ok(screen2.includes(`S2_${m2}`), `Surface 2 missing marker. Got:\n${screen2}`);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("writes output to a file and verifies via surface", async () => {
|
|
178
|
+
const surface = createTrackedSurface(env, "file-test");
|
|
179
|
+
await sleep(1000);
|
|
180
|
+
|
|
181
|
+
const marker = uniqueId();
|
|
182
|
+
const filePath = `/tmp/pi-mux-test-${marker}.txt`;
|
|
183
|
+
|
|
184
|
+
runInPane(surface, `echo "FILE_${marker}" > ${filePath} && echo "WRITTEN_${marker}"`);
|
|
185
|
+
|
|
186
|
+
await waitForScreen(surface, new RegExp(`WRITTEN_${marker}`), 10_000, 50);
|
|
187
|
+
const content = await waitForFile(filePath, 10_000, new RegExp(`FILE_${marker}`));
|
|
188
|
+
assert.ok(content.includes(`FILE_${marker}`), `File content wrong. Got: ${content}`);
|
|
189
|
+
|
|
190
|
+
// Clean up
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(filePath);
|
|
193
|
+
} catch {}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("delivers Escape as byte 27 to the target surface", async () => {
|
|
197
|
+
const surface = createTrackedSurface(env, "escape-byte-test");
|
|
198
|
+
await sleep(1000);
|
|
199
|
+
|
|
200
|
+
const marker = uniqueId();
|
|
201
|
+
const byteFile = `/tmp/pi-mux-escape-${marker}.txt`;
|
|
202
|
+
trackTempFile(env, byteFile);
|
|
203
|
+
|
|
204
|
+
const nodeProgram =
|
|
205
|
+
"const fs = require('node:fs');" +
|
|
206
|
+
"if (!process.stdin.isTTY) throw new Error('stdin is not a TTY');" +
|
|
207
|
+
"process.stdin.setRawMode(true);" +
|
|
208
|
+
"process.stdin.resume();" +
|
|
209
|
+
"process.stdout.write('ESC_READY\\n');" +
|
|
210
|
+
"process.stdin.once('data', (chunk) => {" +
|
|
211
|
+
`fs.writeFileSync(${JSON.stringify(byteFile)}, Array.from(chunk).join(','));` +
|
|
212
|
+
"process.exit(0);" +
|
|
213
|
+
"});";
|
|
214
|
+
const command = `node -e ${JSON.stringify(nodeProgram)}`;
|
|
215
|
+
|
|
216
|
+
runScriptInPane(surface, command);
|
|
217
|
+
await waitForScreen(surface, /ESC_READY/, 15_000, 50);
|
|
218
|
+
|
|
219
|
+
interruptPane(surface);
|
|
220
|
+
|
|
221
|
+
const content = await waitForFile(byteFile, 15_000, /^27$/);
|
|
222
|
+
assert.equal(content.trim(), "27");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|