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.
Files changed (111) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +107 -0
  3. package/dist/app.d.ts +247 -0
  4. package/dist/app.d.ts.map +1 -0
  5. package/dist/app.js +4971 -0
  6. package/dist/app.js.map +1 -0
  7. package/dist/buildInfo.d.ts +5 -0
  8. package/dist/buildInfo.d.ts.map +1 -0
  9. package/dist/buildInfo.js +2 -0
  10. package/dist/buildInfo.js.map +1 -0
  11. package/dist/caffeinate.d.ts +72 -0
  12. package/dist/caffeinate.d.ts.map +1 -0
  13. package/dist/caffeinate.js +258 -0
  14. package/dist/caffeinate.js.map +1 -0
  15. package/dist/claudePath.d.ts +10 -0
  16. package/dist/claudePath.d.ts.map +1 -0
  17. package/dist/claudePath.js +34 -0
  18. package/dist/claudePath.js.map +1 -0
  19. package/dist/clipboard.d.ts +44 -0
  20. package/dist/clipboard.d.ts.map +1 -0
  21. package/dist/clipboard.js +442 -0
  22. package/dist/clipboard.js.map +1 -0
  23. package/dist/config.d.ts +211 -0
  24. package/dist/config.d.ts.map +1 -0
  25. package/dist/config.js +933 -0
  26. package/dist/config.js.map +1 -0
  27. package/dist/constants.d.ts +50 -0
  28. package/dist/constants.d.ts.map +1 -0
  29. package/dist/constants.js +81 -0
  30. package/dist/constants.js.map +1 -0
  31. package/dist/contextBuilder.d.ts +38 -0
  32. package/dist/contextBuilder.d.ts.map +1 -0
  33. package/dist/contextBuilder.js +113 -0
  34. package/dist/contextBuilder.js.map +1 -0
  35. package/dist/dependencyDetector.d.ts +57 -0
  36. package/dist/dependencyDetector.d.ts.map +1 -0
  37. package/dist/dependencyDetector.js +505 -0
  38. package/dist/dependencyDetector.js.map +1 -0
  39. package/dist/executor.d.ts +83 -0
  40. package/dist/executor.d.ts.map +1 -0
  41. package/dist/executor.js +583 -0
  42. package/dist/executor.js.map +1 -0
  43. package/dist/git.d.ts +85 -0
  44. package/dist/git.d.ts.map +1 -0
  45. package/dist/git.js +283 -0
  46. package/dist/git.js.map +1 -0
  47. package/dist/imageManager.d.ts +161 -0
  48. package/dist/imageManager.d.ts.map +1 -0
  49. package/dist/imageManager.js +674 -0
  50. package/dist/imageManager.js.map +1 -0
  51. package/dist/index.d.ts +3 -0
  52. package/dist/index.d.ts.map +1 -0
  53. package/dist/index.js +437 -0
  54. package/dist/index.js.map +1 -0
  55. package/dist/input-visual-test.d.ts +9 -0
  56. package/dist/input-visual-test.d.ts.map +1 -0
  57. package/dist/input-visual-test.js +108 -0
  58. package/dist/input-visual-test.js.map +1 -0
  59. package/dist/inputBox.d.ts +228 -0
  60. package/dist/inputBox.d.ts.map +1 -0
  61. package/dist/inputBox.js +966 -0
  62. package/dist/inputBox.js.map +1 -0
  63. package/dist/logger.d.ts +136 -0
  64. package/dist/logger.d.ts.map +1 -0
  65. package/dist/logger.js +347 -0
  66. package/dist/logger.js.map +1 -0
  67. package/dist/orchestrator.d.ts +149 -0
  68. package/dist/orchestrator.d.ts.map +1 -0
  69. package/dist/orchestrator.js +821 -0
  70. package/dist/orchestrator.js.map +1 -0
  71. package/dist/planner.d.ts +86 -0
  72. package/dist/planner.d.ts.map +1 -0
  73. package/dist/planner.js +830 -0
  74. package/dist/planner.js.map +1 -0
  75. package/dist/pty-test-runner.d.ts +87 -0
  76. package/dist/pty-test-runner.d.ts.map +1 -0
  77. package/dist/pty-test-runner.js +721 -0
  78. package/dist/pty-test-runner.js.map +1 -0
  79. package/dist/screen.d.ts +44 -0
  80. package/dist/screen.d.ts.map +1 -0
  81. package/dist/screen.js +152 -0
  82. package/dist/screen.js.map +1 -0
  83. package/dist/taskQueue.d.ts +70 -0
  84. package/dist/taskQueue.d.ts.map +1 -0
  85. package/dist/taskQueue.js +282 -0
  86. package/dist/taskQueue.js.map +1 -0
  87. package/dist/tui-test-harness.d.ts +216 -0
  88. package/dist/tui-test-harness.d.ts.map +1 -0
  89. package/dist/tui-test-harness.js +527 -0
  90. package/dist/tui-test-harness.js.map +1 -0
  91. package/dist/types.d.ts +257 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +46 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/ui-visual-test.d.ts +15 -0
  96. package/dist/ui-visual-test.d.ts.map +1 -0
  97. package/dist/ui-visual-test.js +141 -0
  98. package/dist/ui-visual-test.js.map +1 -0
  99. package/dist/ui.d.ts +272 -0
  100. package/dist/ui.d.ts.map +1 -0
  101. package/dist/ui.js +1531 -0
  102. package/dist/ui.js.map +1 -0
  103. package/dist/validator.d.ts +53 -0
  104. package/dist/validator.d.ts.map +1 -0
  105. package/dist/validator.js +491 -0
  106. package/dist/validator.js.map +1 -0
  107. package/dist/versionCheck.d.ts +63 -0
  108. package/dist/versionCheck.d.ts.map +1 -0
  109. package/dist/versionCheck.js +261 -0
  110. package/dist/versionCheck.js.map +1 -0
  111. 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