playwright-repl 0.2.1 → 0.7.10

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/dist/repl.js ADDED
@@ -0,0 +1,686 @@
1
+ /**
2
+ * Main REPL loop.
3
+ *
4
+ * Handles readline, command queue, meta-commands, and session management.
5
+ */
6
+ import readline from 'node:readline';
7
+ import path from 'node:path';
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import { replVersion, parseInput, ALIASES, ALL_COMMANDS, buildCompletionItems, c, buildRunCode, verifyText, verifyElement, verifyValue, verifyList, verifyTitle, verifyUrl, verifyNoText, verifyNoElement, actionByText, fillByText, selectByText, checkByText, uncheckByText, Engine, } from '@playwright-repl/core';
11
+ import { SessionManager } from './recorder.js';
12
+ // ─── Response filtering ─────────────────────────────────────────────────────
13
+ export function filterResponse(text, cmdName) {
14
+ const sections = text.split(/^### /m).slice(1);
15
+ const kept = [];
16
+ for (const section of sections) {
17
+ const newline = section.indexOf('\n');
18
+ if (newline === -1)
19
+ continue;
20
+ const title = section.substring(0, newline).trim();
21
+ const content = section.substring(newline + 1).trim();
22
+ if (title === 'Error')
23
+ kept.push(`${c.red}${content}${c.reset}`);
24
+ else if (title === 'Snapshot' && cmdName !== 'snapshot')
25
+ continue;
26
+ else if (title === 'Result' || title === 'Modal state' || title === 'Page' || title === 'Snapshot')
27
+ kept.push(content);
28
+ }
29
+ return kept.length > 0 ? kept.join('\n') : null;
30
+ }
31
+ // ─── Meta-command handlers ──────────────────────────────────────────────────
32
+ export function showHelp() {
33
+ console.log(`\n${c.bold}Available commands:${c.reset}`);
34
+ const categories = {
35
+ 'Navigation': ['open', 'goto', 'go-back', 'go-forward', 'reload'],
36
+ 'Interaction': ['click', 'dblclick', 'fill', 'type', 'press', 'hover', 'select', 'check', 'uncheck', 'drag'],
37
+ 'Inspection': ['snapshot', 'screenshot', 'eval', 'console', 'network', 'run-code'],
38
+ 'Tabs': ['tab-list', 'tab-new', 'tab-close', 'tab-select'],
39
+ 'Storage': ['cookie-list', 'cookie-get', 'localstorage-list', 'localstorage-get', 'state-save', 'state-load'],
40
+ };
41
+ for (const [cat, cmds] of Object.entries(categories)) {
42
+ console.log(` ${c.bold}${cat}:${c.reset} ${cmds.join(', ')}`);
43
+ }
44
+ console.log(`\n ${c.dim}Use .aliases for shortcuts, or type any command with --help${c.reset}`);
45
+ console.log(`\n${c.bold}REPL meta-commands:${c.reset}`);
46
+ console.log(` .aliases Show command aliases`);
47
+ console.log(` .status Show connection status`);
48
+ console.log(` .reconnect Restart browser`);
49
+ console.log(` .record [filename] Start recording commands`);
50
+ console.log(` .save Stop recording and save`);
51
+ console.log(` .pause Pause/resume recording`);
52
+ console.log(` .discard Discard recording`);
53
+ console.log(` .replay <filename> Replay a recorded session`);
54
+ console.log(` .clear Clear terminal output`);
55
+ console.log(` .history Show command history`);
56
+ console.log(` .history clear Clear command history`);
57
+ console.log(` .exit Exit REPL\n`);
58
+ }
59
+ export function showAliases() {
60
+ console.log(`\n${c.bold}Command aliases:${c.reset}`);
61
+ const groups = {};
62
+ for (const [alias, cmd] of Object.entries(ALIASES)) {
63
+ if (!groups[cmd])
64
+ groups[cmd] = [];
65
+ groups[cmd].push(alias);
66
+ }
67
+ for (const [cmd, aliases] of Object.entries(groups).sort()) {
68
+ console.log(` ${c.cyan}${aliases.join(', ')}${c.reset} → ${cmd}`);
69
+ }
70
+ console.log();
71
+ }
72
+ export function showStatus(ctx) {
73
+ const { conn, session } = ctx;
74
+ console.log(`Connected: ${conn.connected ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
75
+ console.log(`Commands sent: ${ctx.commandCount}`);
76
+ console.log(`Mode: ${session.mode}`);
77
+ if (session.mode === 'recording' || session.mode === 'paused') {
78
+ console.log(`Recording: ${c.red}⏺${c.reset} ${session.recordingFilename} (${session.recordedCount} commands${session.mode === 'paused' ? ', paused' : ''})`);
79
+ }
80
+ }
81
+ // ─── Session-level commands ─────────────────────────────────────────────────
82
+ export async function handleKillAll(ctx) {
83
+ try {
84
+ await ctx.conn.close();
85
+ console.log(`${c.green}✓${c.reset} Browser closed`);
86
+ }
87
+ catch (err) {
88
+ console.error(`${c.red}Error:${c.reset} ${err.message}`);
89
+ }
90
+ }
91
+ export async function handleClose(ctx) {
92
+ try {
93
+ await ctx.conn.close();
94
+ console.log(`${c.green}✓${c.reset} Browser closed`);
95
+ }
96
+ catch (err) {
97
+ console.error(`${c.red}Error:${c.reset} ${err.message}`);
98
+ }
99
+ }
100
+ // ─── Session meta-commands (.record, .save, .pause, .discard, .replay) ──────
101
+ export function handleSessionCommand(ctx, line) {
102
+ const { session } = ctx;
103
+ if (line.startsWith('.record')) {
104
+ const filename = line.split(/\s+/)[1] || undefined;
105
+ const file = session.startRecording(filename);
106
+ console.log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
107
+ ctx.rl.setPrompt(promptStr(ctx));
108
+ return true;
109
+ }
110
+ if (line === '.save') {
111
+ const { filename, count } = session.save();
112
+ console.log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
113
+ ctx.rl.setPrompt(promptStr(ctx));
114
+ return true;
115
+ }
116
+ if (line === '.pause') {
117
+ const paused = session.togglePause();
118
+ console.log(paused ? `${c.yellow}⏸${c.reset} Recording paused` : `${c.red}⏺${c.reset} Recording resumed`);
119
+ return true;
120
+ }
121
+ if (line === '.discard') {
122
+ session.discard();
123
+ console.log(`${c.yellow}Recording discarded${c.reset}`);
124
+ ctx.rl.setPrompt(promptStr(ctx));
125
+ return true;
126
+ }
127
+ return false;
128
+ }
129
+ // ─── Process a single line ──────────────────────────────────────────────────
130
+ export async function processLine(ctx, line) {
131
+ line = line.trim();
132
+ if (!line)
133
+ return;
134
+ // ── Meta-commands ────────────────────────────────────────────────
135
+ if (line === '.help' || line === '?')
136
+ return showHelp();
137
+ if (line === '.aliases')
138
+ return showAliases();
139
+ if (line === '.status')
140
+ return showStatus(ctx);
141
+ if (line === '.clear') {
142
+ console.clear();
143
+ ctx.rl.prompt();
144
+ return;
145
+ }
146
+ if (line === '.history clear') {
147
+ ctx.sessionHistory.length = 0;
148
+ console.log('History cleared.');
149
+ return;
150
+ }
151
+ if (line === '.history') {
152
+ const hist = ctx.sessionHistory;
153
+ console.log(hist.length ? hist.join('\n') : '(no history)');
154
+ return;
155
+ }
156
+ if (line === '.exit' || line === '.quit') {
157
+ ctx.conn.close();
158
+ process.exit(0);
159
+ }
160
+ if (line === '.reconnect') {
161
+ await ctx.conn.close();
162
+ try {
163
+ await ctx.conn.start(ctx.opts);
164
+ console.log(`${c.green}✓${c.reset} Reconnected`);
165
+ }
166
+ catch (err) {
167
+ console.error(`${c.red}✗${c.reset} ${err.message}`);
168
+ }
169
+ return;
170
+ }
171
+ // ── Session commands (record/save/pause/discard) ────────────────
172
+ if (line.startsWith('.')) {
173
+ try {
174
+ if (handleSessionCommand(ctx, line))
175
+ return;
176
+ }
177
+ catch (err) {
178
+ console.log(`${c.yellow}${err.message}${c.reset}`);
179
+ return;
180
+ }
181
+ }
182
+ // ── Inline replay ──────────────────────────────────────────────
183
+ if (line.startsWith('.replay')) {
184
+ const filename = line.split(/\s+/)[1];
185
+ if (!filename) {
186
+ console.log(`${c.yellow}Usage: .replay <filename>${c.reset}`);
187
+ return;
188
+ }
189
+ try {
190
+ const player = ctx.session.startReplay(filename);
191
+ console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${filename}${c.reset} (${player.commands.length} commands)\n`);
192
+ while (!player.done) {
193
+ const cmd = player.next();
194
+ console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
195
+ await processLine(ctx, cmd);
196
+ }
197
+ ctx.session.endReplay();
198
+ console.log(`\n${c.green}✓${c.reset} Replay complete`);
199
+ }
200
+ catch (err) {
201
+ console.error(`${c.red}Error:${c.reset} ${err.message}`);
202
+ ctx.session.endReplay();
203
+ }
204
+ return;
205
+ }
206
+ // ── Regular command — parse and send ─────────────────────────────
207
+ let args = parseInput(line);
208
+ if (!args)
209
+ return;
210
+ const cmdName = args._[0];
211
+ if (!cmdName)
212
+ return;
213
+ // Validate command exists
214
+ const knownExtras = ['help', 'highlight', 'list', 'close-all', 'kill-all', 'install', 'install-browser',
215
+ 'verify', 'verify-text', 'verify-element', 'verify-value', 'verify-list',
216
+ 'verify-title', 'verify-url', 'verify-no-text', 'verify-no-element'];
217
+ if (!ALL_COMMANDS.includes(cmdName) && !knownExtras.includes(cmdName)) {
218
+ console.log(`${c.yellow}Unknown command: ${cmdName}${c.reset}`);
219
+ console.log(`${c.dim}Type .help for available commands${c.reset}`);
220
+ return;
221
+ }
222
+ // ── Session-level commands (not forwarded to daemon) ──────────
223
+ if (cmdName === 'kill-all')
224
+ return handleKillAll(ctx);
225
+ if (cmdName === 'close' || cmdName === 'close-all')
226
+ return handleClose(ctx);
227
+ if (cmdName === 'verify') {
228
+ const subType = args._[1];
229
+ const rest = args._.slice(2);
230
+ let translated = null;
231
+ if (subType === 'title' && rest.length > 0)
232
+ translated = buildRunCode(verifyTitle, rest.join(' '));
233
+ else if (subType === 'url' && rest.length > 0)
234
+ translated = buildRunCode(verifyUrl, rest.join(' '));
235
+ else if (subType === 'text' && rest.length > 0)
236
+ translated = buildRunCode(verifyText, rest.join(' '));
237
+ else if (subType === 'no-text' && rest.length > 0)
238
+ translated = buildRunCode(verifyNoText, rest.join(' '));
239
+ else if (subType === 'element' && rest.length >= 2)
240
+ translated = buildRunCode(verifyElement, rest[0], rest.slice(1).join(' '));
241
+ else if (subType === 'no-element' && rest.length >= 2)
242
+ translated = buildRunCode(verifyNoElement, rest[0], rest.slice(1).join(' '));
243
+ else if (subType === 'value' && rest.length >= 2)
244
+ translated = buildRunCode(verifyValue, rest[0], rest.slice(1).join(' '));
245
+ else if (subType === 'list' && rest.length >= 2)
246
+ translated = buildRunCode(verifyList, rest[0], rest.slice(1));
247
+ if (translated) {
248
+ args = translated;
249
+ }
250
+ else {
251
+ console.log(`${c.yellow}Usage: verify <title|url|text|no-text|element|no-element|value|list> <args>${c.reset}`);
252
+ return;
253
+ }
254
+ }
255
+ // ── Legacy verify-* commands (backward compat) ─────────────
256
+ const verifyFns = {
257
+ 'verify-text': verifyText,
258
+ 'verify-element': verifyElement,
259
+ 'verify-value': verifyValue,
260
+ 'verify-list': verifyList,
261
+ 'verify-title': verifyTitle,
262
+ 'verify-url': verifyUrl,
263
+ 'verify-no-text': verifyNoText,
264
+ 'verify-no-element': verifyNoElement,
265
+ };
266
+ if (verifyFns[cmdName]) {
267
+ const pos = args._.slice(1);
268
+ const fn = verifyFns[cmdName];
269
+ let translated = null;
270
+ if (cmdName === 'verify-text' || cmdName === 'verify-no-text' || cmdName === 'verify-title' || cmdName === 'verify-url') {
271
+ const text = pos.join(' ');
272
+ if (text)
273
+ translated = buildRunCode(fn, text);
274
+ }
275
+ else if (cmdName === 'verify-no-element' || cmdName === 'verify-element') {
276
+ if (pos[0] && pos.length >= 2)
277
+ translated = buildRunCode(fn, pos[0], pos.slice(1).join(' '));
278
+ }
279
+ else if (pos[0] && pos.length >= 2) {
280
+ const rest = cmdName === 'verify-list' ? pos.slice(1) : pos.slice(1).join(' ');
281
+ translated = buildRunCode(fn, pos[0], rest);
282
+ }
283
+ if (translated) {
284
+ args = translated;
285
+ }
286
+ else {
287
+ console.log(`${c.yellow}Usage: ${cmdName} <args>${c.reset}`);
288
+ return;
289
+ }
290
+ }
291
+ // ── Auto-resolve text to native Playwright locator ─────────
292
+ const textFns = {
293
+ click: actionByText, dblclick: actionByText, hover: actionByText,
294
+ fill: fillByText, select: selectByText, check: checkByText, uncheck: uncheckByText,
295
+ };
296
+ if (textFns[cmdName] && args._[1] && !/^e\d+$/.test(args._[1]) && !args._.some(a => a.includes('>>'))) {
297
+ const textArg = args._[1];
298
+ const extraArgs = args._.slice(2);
299
+ const fn = textFns[cmdName];
300
+ const nth = args.nth !== undefined ? parseInt(String(args.nth), 10) : undefined;
301
+ let runCodeArgs;
302
+ if (fn === actionByText)
303
+ runCodeArgs = buildRunCode(fn, textArg, cmdName, nth);
304
+ else if (cmdName === 'fill' || cmdName === 'select')
305
+ runCodeArgs = buildRunCode(fn, textArg, extraArgs[0] || '', nth);
306
+ else
307
+ runCodeArgs = buildRunCode(fn, textArg, nth);
308
+ const argsHint = extraArgs.length > 0 ? ` ${extraArgs.join(' ')}` : '';
309
+ const nthHint = nth !== undefined ? ` --nth ${nth}` : '';
310
+ ctx.log(`${c.dim}→ ${cmdName} "${textArg}"${argsHint}${nthHint} (via run-code)${c.reset}`);
311
+ args = runCodeArgs;
312
+ }
313
+ // ── Auto-wrap run-code body with async (page) => { ... } ──
314
+ if (cmdName === 'run-code' && args._[1] && !args._[1].startsWith('async')) {
315
+ const STMT = /^(await|return|const|let|var|for|if|while|throw|try)\b/;
316
+ const body = !args._[1].includes(';') && !STMT.test(args._[1])
317
+ ? `return await ${args._[1]}`
318
+ : args._[1];
319
+ args = { _: ['run-code', `async (page) => { ${body} }`] };
320
+ ctx.log(`${c.dim}→ ${args._[1]}${c.reset}`);
321
+ }
322
+ const startTime = performance.now();
323
+ try {
324
+ const result = await ctx.conn.run(args);
325
+ const elapsed = (performance.now() - startTime).toFixed(0);
326
+ if (result?.text) {
327
+ const filtered = filterResponse(result.text, cmdName);
328
+ if (filtered !== null)
329
+ console.log(filtered);
330
+ }
331
+ if (result?.isError)
332
+ ctx.errors++;
333
+ ctx.commandCount++;
334
+ ctx.session.record(line);
335
+ if (Number(elapsed) > 500) {
336
+ ctx.log(`${c.dim}(${elapsed}ms)${c.reset}`);
337
+ }
338
+ }
339
+ catch (err) {
340
+ ctx.errors++;
341
+ console.error(`${c.red}Error:${c.reset} ${err.message}`);
342
+ if (!ctx.conn.connected) {
343
+ console.log(`${c.yellow}Browser disconnected. Trying to restart...${c.reset}`);
344
+ try {
345
+ await ctx.conn.start(ctx.opts);
346
+ console.log(`${c.green}✓${c.reset} Restarted. Try your command again.`);
347
+ }
348
+ catch {
349
+ console.error(`${c.red}✗${c.reset} Could not restart. Use .reconnect or restart the REPL.`);
350
+ }
351
+ }
352
+ }
353
+ }
354
+ // ─── Resolve replay targets (files and folders → .pw file list) ──────────────
355
+ export function resolveReplayFiles(targets) {
356
+ const files = [];
357
+ for (const target of targets) {
358
+ if (fs.statSync(target).isDirectory()) {
359
+ const entries = fs.readdirSync(target)
360
+ .filter(f => f.endsWith('.pw'))
361
+ .sort()
362
+ .map(f => path.join(target, f));
363
+ files.push(...entries);
364
+ }
365
+ else {
366
+ files.push(target);
367
+ }
368
+ }
369
+ return files;
370
+ }
371
+ // ─── Replay mode (non-interactive, --replay flag) ───────────────────────────
372
+ export async function runReplayMode(ctx, replayFile, step) {
373
+ try {
374
+ const player = ctx.session.startReplay(replayFile, step);
375
+ console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${replayFile}${c.reset} (${player.commands.length} commands)\n`);
376
+ while (!player.done) {
377
+ const cmd = player.next();
378
+ console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
379
+ await processLine(ctx, cmd);
380
+ if (ctx.session.step && !player.done) {
381
+ await new Promise((resolve) => {
382
+ process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
383
+ process.stdin.once('data', () => {
384
+ process.stdout.write('\r\x1b[K');
385
+ resolve();
386
+ });
387
+ });
388
+ }
389
+ }
390
+ ctx.session.endReplay();
391
+ console.log(`\n${c.green}✓${c.reset} Replay complete`);
392
+ ctx.conn.close();
393
+ process.exit(0);
394
+ }
395
+ catch (err) {
396
+ console.error(`${c.red}Error:${c.reset} ${err.message}`);
397
+ ctx.conn.close();
398
+ process.exit(1);
399
+ }
400
+ }
401
+ export async function runMultiReplayMode(ctx, targets, step) {
402
+ const files = resolveReplayFiles(targets);
403
+ if (files.length === 0) {
404
+ console.error(`${c.red}Error:${c.reset} No .pw files found`);
405
+ ctx.conn.close();
406
+ process.exit(1);
407
+ }
408
+ // Single file → delegate to existing replay mode
409
+ if (files.length === 1) {
410
+ return runReplayMode(ctx, files[0], step);
411
+ }
412
+ const logFile = `replay-${new Date().toISOString().replace(/[:.]/g, '-')}.log`;
413
+ const logLines = [];
414
+ const log = (line) => logLines.push(line);
415
+ console.log(`${c.blue}▶${c.reset} Running ${c.bold}${files.length}${c.reset} files\n`);
416
+ log(`Replay started ${new Date().toISOString()}`);
417
+ log(`Files: ${files.length}\n`);
418
+ const results = [];
419
+ for (const file of files) {
420
+ const basename = path.basename(file);
421
+ log(`=== ${basename} ===`);
422
+ console.log(`${c.blue}▶${c.reset} ${c.bold}${basename}${c.reset}`);
423
+ let passed = true;
424
+ let commandsRun = 0;
425
+ let errorMsg;
426
+ try {
427
+ const player = ctx.session.startReplay(file, step);
428
+ const total = player.commands.length;
429
+ while (!player.done) {
430
+ const cmd = player.next();
431
+ commandsRun++;
432
+ const errsBefore = ctx.errors;
433
+ log(`[${commandsRun}/${total}] ${cmd}`);
434
+ console.log(` ${c.dim}[${commandsRun}/${total}]${c.reset} ${cmd}`);
435
+ await processLine(ctx, cmd);
436
+ if (ctx.errors > errsBefore) {
437
+ passed = false;
438
+ errorMsg = `failed at command ${commandsRun}/${total}: ${cmd}`;
439
+ log(` FAIL`);
440
+ break;
441
+ }
442
+ log(` OK`);
443
+ }
444
+ ctx.session.endReplay();
445
+ }
446
+ catch (err) {
447
+ passed = false;
448
+ errorMsg = err.message;
449
+ log(` FAIL: ${errorMsg}`);
450
+ try {
451
+ ctx.session.endReplay();
452
+ }
453
+ catch { /* ignore */ }
454
+ }
455
+ const status = passed ? `${c.green}PASS${c.reset}` : `${c.red}FAIL${c.reset}`;
456
+ log(passed ? `PASS ${basename} (${commandsRun} commands)` : `FAIL ${basename} (${errorMsg})`);
457
+ console.log(` ${status} ${basename}\n`);
458
+ log('');
459
+ results.push({ file: basename, passed, commands: commandsRun, error: errorMsg });
460
+ }
461
+ // Summary
462
+ const passCount = results.filter(r => r.passed).length;
463
+ const failCount = results.filter(r => !r.passed).length;
464
+ log(`=== Summary ===`);
465
+ console.log(`${c.bold}─── Results ───${c.reset}`);
466
+ for (const r of results) {
467
+ const icon = r.passed ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
468
+ const suffix = r.error ? ` — ${r.error}` : '';
469
+ console.log(` ${icon} ${r.file}${suffix}`);
470
+ log(`${r.passed ? 'PASS' : 'FAIL'} ${r.file}${r.error ? ` — ${r.error}` : ''}`);
471
+ }
472
+ const summary = `\n${passCount} passed, ${failCount} failed (${results.length} total)`;
473
+ console.log(summary);
474
+ log(summary);
475
+ // Write log file
476
+ fs.writeFileSync(logFile, logLines.join('\n') + '\n', 'utf-8');
477
+ console.log(`${c.dim}Log: ${logFile}${c.reset}`);
478
+ ctx.conn.close();
479
+ process.exit(failCount > 0 ? 1 : 0);
480
+ }
481
+ // ─── Command loop (interactive) ─────────────────────────────────────────────
482
+ export function startCommandLoop(ctx) {
483
+ let processing = false;
484
+ const commandQueue = [];
485
+ async function processQueue() {
486
+ if (processing)
487
+ return;
488
+ processing = true;
489
+ while (commandQueue.length > 0) {
490
+ const line = commandQueue.shift();
491
+ await processLine(ctx, line);
492
+ if (line.trim()) {
493
+ ctx.sessionHistory.push(line.trim());
494
+ try {
495
+ fs.mkdirSync(path.dirname(ctx.historyFile), { recursive: true });
496
+ fs.appendFileSync(ctx.historyFile, line.trim() + '\n');
497
+ }
498
+ catch { /* ignore */ }
499
+ }
500
+ }
501
+ processing = false;
502
+ ctx.rl.prompt();
503
+ }
504
+ ctx.rl.prompt();
505
+ ctx.rl.on('line', (line) => {
506
+ commandQueue.push(line);
507
+ processQueue();
508
+ });
509
+ ctx.rl.on('close', async () => {
510
+ while (processing || commandQueue.length > 0) {
511
+ await new Promise(r => setTimeout(r, 50));
512
+ }
513
+ ctx.log(`\n${c.dim}Closing browser...${c.reset}`);
514
+ ctx.conn.close();
515
+ process.exit(0);
516
+ });
517
+ let lastSigint = 0;
518
+ ctx.rl.on('SIGINT', () => {
519
+ if (ctx.opts?.extension) {
520
+ ctx.conn.close();
521
+ process.exit(0);
522
+ }
523
+ const now = Date.now();
524
+ if (now - lastSigint < 500) {
525
+ ctx.conn.close();
526
+ process.exit(0);
527
+ }
528
+ lastSigint = now;
529
+ ctx.log(`\n${c.dim}(Ctrl+C again to exit, or type .exit)${c.reset}`);
530
+ ctx.rl.prompt();
531
+ });
532
+ }
533
+ // ─── Prompt string ──────────────────────────────────────────────────────────
534
+ export function promptStr(ctx) {
535
+ if (ctx.opts?.extension)
536
+ return '';
537
+ const mode = ctx.session.mode;
538
+ const prefix = mode === 'recording' ? `${c.red}⏺${c.reset} `
539
+ : mode === 'paused' ? `${c.yellow}⏸${c.reset} `
540
+ : '';
541
+ return `${prefix}${c.cyan}pw>${c.reset} `;
542
+ }
543
+ // ─── Ghost completion (inline suggestion) ───────────────────────────────────
544
+ /**
545
+ * Returns matching commands for ghost completion.
546
+ * When the input exactly matches a command AND there are longer matches,
547
+ * the exact match is included so the user can cycle through all options.
548
+ */
549
+ export function getGhostMatches(cmds, input) {
550
+ if (input.length > 0) {
551
+ // Only match commands with spaces if the input itself contains a space
552
+ const candidates = input.includes(' ')
553
+ ? cmds.filter(cmd => cmd.includes(' '))
554
+ : cmds;
555
+ const longer = candidates.filter(cmd => cmd.startsWith(input) && cmd !== input);
556
+ if (longer.length > 0 && cmds.includes(input))
557
+ longer.push(input);
558
+ return longer;
559
+ }
560
+ return [];
561
+ }
562
+ /* eslint-disable @typescript-eslint/no-explicit-any */
563
+ function attachGhostCompletion(rl, items) {
564
+ if (!process.stdin.isTTY)
565
+ return; // no ghost text for piped input
566
+ const cmds = items.map((i) => i.cmd);
567
+ let ghost = '';
568
+ let matches = []; // all matching commands for current input
569
+ let matchIdx = 0; // which match is currently shown
570
+ function renderGhost(suffix) {
571
+ ghost = suffix;
572
+ rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
573
+ }
574
+ const origTtyWrite = rl._ttyWrite.bind(rl);
575
+ rl._ttyWrite = function (s, key) {
576
+ // Tab handling — based on matches, not ghost text
577
+ if (key && key.name === 'tab') {
578
+ // Cycle through multiple matches
579
+ if (matches.length > 1) {
580
+ rl.output.write('\x1b[K');
581
+ ghost = '';
582
+ matchIdx = (matchIdx + 1) % matches.length;
583
+ const input = rl.line || '';
584
+ const suffix = matches[matchIdx].slice(input.length);
585
+ if (suffix)
586
+ renderGhost(suffix);
587
+ return;
588
+ }
589
+ // Single match — accept it
590
+ if (ghost && matches.length === 1) {
591
+ const text = ghost;
592
+ rl.output.write('\x1b[K');
593
+ ghost = '';
594
+ matches = [];
595
+ rl._insertString(text);
596
+ return;
597
+ }
598
+ return;
599
+ }
600
+ if (ghost && key) {
601
+ // Right-arrow-at-end accepts ghost suggestion
602
+ if (key.name === 'right' && rl.cursor === rl.line.length) {
603
+ const text = ghost;
604
+ rl.output.write('\x1b[K');
605
+ ghost = '';
606
+ matches = [];
607
+ rl._insertString(text);
608
+ return;
609
+ }
610
+ }
611
+ // Clear existing ghost text before readline processes the key
612
+ if (ghost) {
613
+ rl.output.write('\x1b[K');
614
+ ghost = '';
615
+ }
616
+ // Let readline handle the key normally
617
+ origTtyWrite(s, key);
618
+ // Render new ghost text if cursor is at end of line
619
+ const input = rl.line || '';
620
+ matches = getGhostMatches(cmds, input);
621
+ matchIdx = 0;
622
+ if (matches.length > 0 && rl.cursor === rl.line.length) {
623
+ renderGhost(matches[0].slice(input.length));
624
+ }
625
+ };
626
+ }
627
+ /* eslint-enable @typescript-eslint/no-explicit-any */
628
+ // ─── REPL ────────────────────────────────────────────────────────────────────
629
+ export async function startRepl(opts = {}) {
630
+ const silent = opts.silent || false;
631
+ const log = (...args) => { if (!silent)
632
+ console.log(...args); };
633
+ log(`${c.bold}${c.magenta}🎭 Playwright REPL${c.reset} ${c.dim}v${replVersion}${c.reset}`);
634
+ // ─── Start engine ────────────────────────────────────────────────
635
+ if (opts.extension) {
636
+ log(`${c.dim}Extension mode: starting CDP relay server...${c.reset}`);
637
+ log('');
638
+ }
639
+ else {
640
+ log(`${c.dim}Type .help for commands${c.reset}\n`);
641
+ }
642
+ const conn = new Engine();
643
+ try {
644
+ await conn.start(opts);
645
+ if (opts.extension)
646
+ log(`${c.green}✓${c.reset} Extension connected, ready for commands\n`);
647
+ else
648
+ log(`${c.green}✓${c.reset} Browser ready\n`);
649
+ }
650
+ catch (err) {
651
+ console.error(`${c.red}✗${c.reset} Failed to start: ${err.message}`);
652
+ process.exit(1);
653
+ }
654
+ // ─── Session + readline ──────────────────────────────────────────
655
+ const session = new SessionManager();
656
+ const historyDir = path.join(os.homedir(), '.playwright-repl');
657
+ const historyFile = path.join(historyDir, '.repl-history');
658
+ const ctx = { conn, session, rl: null, opts, log, historyFile, sessionHistory: [], commandCount: 0, errors: 0 };
659
+ // Auto-start recording if --record was passed
660
+ if (opts.record) {
661
+ const file = session.startRecording(opts.record);
662
+ log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
663
+ }
664
+ const rl = readline.createInterface({
665
+ input: process.stdin,
666
+ output: process.stdout,
667
+ prompt: promptStr(ctx),
668
+ historySize: 500,
669
+ });
670
+ ctx.rl = rl;
671
+ try {
672
+ const hist = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean).reverse();
673
+ for (const line of hist)
674
+ rl.history.push(line);
675
+ }
676
+ catch { /* ignore */ }
677
+ attachGhostCompletion(rl, buildCompletionItems());
678
+ // ─── Start ───────────────────────────────────────────────────────
679
+ if (opts.replay && opts.replay.length > 0) {
680
+ await runMultiReplayMode(ctx, opts.replay, opts.step || false);
681
+ }
682
+ else {
683
+ startCommandLoop(ctx);
684
+ }
685
+ }
686
+ //# sourceMappingURL=repl.js.map