maistro 1.0.390
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/LICENSE +15 -0
- package/README.md +107 -0
- package/dist/app.d.ts +247 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +4971 -0
- package/dist/app.js.map +1 -0
- package/dist/buildInfo.d.ts +5 -0
- package/dist/buildInfo.d.ts.map +1 -0
- package/dist/buildInfo.js +2 -0
- package/dist/buildInfo.js.map +1 -0
- package/dist/caffeinate.d.ts +72 -0
- package/dist/caffeinate.d.ts.map +1 -0
- package/dist/caffeinate.js +258 -0
- package/dist/caffeinate.js.map +1 -0
- package/dist/claudePath.d.ts +10 -0
- package/dist/claudePath.d.ts.map +1 -0
- package/dist/claudePath.js +34 -0
- package/dist/claudePath.js.map +1 -0
- package/dist/clipboard.d.ts +44 -0
- package/dist/clipboard.d.ts.map +1 -0
- package/dist/clipboard.js +442 -0
- package/dist/clipboard.js.map +1 -0
- package/dist/config.d.ts +211 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +933 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +50 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +81 -0
- package/dist/constants.js.map +1 -0
- package/dist/contextBuilder.d.ts +38 -0
- package/dist/contextBuilder.d.ts.map +1 -0
- package/dist/contextBuilder.js +113 -0
- package/dist/contextBuilder.js.map +1 -0
- package/dist/dependencyDetector.d.ts +57 -0
- package/dist/dependencyDetector.d.ts.map +1 -0
- package/dist/dependencyDetector.js +505 -0
- package/dist/dependencyDetector.js.map +1 -0
- package/dist/executor.d.ts +83 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +583 -0
- package/dist/executor.js.map +1 -0
- package/dist/git.d.ts +85 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +283 -0
- package/dist/git.js.map +1 -0
- package/dist/imageManager.d.ts +161 -0
- package/dist/imageManager.d.ts.map +1 -0
- package/dist/imageManager.js +674 -0
- package/dist/imageManager.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +437 -0
- package/dist/index.js.map +1 -0
- package/dist/input-visual-test.d.ts +9 -0
- package/dist/input-visual-test.d.ts.map +1 -0
- package/dist/input-visual-test.js +108 -0
- package/dist/input-visual-test.js.map +1 -0
- package/dist/inputBox.d.ts +228 -0
- package/dist/inputBox.d.ts.map +1 -0
- package/dist/inputBox.js +966 -0
- package/dist/inputBox.js.map +1 -0
- package/dist/logger.d.ts +136 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +347 -0
- package/dist/logger.js.map +1 -0
- package/dist/orchestrator.d.ts +149 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +821 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/planner.d.ts +86 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/planner.js +830 -0
- package/dist/planner.js.map +1 -0
- package/dist/pty-test-runner.d.ts +87 -0
- package/dist/pty-test-runner.d.ts.map +1 -0
- package/dist/pty-test-runner.js +721 -0
- package/dist/pty-test-runner.js.map +1 -0
- package/dist/screen.d.ts +44 -0
- package/dist/screen.d.ts.map +1 -0
- package/dist/screen.js +152 -0
- package/dist/screen.js.map +1 -0
- package/dist/taskQueue.d.ts +70 -0
- package/dist/taskQueue.d.ts.map +1 -0
- package/dist/taskQueue.js +282 -0
- package/dist/taskQueue.js.map +1 -0
- package/dist/tui-test-harness.d.ts +216 -0
- package/dist/tui-test-harness.d.ts.map +1 -0
- package/dist/tui-test-harness.js +527 -0
- package/dist/tui-test-harness.js.map +1 -0
- package/dist/types.d.ts +257 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +46 -0
- package/dist/types.js.map +1 -0
- package/dist/ui-visual-test.d.ts +15 -0
- package/dist/ui-visual-test.d.ts.map +1 -0
- package/dist/ui-visual-test.js +141 -0
- package/dist/ui-visual-test.js.map +1 -0
- package/dist/ui.d.ts +272 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +1531 -0
- package/dist/ui.js.map +1 -0
- package/dist/validator.d.ts +53 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +491 -0
- package/dist/validator.js.map +1 -0
- package/dist/versionCheck.d.ts +63 -0
- package/dist/versionCheck.d.ts.map +1 -0
- package/dist/versionCheck.js +261 -0
- package/dist/versionCheck.js.map +1 -0
- package/package.json +62 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY Test Runner - Real terminal testing framework
|
|
3
|
+
*
|
|
4
|
+
* Uses the `expect` command (built into macOS) to run maistro
|
|
5
|
+
* in a real pseudo-terminal and test actual terminal behavior.
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as os from 'os';
|
|
11
|
+
export class PTYTestRunner {
|
|
12
|
+
process = null;
|
|
13
|
+
output = '';
|
|
14
|
+
snapshots = new Map();
|
|
15
|
+
debug = false;
|
|
16
|
+
tmpScript = '';
|
|
17
|
+
tempDir;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.debug = options?.debug ?? false;
|
|
20
|
+
this.tempDir = options?.tempDir;
|
|
21
|
+
}
|
|
22
|
+
async start(command, args = [], options) {
|
|
23
|
+
const cols = options?.cols ?? 120;
|
|
24
|
+
const rows = options?.rows ?? 24;
|
|
25
|
+
const cwd = options?.cwd;
|
|
26
|
+
// Create a temp expect script
|
|
27
|
+
this.tmpScript = path.join(os.tmpdir(), `pty-expect-${Date.now()}.exp`);
|
|
28
|
+
// Build the full command
|
|
29
|
+
const fullCommand = [command, ...args].map(arg => {
|
|
30
|
+
// Escape special characters for Tcl
|
|
31
|
+
return `"${arg.replace(/"/g, '\\"')}"`;
|
|
32
|
+
}).join(' ');
|
|
33
|
+
// Create expect script that spawns the process and forwards stdin
|
|
34
|
+
const expectScript = `#!/usr/bin/expect -f
|
|
35
|
+
log_user 1
|
|
36
|
+
set timeout -1
|
|
37
|
+
set stty_init "rows ${rows} cols ${cols} -icrnl -inlcr -igncr"
|
|
38
|
+
spawn -noecho ${fullCommand}
|
|
39
|
+
|
|
40
|
+
# Read from stdin and send to the spawned process
|
|
41
|
+
fconfigure stdin -blocking 0 -buffering none -translation binary
|
|
42
|
+
|
|
43
|
+
proc read_stdin {} {
|
|
44
|
+
global spawn_id
|
|
45
|
+
set data [read stdin 1]
|
|
46
|
+
if {$data ne ""} {
|
|
47
|
+
send -- $data
|
|
48
|
+
}
|
|
49
|
+
after 10 read_stdin
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
proc check_eof {} {
|
|
53
|
+
if {[eof stdin]} {
|
|
54
|
+
exit
|
|
55
|
+
}
|
|
56
|
+
after 100 check_eof
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
read_stdin
|
|
60
|
+
check_eof
|
|
61
|
+
expect eof
|
|
62
|
+
wait
|
|
63
|
+
`;
|
|
64
|
+
fs.writeFileSync(this.tmpScript, expectScript);
|
|
65
|
+
fs.chmodSync(this.tmpScript, '755');
|
|
66
|
+
// Run the expect script
|
|
67
|
+
this.process = spawn('/usr/bin/expect', ['-f', this.tmpScript], {
|
|
68
|
+
env: {
|
|
69
|
+
...process.env,
|
|
70
|
+
TERM: 'xterm-256color',
|
|
71
|
+
LANG: 'en_US.UTF-8',
|
|
72
|
+
COLUMNS: String(cols),
|
|
73
|
+
LINES: String(rows),
|
|
74
|
+
FORCE_COLOR: '1',
|
|
75
|
+
MAISTRO_DEBUG: '',
|
|
76
|
+
MAISTRO_SKIP_DOCTOR: '1',
|
|
77
|
+
},
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
cwd,
|
|
80
|
+
});
|
|
81
|
+
this.output = '';
|
|
82
|
+
this.process.stdout?.on('data', (data) => {
|
|
83
|
+
const str = data.toString();
|
|
84
|
+
this.output += str;
|
|
85
|
+
if (this.debug) {
|
|
86
|
+
const visible = str
|
|
87
|
+
.replace(/\x1b/g, '\\x1b')
|
|
88
|
+
.replace(/\r/g, '\\r')
|
|
89
|
+
.replace(/\n/g, '\\n');
|
|
90
|
+
console.log(`[PTY STDOUT]: ${visible}`);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
this.process.stderr?.on('data', (data) => {
|
|
94
|
+
const str = data.toString();
|
|
95
|
+
// Expect sometimes outputs to stderr, capture it too
|
|
96
|
+
this.output += str;
|
|
97
|
+
if (this.debug) {
|
|
98
|
+
console.log(`[PTY STDERR]: ${str}`);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Wait for initial render
|
|
102
|
+
await this.wait(500);
|
|
103
|
+
}
|
|
104
|
+
async runSequence(sequence) {
|
|
105
|
+
const steps = this.parseSequence(sequence);
|
|
106
|
+
for (const step of steps) {
|
|
107
|
+
await this.executeStep(step);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
parseSequence(sequence) {
|
|
111
|
+
return sequence.split('\n')
|
|
112
|
+
.map(line => line.trim())
|
|
113
|
+
.filter(line => line && !line.startsWith('#'))
|
|
114
|
+
.map(line => {
|
|
115
|
+
const colonIndex = line.indexOf(':');
|
|
116
|
+
if (colonIndex === -1) {
|
|
117
|
+
throw new Error(`Invalid step format: "${line}" (missing colon)`);
|
|
118
|
+
}
|
|
119
|
+
const action = line.substring(0, colonIndex);
|
|
120
|
+
const rest = line.substring(colonIndex + 1);
|
|
121
|
+
if (action === 'assertCount') {
|
|
122
|
+
const lastColonIndex = rest.lastIndexOf(':');
|
|
123
|
+
if (lastColonIndex === -1) {
|
|
124
|
+
throw new Error(`assertCount requires format "assertCount:text:count", got: "${line}"`);
|
|
125
|
+
}
|
|
126
|
+
const text = rest.substring(0, lastColonIndex);
|
|
127
|
+
const count = parseInt(rest.substring(lastColonIndex + 1), 10);
|
|
128
|
+
if (isNaN(count)) {
|
|
129
|
+
throw new Error(`Invalid count in assertCount: "${line}"`);
|
|
130
|
+
}
|
|
131
|
+
return { action, value: text, count };
|
|
132
|
+
}
|
|
133
|
+
if (action === 'key') {
|
|
134
|
+
const parts = rest.split(':');
|
|
135
|
+
const keyName = parts[0];
|
|
136
|
+
const repeat = parts[1] ? parseInt(parts[1], 10) : 1;
|
|
137
|
+
return { action, value: keyName, count: repeat };
|
|
138
|
+
}
|
|
139
|
+
return { action, value: rest };
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async executeStep(step) {
|
|
143
|
+
if (this.debug) {
|
|
144
|
+
console.log(`[STEP]: ${step.action}:${step.value}${step.count !== undefined ? ':' + step.count : ''}`);
|
|
145
|
+
}
|
|
146
|
+
switch (step.action) {
|
|
147
|
+
case 'type':
|
|
148
|
+
for (const char of step.value) {
|
|
149
|
+
this.process?.stdin?.write(char);
|
|
150
|
+
await this.wait(10);
|
|
151
|
+
}
|
|
152
|
+
await this.wait(50);
|
|
153
|
+
break;
|
|
154
|
+
case 'paste':
|
|
155
|
+
// Write all text at once (simulates CTRL+V paste)
|
|
156
|
+
this.process?.stdin?.write(step.value);
|
|
157
|
+
await this.wait(100);
|
|
158
|
+
break;
|
|
159
|
+
case 'key':
|
|
160
|
+
const times = step.count ?? 1;
|
|
161
|
+
for (let i = 0; i < times; i++) {
|
|
162
|
+
this.process?.stdin?.write(this.keyToSequence(step.value));
|
|
163
|
+
await this.wait(30);
|
|
164
|
+
}
|
|
165
|
+
await this.wait(50);
|
|
166
|
+
break;
|
|
167
|
+
case 'wait':
|
|
168
|
+
await this.wait(parseInt(step.value, 10));
|
|
169
|
+
break;
|
|
170
|
+
case 'assert':
|
|
171
|
+
this.assertContains(step.value);
|
|
172
|
+
break;
|
|
173
|
+
case 'assertCount':
|
|
174
|
+
this.assertOccurrences(step.value, step.count);
|
|
175
|
+
break;
|
|
176
|
+
case 'assertNot':
|
|
177
|
+
this.assertNotContains(step.value);
|
|
178
|
+
break;
|
|
179
|
+
case 'snapshot':
|
|
180
|
+
this.snapshots.set(step.value, this.getCleanOutput());
|
|
181
|
+
break;
|
|
182
|
+
case 'debug':
|
|
183
|
+
console.log('=== DEBUG OUTPUT ===');
|
|
184
|
+
console.log('Raw output length:', this.output.length);
|
|
185
|
+
console.log('Clean output:');
|
|
186
|
+
console.log(this.getCleanOutput());
|
|
187
|
+
console.log('===================');
|
|
188
|
+
break;
|
|
189
|
+
case 'raw':
|
|
190
|
+
// Send raw bytes directly to stdin (for testing escape sequences)
|
|
191
|
+
this.process?.stdin?.write(step.value);
|
|
192
|
+
await this.wait(50);
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
throw new Error(`Unknown action: ${step.action}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
keyToSequence(key) {
|
|
199
|
+
const keys = {
|
|
200
|
+
Enter: '\r',
|
|
201
|
+
Escape: '\x1b',
|
|
202
|
+
Backspace: '\x7f',
|
|
203
|
+
Delete: '\x1b[3~',
|
|
204
|
+
Tab: '\t',
|
|
205
|
+
ArrowUp: '\x1b[A',
|
|
206
|
+
ArrowDown: '\x1b[B',
|
|
207
|
+
ArrowLeft: '\x1b[D',
|
|
208
|
+
ArrowRight: '\x1b[C',
|
|
209
|
+
Home: '\x1b[H',
|
|
210
|
+
End: '\x1b[F',
|
|
211
|
+
PageUp: '\x1b[5~',
|
|
212
|
+
PageDown: '\x1b[6~',
|
|
213
|
+
'Ctrl+C': '\x03',
|
|
214
|
+
'Ctrl+D': '\x04',
|
|
215
|
+
'Ctrl+Z': '\x1a',
|
|
216
|
+
'Ctrl+A': '\x01',
|
|
217
|
+
'Ctrl+E': '\x05',
|
|
218
|
+
'Ctrl+J': '\x0a', // LF - reliable cross-terminal newline
|
|
219
|
+
'Shift+Enter': '\x0a', // Same as Ctrl+J - works in terminals that send LF for Shift+Enter
|
|
220
|
+
'Shift+Up': '\x1b[1;2A', // Shift+Up for history navigation
|
|
221
|
+
'Shift+Down': '\x1b[1;2B', // Shift+Down for history navigation
|
|
222
|
+
};
|
|
223
|
+
const sequence = keys[key];
|
|
224
|
+
if (!sequence) {
|
|
225
|
+
if (key.length === 1) {
|
|
226
|
+
return key;
|
|
227
|
+
}
|
|
228
|
+
throw new Error(`Unknown key: ${key}`);
|
|
229
|
+
}
|
|
230
|
+
return sequence;
|
|
231
|
+
}
|
|
232
|
+
wait(ms) {
|
|
233
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Send raw bytes to stdin
|
|
237
|
+
*/
|
|
238
|
+
sendRaw(data) {
|
|
239
|
+
this.process?.stdin?.write(data);
|
|
240
|
+
}
|
|
241
|
+
getCleanOutput() {
|
|
242
|
+
// First strip ANSI escape codes
|
|
243
|
+
let clean = this.output
|
|
244
|
+
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '') // \x1b[?25l/h cursor hide/show (DEC private mode)
|
|
245
|
+
.replace(/\x1b\[<[0-9;]+[Mm]?/g, '') // SGR extended mouse sequences (\x1b[<button;x;yM/m) - terminator optional for partial
|
|
246
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // Standard CSI sequences
|
|
247
|
+
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '') // OSC sequences
|
|
248
|
+
.replace(/\x1b[78]/g, '') // DEC save/restore cursor
|
|
249
|
+
.replace(/\x1b[()][0-9A-Za-z]/g, '') // Character set selection
|
|
250
|
+
// Strip mouse sequence remnants (partial sequences without escape prefix)
|
|
251
|
+
.replace(/\[<\d+;\d+;\d*[Mm]?/g, '') // [<button;x;yM/m without \x1b (including partial)
|
|
252
|
+
.replace(/\[<\d+;\d*;?/g, '') // Even more partial like [<64;60
|
|
253
|
+
.replace(/\[</g, '') // Just [< prefix
|
|
254
|
+
// Strip bare mouse remnants: patterns like 64;60;15M, 0;21;29M0;21;29m, etc.
|
|
255
|
+
.replace(/\d+;\d+;\d+[Mm]/g, '') // bare remnants like 0;50;20M or 64;60;15M
|
|
256
|
+
.replace(/;\d+;\d+[Mm]?/g, ''); // partial like ;60;15M
|
|
257
|
+
// Process to simulate terminal behavior:
|
|
258
|
+
// - \r\n or \n = newline
|
|
259
|
+
// - \r followed by text = overwrite current line
|
|
260
|
+
// - \r at end of line = just cursor positioning, keep content
|
|
261
|
+
const lines = [];
|
|
262
|
+
let currentLine = '';
|
|
263
|
+
let cursorPos = 0;
|
|
264
|
+
for (let i = 0; i < clean.length; i++) {
|
|
265
|
+
const char = clean[i];
|
|
266
|
+
if (char === '\r') {
|
|
267
|
+
// Carriage return
|
|
268
|
+
if (clean[i + 1] === '\n') {
|
|
269
|
+
// CRLF - newline
|
|
270
|
+
lines.push(currentLine);
|
|
271
|
+
currentLine = '';
|
|
272
|
+
cursorPos = 0;
|
|
273
|
+
i++; // Skip the \n
|
|
274
|
+
}
|
|
275
|
+
else if (i + 1 < clean.length && clean[i + 1] !== '\r') {
|
|
276
|
+
// \r followed by printable text - reset cursor to start for overwrite
|
|
277
|
+
cursorPos = 0;
|
|
278
|
+
}
|
|
279
|
+
// else: \r at end or followed by another \r - ignore (just cursor positioning)
|
|
280
|
+
}
|
|
281
|
+
else if (char === '\n') {
|
|
282
|
+
lines.push(currentLine);
|
|
283
|
+
currentLine = '';
|
|
284
|
+
cursorPos = 0;
|
|
285
|
+
}
|
|
286
|
+
else if (char >= ' ') {
|
|
287
|
+
// Printable character - write at cursor position
|
|
288
|
+
if (cursorPos < currentLine.length) {
|
|
289
|
+
// Overwrite existing character
|
|
290
|
+
currentLine = currentLine.substring(0, cursorPos) + char + currentLine.substring(cursorPos + 1);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
// Append
|
|
294
|
+
currentLine += char;
|
|
295
|
+
}
|
|
296
|
+
cursorPos++;
|
|
297
|
+
}
|
|
298
|
+
// Ignore control characters except \r and \n
|
|
299
|
+
}
|
|
300
|
+
if (currentLine) {
|
|
301
|
+
lines.push(currentLine);
|
|
302
|
+
}
|
|
303
|
+
return lines.join('\n');
|
|
304
|
+
}
|
|
305
|
+
getRawOutput() {
|
|
306
|
+
return this.output;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Render output as it would appear on a real terminal screen.
|
|
310
|
+
* This simulates a fixed-size terminal with rows/cols, line wrapping,
|
|
311
|
+
* and scrolling behavior.
|
|
312
|
+
*/
|
|
313
|
+
renderScreen(cols = 80, rows = 24) {
|
|
314
|
+
// Initialize screen as array of rows
|
|
315
|
+
const screen = Array(rows).fill('').map(() => ' '.repeat(cols));
|
|
316
|
+
let cursorRow = 0;
|
|
317
|
+
let cursorCol = 0;
|
|
318
|
+
let savedCursorRow = 0;
|
|
319
|
+
let savedCursorCol = 0;
|
|
320
|
+
let scrollCount = 0; // Track how many times we've scrolled
|
|
321
|
+
// Strip sequences we don't need to process
|
|
322
|
+
let data = this.output
|
|
323
|
+
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '') // OSC sequences
|
|
324
|
+
.replace(/\x1b\[\?[0-9;]*[hl]/g, '') // DEC private mode (cursor show/hide)
|
|
325
|
+
.replace(/\x1b\[[0-9;]*m/g, '') // SGR (colors)
|
|
326
|
+
.replace(/\x1b[()][0-9A-Za-z]/g, ''); // Character set
|
|
327
|
+
let i = 0;
|
|
328
|
+
while (i < data.length) {
|
|
329
|
+
const char = data[i];
|
|
330
|
+
// Check for escape sequences
|
|
331
|
+
if (char === '\x1b') {
|
|
332
|
+
// DEC save cursor
|
|
333
|
+
if (data[i + 1] === '7') {
|
|
334
|
+
savedCursorRow = cursorRow;
|
|
335
|
+
savedCursorCol = cursorCol;
|
|
336
|
+
i += 2;
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
// DEC restore cursor
|
|
340
|
+
if (data[i + 1] === '8') {
|
|
341
|
+
cursorRow = savedCursorRow;
|
|
342
|
+
cursorCol = savedCursorCol;
|
|
343
|
+
i += 2;
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
// CSI sequence
|
|
347
|
+
if (data[i + 1] === '[') {
|
|
348
|
+
const match = data.slice(i).match(/^\x1b\[([0-9;]*)([a-zA-Z])/);
|
|
349
|
+
if (match) {
|
|
350
|
+
const params = match[1];
|
|
351
|
+
const cmd = match[2];
|
|
352
|
+
i += match[0].length;
|
|
353
|
+
switch (cmd) {
|
|
354
|
+
case 'H': // Cursor position
|
|
355
|
+
case 'f': {
|
|
356
|
+
const parts = params.split(';');
|
|
357
|
+
cursorRow = Math.max(0, (parseInt(parts[0] || '1', 10) - 1));
|
|
358
|
+
cursorCol = Math.max(0, (parseInt(parts[1] || '1', 10) - 1));
|
|
359
|
+
if (cursorRow >= rows)
|
|
360
|
+
cursorRow = rows - 1;
|
|
361
|
+
if (cursorCol >= cols)
|
|
362
|
+
cursorCol = cols - 1;
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
case 'A': // Cursor up
|
|
366
|
+
cursorRow = Math.max(0, cursorRow - (parseInt(params || '1', 10)));
|
|
367
|
+
break;
|
|
368
|
+
case 'B': // Cursor down
|
|
369
|
+
cursorRow = Math.min(rows - 1, cursorRow + (parseInt(params || '1', 10)));
|
|
370
|
+
break;
|
|
371
|
+
case 'C': // Cursor forward
|
|
372
|
+
cursorCol = Math.min(cols - 1, cursorCol + (parseInt(params || '1', 10)));
|
|
373
|
+
break;
|
|
374
|
+
case 'D': // Cursor back
|
|
375
|
+
cursorCol = Math.max(0, cursorCol - (parseInt(params || '1', 10)));
|
|
376
|
+
break;
|
|
377
|
+
case 'J': // Clear screen
|
|
378
|
+
// params '' or '0' = clear from cursor to end of screen
|
|
379
|
+
// params '1' = clear from start to cursor
|
|
380
|
+
// params '2' = clear entire screen
|
|
381
|
+
if (params === '' || params === '0') {
|
|
382
|
+
// Clear from cursor to end of current line
|
|
383
|
+
screen[cursorRow] = screen[cursorRow].substring(0, cursorCol) + ' '.repeat(cols - cursorCol);
|
|
384
|
+
// Clear all lines below cursor
|
|
385
|
+
for (let r = cursorRow + 1; r < rows; r++) {
|
|
386
|
+
screen[r] = ' '.repeat(cols);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else if (params === '2') {
|
|
390
|
+
for (let r = 0; r < rows; r++) {
|
|
391
|
+
screen[r] = ' '.repeat(cols);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
case 'K': // Clear line
|
|
396
|
+
if (params === '' || params === '0') {
|
|
397
|
+
// Clear from cursor to end of line
|
|
398
|
+
screen[cursorRow] = screen[cursorRow].substring(0, cursorCol) + ' '.repeat(cols - cursorCol);
|
|
399
|
+
}
|
|
400
|
+
else if (params === '1') {
|
|
401
|
+
// Clear from start of line to cursor
|
|
402
|
+
screen[cursorRow] = ' '.repeat(cursorCol) + screen[cursorRow].substring(cursorCol);
|
|
403
|
+
}
|
|
404
|
+
else if (params === '2') {
|
|
405
|
+
// Clear entire line
|
|
406
|
+
screen[cursorRow] = ' '.repeat(cols);
|
|
407
|
+
}
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Unknown escape, skip the escape char
|
|
414
|
+
i++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
// Carriage return
|
|
418
|
+
if (char === '\r') {
|
|
419
|
+
cursorCol = 0;
|
|
420
|
+
i++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
// Newline
|
|
424
|
+
if (char === '\n') {
|
|
425
|
+
cursorRow++;
|
|
426
|
+
if (cursorRow >= rows) {
|
|
427
|
+
// Scroll up
|
|
428
|
+
screen.shift();
|
|
429
|
+
screen.push(' '.repeat(cols));
|
|
430
|
+
cursorRow = rows - 1;
|
|
431
|
+
scrollCount++;
|
|
432
|
+
}
|
|
433
|
+
i++;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
// Printable character
|
|
437
|
+
if (char >= ' ') {
|
|
438
|
+
// Write character at cursor position
|
|
439
|
+
const row = screen[cursorRow];
|
|
440
|
+
screen[cursorRow] = row.substring(0, cursorCol) + char + row.substring(cursorCol + 1);
|
|
441
|
+
cursorCol++;
|
|
442
|
+
// Handle line wrap
|
|
443
|
+
if (cursorCol >= cols) {
|
|
444
|
+
cursorCol = 0;
|
|
445
|
+
cursorRow++;
|
|
446
|
+
if (cursorRow >= rows) {
|
|
447
|
+
// Scroll up
|
|
448
|
+
screen.shift();
|
|
449
|
+
screen.push(' '.repeat(cols));
|
|
450
|
+
cursorRow = rows - 1;
|
|
451
|
+
scrollCount++;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
i++;
|
|
456
|
+
}
|
|
457
|
+
return screen;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Render screen and return both screen content and scroll count
|
|
461
|
+
*/
|
|
462
|
+
renderScreenWithStats(cols = 80, rows = 24) {
|
|
463
|
+
// Initialize screen as array of rows
|
|
464
|
+
const screen = Array(rows).fill('').map(() => ' '.repeat(cols));
|
|
465
|
+
let cursorRow = 0;
|
|
466
|
+
let cursorCol = 0;
|
|
467
|
+
let savedCursorRow = 0;
|
|
468
|
+
let savedCursorCol = 0;
|
|
469
|
+
let scrollCount = 0;
|
|
470
|
+
// Strip sequences we don't need to process
|
|
471
|
+
let data = this.output
|
|
472
|
+
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '') // OSC sequences
|
|
473
|
+
.replace(/\x1b\[\?[0-9;]*[hl]/g, '') // DEC private mode (cursor show/hide)
|
|
474
|
+
.replace(/\x1b\[[0-9;]*m/g, '') // SGR (colors)
|
|
475
|
+
.replace(/\x1b[()][0-9A-Za-z]/g, ''); // Character set
|
|
476
|
+
let idx = 0;
|
|
477
|
+
while (idx < data.length) {
|
|
478
|
+
const char = data[idx];
|
|
479
|
+
if (char === '\x1b') {
|
|
480
|
+
if (data[idx + 1] === '7') {
|
|
481
|
+
savedCursorRow = cursorRow;
|
|
482
|
+
savedCursorCol = cursorCol;
|
|
483
|
+
idx += 2;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (data[idx + 1] === '8') {
|
|
487
|
+
cursorRow = savedCursorRow;
|
|
488
|
+
cursorCol = savedCursorCol;
|
|
489
|
+
idx += 2;
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
if (data[idx + 1] === '[') {
|
|
493
|
+
const match = data.slice(idx).match(/^\x1b\[([0-9;]*)([a-zA-Z])/);
|
|
494
|
+
if (match) {
|
|
495
|
+
const params = match[1];
|
|
496
|
+
const cmd = match[2];
|
|
497
|
+
idx += match[0].length;
|
|
498
|
+
switch (cmd) {
|
|
499
|
+
case 'H':
|
|
500
|
+
case 'f': {
|
|
501
|
+
const parts = params.split(';');
|
|
502
|
+
cursorRow = Math.max(0, (parseInt(parts[0] || '1', 10) - 1));
|
|
503
|
+
cursorCol = Math.max(0, (parseInt(parts[1] || '1', 10) - 1));
|
|
504
|
+
if (cursorRow >= rows)
|
|
505
|
+
cursorRow = rows - 1;
|
|
506
|
+
if (cursorCol >= cols)
|
|
507
|
+
cursorCol = cols - 1;
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
case 'A':
|
|
511
|
+
cursorRow = Math.max(0, cursorRow - (parseInt(params || '1', 10)));
|
|
512
|
+
break;
|
|
513
|
+
case 'B':
|
|
514
|
+
cursorRow = Math.min(rows - 1, cursorRow + (parseInt(params || '1', 10)));
|
|
515
|
+
break;
|
|
516
|
+
case 'C':
|
|
517
|
+
cursorCol = Math.min(cols - 1, cursorCol + (parseInt(params || '1', 10)));
|
|
518
|
+
break;
|
|
519
|
+
case 'D':
|
|
520
|
+
cursorCol = Math.max(0, cursorCol - (parseInt(params || '1', 10)));
|
|
521
|
+
break;
|
|
522
|
+
case 'J':
|
|
523
|
+
if (params === '' || params === '0') {
|
|
524
|
+
screen[cursorRow] = screen[cursorRow].substring(0, cursorCol) + ' '.repeat(cols - cursorCol);
|
|
525
|
+
for (let r = cursorRow + 1; r < rows; r++) {
|
|
526
|
+
screen[r] = ' '.repeat(cols);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
else if (params === '2') {
|
|
530
|
+
for (let r = 0; r < rows; r++) {
|
|
531
|
+
screen[r] = ' '.repeat(cols);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
break;
|
|
535
|
+
case 'K':
|
|
536
|
+
if (params === '' || params === '0') {
|
|
537
|
+
screen[cursorRow] = screen[cursorRow].substring(0, cursorCol) + ' '.repeat(cols - cursorCol);
|
|
538
|
+
}
|
|
539
|
+
else if (params === '1') {
|
|
540
|
+
screen[cursorRow] = ' '.repeat(cursorCol) + screen[cursorRow].substring(cursorCol);
|
|
541
|
+
}
|
|
542
|
+
else if (params === '2') {
|
|
543
|
+
screen[cursorRow] = ' '.repeat(cols);
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
idx++;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (char === '\r') {
|
|
554
|
+
cursorCol = 0;
|
|
555
|
+
idx++;
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (char === '\n') {
|
|
559
|
+
cursorRow++;
|
|
560
|
+
if (cursorRow >= rows) {
|
|
561
|
+
screen.shift();
|
|
562
|
+
screen.push(' '.repeat(cols));
|
|
563
|
+
cursorRow = rows - 1;
|
|
564
|
+
scrollCount++;
|
|
565
|
+
}
|
|
566
|
+
idx++;
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (char >= ' ') {
|
|
570
|
+
const row = screen[cursorRow];
|
|
571
|
+
screen[cursorRow] = row.substring(0, cursorCol) + char + row.substring(cursorCol + 1);
|
|
572
|
+
cursorCol++;
|
|
573
|
+
if (cursorCol >= cols) {
|
|
574
|
+
cursorCol = 0;
|
|
575
|
+
cursorRow++;
|
|
576
|
+
if (cursorRow >= rows) {
|
|
577
|
+
screen.shift();
|
|
578
|
+
screen.push(' '.repeat(cols));
|
|
579
|
+
cursorRow = rows - 1;
|
|
580
|
+
scrollCount++;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
idx++;
|
|
585
|
+
}
|
|
586
|
+
return { screen, scrollCount };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Get screen as string (rows joined by newlines, trailing spaces trimmed)
|
|
590
|
+
*/
|
|
591
|
+
getScreenOutput(cols = 80, rows = 24) {
|
|
592
|
+
const { screen } = this.renderScreenWithStats(cols, rows);
|
|
593
|
+
return screen.map(line => line.trimEnd()).join('\n');
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Get scroll count from last render
|
|
597
|
+
*/
|
|
598
|
+
getScrollCount(cols = 80, rows = 24) {
|
|
599
|
+
return this.renderScreenWithStats(cols, rows).scrollCount;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Count occurrences in rendered screen output
|
|
603
|
+
*/
|
|
604
|
+
countOccurrencesOnScreen(text, cols = 80, rows = 24) {
|
|
605
|
+
const screenOutput = this.getScreenOutput(cols, rows);
|
|
606
|
+
let count = 0;
|
|
607
|
+
let pos = 0;
|
|
608
|
+
while ((pos = screenOutput.indexOf(text, pos)) !== -1) {
|
|
609
|
+
count++;
|
|
610
|
+
pos += text.length;
|
|
611
|
+
}
|
|
612
|
+
return count;
|
|
613
|
+
}
|
|
614
|
+
getSnapshot(name) {
|
|
615
|
+
return this.snapshots.get(name);
|
|
616
|
+
}
|
|
617
|
+
countOccurrences(text) {
|
|
618
|
+
const clean = this.getCleanOutput();
|
|
619
|
+
let count = 0;
|
|
620
|
+
let pos = 0;
|
|
621
|
+
while ((pos = clean.indexOf(text, pos)) !== -1) {
|
|
622
|
+
count++;
|
|
623
|
+
pos += text.length;
|
|
624
|
+
}
|
|
625
|
+
return count;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Count occurrences in output with ANSI stripped but carriage returns kept.
|
|
629
|
+
* This can detect "visual bugs" where the terminal SHOWS duplicate text
|
|
630
|
+
* even though proper CR processing would overwrite it.
|
|
631
|
+
*/
|
|
632
|
+
countOccurrencesRaw(text) {
|
|
633
|
+
// Strip ANSI codes but keep \r and \n
|
|
634
|
+
const stripped = this.output
|
|
635
|
+
.replace(/\x1b\[\?[0-9;]*[a-zA-Z]/g, '')
|
|
636
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '')
|
|
637
|
+
.replace(/\x1b\][^\x07]*(\x07|\x1b\\)/g, '')
|
|
638
|
+
.replace(/\x1b[78]/g, '')
|
|
639
|
+
.replace(/\x1b[()][0-9A-Za-z]/g, '');
|
|
640
|
+
let count = 0;
|
|
641
|
+
let pos = 0;
|
|
642
|
+
while ((pos = stripped.indexOf(text, pos)) !== -1) {
|
|
643
|
+
count++;
|
|
644
|
+
pos += text.length;
|
|
645
|
+
}
|
|
646
|
+
return count;
|
|
647
|
+
}
|
|
648
|
+
assertContains(text) {
|
|
649
|
+
const clean = this.getCleanOutput();
|
|
650
|
+
if (!clean.includes(text)) {
|
|
651
|
+
throw new Error(`Expected terminal to contain "${text}"\n` +
|
|
652
|
+
`Actual output (${clean.length} chars):\n` +
|
|
653
|
+
`---\n${clean}\n---`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
assertNotContains(text) {
|
|
657
|
+
const clean = this.getCleanOutput();
|
|
658
|
+
if (clean.includes(text)) {
|
|
659
|
+
throw new Error(`Expected terminal NOT to contain "${text}"\n` +
|
|
660
|
+
`Actual output:\n---\n${clean}\n---`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
assertOccurrences(text, expected) {
|
|
664
|
+
const actual = this.countOccurrences(text);
|
|
665
|
+
if (actual !== expected) {
|
|
666
|
+
throw new Error(`Expected "${text}" to appear ${expected} time(s), but found ${actual}\n` +
|
|
667
|
+
`Output:\n---\n${this.getCleanOutput()}\n---`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
clearOutput() {
|
|
671
|
+
this.output = '';
|
|
672
|
+
}
|
|
673
|
+
async stop() {
|
|
674
|
+
if (this.process) {
|
|
675
|
+
// Send Ctrl+C to gracefully exit
|
|
676
|
+
this.process.stdin?.write('\x03');
|
|
677
|
+
await this.wait(100);
|
|
678
|
+
this.process.stdin?.end();
|
|
679
|
+
this.process.kill();
|
|
680
|
+
this.process = null;
|
|
681
|
+
}
|
|
682
|
+
// Clean up temp script
|
|
683
|
+
if (this.tmpScript && fs.existsSync(this.tmpScript)) {
|
|
684
|
+
try {
|
|
685
|
+
fs.unlinkSync(this.tmpScript);
|
|
686
|
+
}
|
|
687
|
+
catch {
|
|
688
|
+
// Ignore cleanup errors
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// Clean up temp directory if created
|
|
692
|
+
if (this.tempDir && fs.existsSync(this.tempDir)) {
|
|
693
|
+
try {
|
|
694
|
+
fs.rmSync(this.tempDir, { recursive: true, force: true });
|
|
695
|
+
}
|
|
696
|
+
catch {
|
|
697
|
+
// Ignore cleanup errors
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Helper to create a test runner with common setup
|
|
704
|
+
*/
|
|
705
|
+
export async function createMaistroTestRunner(options) {
|
|
706
|
+
let cwd;
|
|
707
|
+
if (options?.useTempDir) {
|
|
708
|
+
// Create a temp directory for clean testing
|
|
709
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'maistro-test-'));
|
|
710
|
+
}
|
|
711
|
+
const runner = new PTYTestRunner({ debug: options?.debug, tempDir: cwd });
|
|
712
|
+
// Use absolute path to dist/index.js since we may be in a different cwd
|
|
713
|
+
const distPath = path.resolve(process.cwd(), 'dist/index.js');
|
|
714
|
+
await runner.start(process.execPath, [distPath], {
|
|
715
|
+
cols: options?.cols ?? 120,
|
|
716
|
+
rows: options?.rows ?? 24,
|
|
717
|
+
cwd,
|
|
718
|
+
});
|
|
719
|
+
return runner;
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=pty-test-runner.js.map
|