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/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/playwright-repl.d.ts +14 -0
- package/dist/playwright-repl.d.ts.map +1 -0
- package/dist/playwright-repl.js +107 -0
- package/dist/playwright-repl.js.map +1 -0
- package/dist/recorder.d.ts +95 -0
- package/dist/recorder.d.ts.map +1 -0
- package/dist/recorder.js +207 -0
- package/dist/recorder.js.map +1 -0
- package/dist/repl.d.ts +48 -0
- package/dist/repl.d.ts.map +1 -0
- package/dist/repl.js +686 -0
- package/dist/repl.js.map +1 -0
- package/package.json +10 -16
- package/CHANGELOG.md +0 -129
- package/LICENSE +0 -21
- package/README.md +0 -418
- package/bin/daemon-launcher.cjs +0 -13
- package/bin/mcp-server.cjs +0 -32
- package/bin/playwright-repl.mjs +0 -79
- package/examples/01-add-todos.pw +0 -14
- package/examples/02-complete-and-filter.pw +0 -21
- package/examples/03-record-session.pw +0 -13
- package/examples/04-replay-session.pw +0 -17
- package/examples/05-ci-pipe.pw +0 -14
- package/examples/06-edit-todo.pw +0 -11
- package/examples/ghost-completion-demo.mjs +0 -122
- package/src/colors.mjs +0 -17
- package/src/completion-data.mjs +0 -50
- package/src/connection.mjs +0 -119
- package/src/index.mjs +0 -16
- package/src/parser.mjs +0 -141
- package/src/recorder.mjs +0 -241
- package/src/repl.mjs +0 -678
- package/src/resolve.mjs +0 -82
- package/src/workspace.mjs +0 -104
package/src/repl.mjs
DELETED
|
@@ -1,678 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Main REPL loop.
|
|
3
|
-
*
|
|
4
|
-
* Handles readline, command queue, meta-commands, and session management.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import readline from 'node:readline';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import { execSync } from 'node:child_process';
|
|
11
|
-
|
|
12
|
-
import { replVersion } from './resolve.mjs';
|
|
13
|
-
import { DaemonConnection } from './connection.mjs';
|
|
14
|
-
import { socketPath, daemonProfilesDir, isDaemonRunning, startDaemon } from './workspace.mjs';
|
|
15
|
-
import { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
16
|
-
import { SessionManager } from './recorder.mjs';
|
|
17
|
-
import { buildCompletionItems } from './completion-data.mjs';
|
|
18
|
-
import { c } from './colors.mjs';
|
|
19
|
-
|
|
20
|
-
// ─── Verify commands → run-code translation ─────────────────────────────────
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The daemon has browser_verify_* tools but no CLI keyword mappings.
|
|
24
|
-
* We intercept verify-* commands here and translate them to run-code calls
|
|
25
|
-
* that use the equivalent Playwright API.
|
|
26
|
-
*/
|
|
27
|
-
export function verifyToRunCode(cmdName, positionalArgs) {
|
|
28
|
-
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
29
|
-
|
|
30
|
-
switch (cmdName) {
|
|
31
|
-
case 'verify-text': {
|
|
32
|
-
const text = positionalArgs.join(' ');
|
|
33
|
-
if (!text) return null;
|
|
34
|
-
return { _: ['run-code', `async (page) => { if (await page.getByText('${esc(text)}').filter({ visible: true }).count() === 0) throw new Error('Text not found: ${esc(text)}'); }`] };
|
|
35
|
-
}
|
|
36
|
-
case 'verify-element': {
|
|
37
|
-
const [role, ...nameParts] = positionalArgs;
|
|
38
|
-
const name = nameParts.join(' ');
|
|
39
|
-
if (!role || !name) return null;
|
|
40
|
-
return { _: ['run-code', `async (page) => { if (await page.getByRole('${esc(role)}', { name: '${esc(name)}' }).count() === 0) throw new Error('Element not found: ${esc(role)} "${esc(name)}"'); }`] };
|
|
41
|
-
}
|
|
42
|
-
case 'verify-value': {
|
|
43
|
-
const [ref, ...valueParts] = positionalArgs;
|
|
44
|
-
const value = valueParts.join(' ');
|
|
45
|
-
if (!ref || !value) return null;
|
|
46
|
-
return { _: ['run-code', `async (page) => { const el = page.locator('[aria-ref="${esc(ref)}"]'); const v = await el.inputValue(); if (v !== '${esc(value)}') throw new Error('Expected "${esc(value)}", got "' + v + '"'); }`] };
|
|
47
|
-
}
|
|
48
|
-
case 'verify-list': {
|
|
49
|
-
const [ref, ...items] = positionalArgs;
|
|
50
|
-
if (!ref || items.length === 0) return null;
|
|
51
|
-
const checks = items.map(item => `if (await loc.getByText('${esc(item)}').count() === 0) throw new Error('Item not found: ${esc(item)}');`).join(' ');
|
|
52
|
-
return { _: ['run-code', `async (page) => { const loc = page.locator('[aria-ref="${esc(ref)}"]'); ${checks} }`] };
|
|
53
|
-
}
|
|
54
|
-
default:
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ─── Text-to-action via Playwright native locators ──────────────────────────
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Build a run-code args object that uses Playwright's native text locators.
|
|
63
|
-
* e.g. click "Active" → page.getByText("Active").click()
|
|
64
|
-
* fill "Email" "test" → page.getByLabel("Email").fill("test")
|
|
65
|
-
* check "Buy groceries" → listitem with text → checkbox.check()
|
|
66
|
-
*/
|
|
67
|
-
export function textToRunCode(cmdName, textArg, extraArgs) {
|
|
68
|
-
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
69
|
-
const text = esc(textArg);
|
|
70
|
-
|
|
71
|
-
switch (cmdName) {
|
|
72
|
-
case 'click':
|
|
73
|
-
return { _: ['run-code', `async (page) => {
|
|
74
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
75
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
76
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
77
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
78
|
-
await loc.click();
|
|
79
|
-
}`] };
|
|
80
|
-
case 'dblclick':
|
|
81
|
-
return { _: ['run-code', `async (page) => {
|
|
82
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
83
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
84
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
85
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
86
|
-
await loc.dblclick();
|
|
87
|
-
}`] };
|
|
88
|
-
case 'hover':
|
|
89
|
-
return { _: ['run-code', `async (page) => {
|
|
90
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
91
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
92
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
93
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
94
|
-
await loc.hover();
|
|
95
|
-
}`] };
|
|
96
|
-
case 'fill': {
|
|
97
|
-
const value = esc(extraArgs[0] || '');
|
|
98
|
-
// Try getByLabel first, fall back to getByPlaceholder, then getByRole('textbox')
|
|
99
|
-
return { _: ['run-code', `async (page) => {
|
|
100
|
-
let loc = page.getByLabel('${text}');
|
|
101
|
-
if (await loc.count() === 0) loc = page.getByPlaceholder('${text}');
|
|
102
|
-
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: '${text}' });
|
|
103
|
-
await loc.fill('${value}');
|
|
104
|
-
}`] };
|
|
105
|
-
}
|
|
106
|
-
case 'select': {
|
|
107
|
-
const value = esc(extraArgs[0] || '');
|
|
108
|
-
return { _: ['run-code', `async (page) => {
|
|
109
|
-
let loc = page.getByLabel('${text}');
|
|
110
|
-
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: '${text}' });
|
|
111
|
-
await loc.selectOption('${value}');
|
|
112
|
-
}`] };
|
|
113
|
-
}
|
|
114
|
-
case 'check':
|
|
115
|
-
// Scope to listitem/group with matching text, then find checkbox inside
|
|
116
|
-
return { _: ['run-code', `async (page) => {
|
|
117
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
118
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
|
|
119
|
-
let loc = page.getByLabel('${text}');
|
|
120
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
121
|
-
await loc.check();
|
|
122
|
-
}`] };
|
|
123
|
-
case 'uncheck':
|
|
124
|
-
return { _: ['run-code', `async (page) => {
|
|
125
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
126
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
|
|
127
|
-
let loc = page.getByLabel('${text}');
|
|
128
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
129
|
-
await loc.uncheck();
|
|
130
|
-
}`] };
|
|
131
|
-
default:
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ─── Response filtering ─────────────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
export function filterResponse(text) {
|
|
139
|
-
const sections = text.split(/^### /m).slice(1);
|
|
140
|
-
const kept = [];
|
|
141
|
-
for (const section of sections) {
|
|
142
|
-
const newline = section.indexOf('\n');
|
|
143
|
-
if (newline === -1) continue;
|
|
144
|
-
const title = section.substring(0, newline).trim();
|
|
145
|
-
const content = section.substring(newline + 1).trim();
|
|
146
|
-
if (title === 'Result' || title === 'Error' || title === 'Modal state')
|
|
147
|
-
kept.push(content);
|
|
148
|
-
}
|
|
149
|
-
return kept.length > 0 ? kept.join('\n') : null;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ─── Meta-command handlers ──────────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
export function showHelp() {
|
|
155
|
-
console.log(`\n${c.bold}Available commands:${c.reset}`);
|
|
156
|
-
const categories = {
|
|
157
|
-
'Navigation': ['open', 'goto', 'go-back', 'go-forward', 'reload'],
|
|
158
|
-
'Interaction': ['click', 'dblclick', 'fill', 'type', 'press', 'hover', 'select', 'check', 'uncheck', 'drag'],
|
|
159
|
-
'Inspection': ['snapshot', 'screenshot', 'eval', 'console', 'network', 'run-code'],
|
|
160
|
-
'Tabs': ['tab-list', 'tab-new', 'tab-close', 'tab-select'],
|
|
161
|
-
'Storage': ['cookie-list', 'cookie-get', 'localstorage-list', 'localstorage-get', 'state-save', 'state-load'],
|
|
162
|
-
};
|
|
163
|
-
for (const [cat, cmds] of Object.entries(categories)) {
|
|
164
|
-
console.log(` ${c.bold}${cat}:${c.reset} ${cmds.join(', ')}`);
|
|
165
|
-
}
|
|
166
|
-
console.log(`\n ${c.dim}Use .aliases for shortcuts, or type any command with --help${c.reset}`);
|
|
167
|
-
console.log(`\n${c.bold}REPL meta-commands:${c.reset}`);
|
|
168
|
-
console.log(` .aliases Show command aliases`);
|
|
169
|
-
console.log(` .status Show connection status`);
|
|
170
|
-
console.log(` .reconnect Reconnect to daemon`);
|
|
171
|
-
console.log(` .record [filename] Start recording commands`);
|
|
172
|
-
console.log(` .save Stop recording and save`);
|
|
173
|
-
console.log(` .pause Pause/resume recording`);
|
|
174
|
-
console.log(` .discard Discard recording`);
|
|
175
|
-
console.log(` .replay <filename> Replay a recorded session`);
|
|
176
|
-
console.log(` .exit Exit REPL\n`);
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
export function showAliases() {
|
|
180
|
-
console.log(`\n${c.bold}Command aliases:${c.reset}`);
|
|
181
|
-
const groups = {};
|
|
182
|
-
for (const [alias, cmd] of Object.entries(ALIASES)) {
|
|
183
|
-
if (!groups[cmd]) groups[cmd] = [];
|
|
184
|
-
groups[cmd].push(alias);
|
|
185
|
-
}
|
|
186
|
-
for (const [cmd, aliases] of Object.entries(groups).sort()) {
|
|
187
|
-
console.log(` ${c.cyan}${aliases.join(', ')}${c.reset} → ${cmd}`);
|
|
188
|
-
}
|
|
189
|
-
console.log();
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
export function showStatus(ctx) {
|
|
193
|
-
const { conn, sessionName, session } = ctx;
|
|
194
|
-
console.log(`Connected: ${conn.connected ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
|
|
195
|
-
console.log(`Session: ${sessionName}`);
|
|
196
|
-
console.log(`Socket: ${socketPath(sessionName)}`);
|
|
197
|
-
console.log(`Commands sent: ${ctx.commandCount}`);
|
|
198
|
-
console.log(`Mode: ${session.mode}`);
|
|
199
|
-
if (session.mode === 'recording' || session.mode === 'paused') {
|
|
200
|
-
console.log(`Recording: ${c.red}⏺${c.reset} ${session.recordingFilename} (${session.recordedCount} commands${session.mode === 'paused' ? ', paused' : ''})`);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ─── Session-level commands ─────────────────────────────────────────────────
|
|
205
|
-
|
|
206
|
-
export async function handleKillAll(ctx) {
|
|
207
|
-
try {
|
|
208
|
-
let killed = 0;
|
|
209
|
-
if (process.platform === 'win32') {
|
|
210
|
-
let result = '';
|
|
211
|
-
try {
|
|
212
|
-
result = execSync(
|
|
213
|
-
'powershell -NoProfile -Command "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like \'*run-mcp-server*\' -and $_.CommandLine -like \'*--daemon-session*\' } | Select-Object -ExpandProperty ProcessId"',
|
|
214
|
-
{ encoding: 'utf-8' }
|
|
215
|
-
);
|
|
216
|
-
} catch (err) {
|
|
217
|
-
result = err.stdout || '';
|
|
218
|
-
}
|
|
219
|
-
for (const line of result.trim().split(/\r?\n/)) {
|
|
220
|
-
const pid = line.trim();
|
|
221
|
-
if (/^\d+$/.test(pid)) {
|
|
222
|
-
try { process.kill(parseInt(pid, 10)); killed++; } catch {}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
} else {
|
|
226
|
-
const result = execSync('ps aux', { encoding: 'utf-8' });
|
|
227
|
-
for (const ln of result.split('\n')) {
|
|
228
|
-
if (ln.includes('run-mcp-server') && ln.includes('--daemon-session')) {
|
|
229
|
-
const pid = ln.trim().split(/\s+/)[1];
|
|
230
|
-
if (pid && /^\d+$/.test(pid)) {
|
|
231
|
-
try { process.kill(parseInt(pid, 10), 'SIGKILL'); killed++; } catch {}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
console.log(killed > 0
|
|
237
|
-
? `${c.green}✓${c.reset} Killed ${killed} daemon process${killed === 1 ? '' : 'es'}`
|
|
238
|
-
: `${c.dim}No daemon processes found${c.reset}`);
|
|
239
|
-
ctx.conn.close();
|
|
240
|
-
} catch (err) {
|
|
241
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export async function handleClose(ctx) {
|
|
246
|
-
try {
|
|
247
|
-
await ctx.conn.send('stop', {});
|
|
248
|
-
console.log(`${c.green}✓${c.reset} Daemon stopped`);
|
|
249
|
-
ctx.conn.close();
|
|
250
|
-
} catch (err) {
|
|
251
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ─── Session meta-commands (.record, .save, .pause, .discard, .replay) ──────
|
|
256
|
-
|
|
257
|
-
export function handleSessionCommand(ctx, line) {
|
|
258
|
-
const { session } = ctx;
|
|
259
|
-
|
|
260
|
-
if (line.startsWith('.record')) {
|
|
261
|
-
const filename = line.split(/\s+/)[1] || undefined;
|
|
262
|
-
const file = session.startRecording(filename);
|
|
263
|
-
console.log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
264
|
-
ctx.rl.setPrompt(promptStr(ctx));
|
|
265
|
-
return true;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (line === '.save') {
|
|
269
|
-
const { filename, count } = session.save();
|
|
270
|
-
console.log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
|
|
271
|
-
ctx.rl.setPrompt(promptStr(ctx));
|
|
272
|
-
return true;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (line === '.pause') {
|
|
276
|
-
const paused = session.togglePause();
|
|
277
|
-
console.log(paused ? `${c.yellow}⏸${c.reset} Recording paused` : `${c.red}⏺${c.reset} Recording resumed`);
|
|
278
|
-
return true;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
if (line === '.discard') {
|
|
282
|
-
session.discard();
|
|
283
|
-
console.log(`${c.yellow}Recording discarded${c.reset}`);
|
|
284
|
-
ctx.rl.setPrompt(promptStr(ctx));
|
|
285
|
-
return true;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
return false;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// ─── Process a single line ──────────────────────────────────────────────────
|
|
292
|
-
|
|
293
|
-
export async function processLine(ctx, line) {
|
|
294
|
-
line = line.trim();
|
|
295
|
-
if (!line) return;
|
|
296
|
-
|
|
297
|
-
// ── Meta-commands ────────────────────────────────────────────────
|
|
298
|
-
|
|
299
|
-
if (line === '.help' || line === '?') return showHelp();
|
|
300
|
-
if (line === '.aliases') return showAliases();
|
|
301
|
-
if (line === '.status') return showStatus(ctx);
|
|
302
|
-
|
|
303
|
-
if (line === '.exit' || line === '.quit') {
|
|
304
|
-
ctx.conn.close();
|
|
305
|
-
process.exit(0);
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (line === '.reconnect') {
|
|
309
|
-
ctx.conn.close();
|
|
310
|
-
try {
|
|
311
|
-
await ctx.conn.connect();
|
|
312
|
-
console.log(`${c.green}✓${c.reset} Reconnected`);
|
|
313
|
-
} catch (err) {
|
|
314
|
-
console.error(`${c.red}✗${c.reset} ${err.message}`);
|
|
315
|
-
}
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// ── Session commands (record/save/pause/discard) ────────────────
|
|
320
|
-
|
|
321
|
-
if (line.startsWith('.')) {
|
|
322
|
-
try {
|
|
323
|
-
if (handleSessionCommand(ctx, line)) return;
|
|
324
|
-
} catch (err) {
|
|
325
|
-
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
326
|
-
return;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// ── Inline replay ──────────────────────────────────────────────
|
|
331
|
-
|
|
332
|
-
if (line.startsWith('.replay')) {
|
|
333
|
-
const filename = line.split(/\s+/)[1];
|
|
334
|
-
if (!filename) {
|
|
335
|
-
console.log(`${c.yellow}Usage: .replay <filename>${c.reset}`);
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
try {
|
|
339
|
-
const player = ctx.session.startReplay(filename);
|
|
340
|
-
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${filename}${c.reset} (${player.commands.length} commands)\n`);
|
|
341
|
-
while (!player.done) {
|
|
342
|
-
const cmd = player.next();
|
|
343
|
-
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
|
|
344
|
-
await processLine(ctx, cmd);
|
|
345
|
-
}
|
|
346
|
-
ctx.session.endReplay();
|
|
347
|
-
console.log(`\n${c.green}✓${c.reset} Replay complete`);
|
|
348
|
-
} catch (err) {
|
|
349
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
350
|
-
ctx.session.endReplay();
|
|
351
|
-
}
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// ── Regular command — parse and send ─────────────────────────────
|
|
356
|
-
|
|
357
|
-
let args = parseInput(line);
|
|
358
|
-
if (!args) return;
|
|
359
|
-
|
|
360
|
-
const cmdName = args._[0];
|
|
361
|
-
if (!cmdName) return;
|
|
362
|
-
|
|
363
|
-
// Validate command exists
|
|
364
|
-
const knownExtras = ['help', 'list', 'close-all', 'kill-all', 'install', 'install-browser',
|
|
365
|
-
'verify-text', 'verify-element', 'verify-value', 'verify-list'];
|
|
366
|
-
if (!ALL_COMMANDS.includes(cmdName) && !knownExtras.includes(cmdName)) {
|
|
367
|
-
console.log(`${c.yellow}Unknown command: ${cmdName}${c.reset}`);
|
|
368
|
-
console.log(`${c.dim}Type .help for available commands${c.reset}`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// ── Session-level commands (not forwarded to daemon) ──────────
|
|
373
|
-
if (cmdName === 'kill-all') return handleKillAll(ctx);
|
|
374
|
-
if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx);
|
|
375
|
-
|
|
376
|
-
// ── Verify commands → run-code translation ──────────────────
|
|
377
|
-
const verifyCommands = ['verify-text', 'verify-element', 'verify-value', 'verify-list'];
|
|
378
|
-
if (verifyCommands.includes(cmdName)) {
|
|
379
|
-
const translated = verifyToRunCode(cmdName, args._.slice(1));
|
|
380
|
-
if (translated) {
|
|
381
|
-
args = translated;
|
|
382
|
-
} else {
|
|
383
|
-
console.log(`${c.yellow}Usage: ${cmdName} <args>${c.reset}`);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── Auto-resolve text to native Playwright locator ─────────
|
|
389
|
-
const refCommands = ['click', 'dblclick', 'hover', 'fill', 'select', 'check', 'uncheck'];
|
|
390
|
-
if (refCommands.includes(cmdName) && args._[1] && !/^e\d+$/.test(args._[1])) {
|
|
391
|
-
const textArg = args._[1];
|
|
392
|
-
const extraArgs = args._.slice(2);
|
|
393
|
-
const runCodeArgs = textToRunCode(cmdName, textArg, extraArgs);
|
|
394
|
-
if (runCodeArgs) {
|
|
395
|
-
ctx.log(`${c.dim}→ ${runCodeArgs._[1]}${c.reset}`);
|
|
396
|
-
args = runCodeArgs;
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
const startTime = performance.now();
|
|
401
|
-
try {
|
|
402
|
-
const result = await ctx.conn.run(args);
|
|
403
|
-
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
404
|
-
if (result?.text) {
|
|
405
|
-
const output = filterResponse(result.text);
|
|
406
|
-
if (output) console.log(output);
|
|
407
|
-
}
|
|
408
|
-
ctx.commandCount++;
|
|
409
|
-
ctx.session.record(line);
|
|
410
|
-
|
|
411
|
-
if (elapsed > 500) {
|
|
412
|
-
ctx.log(`${c.dim}(${elapsed}ms)${c.reset}`);
|
|
413
|
-
}
|
|
414
|
-
} catch (err) {
|
|
415
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
416
|
-
if (!ctx.conn.connected) {
|
|
417
|
-
console.log(`${c.yellow}Connection lost. Trying to reconnect...${c.reset}`);
|
|
418
|
-
try {
|
|
419
|
-
await ctx.conn.connect();
|
|
420
|
-
console.log(`${c.green}✓${c.reset} Reconnected. Try your command again.`);
|
|
421
|
-
} catch {
|
|
422
|
-
console.error(`${c.red}✗${c.reset} Could not reconnect. Use .reconnect or restart.`);
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// ─── Replay mode (non-interactive, --replay flag) ───────────────────────────
|
|
429
|
-
|
|
430
|
-
export async function runReplayMode(ctx, replayFile, step) {
|
|
431
|
-
try {
|
|
432
|
-
const player = ctx.session.startReplay(replayFile, step);
|
|
433
|
-
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${replayFile}${c.reset} (${player.commands.length} commands)\n`);
|
|
434
|
-
while (!player.done) {
|
|
435
|
-
const cmd = player.next();
|
|
436
|
-
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
|
|
437
|
-
await processLine(ctx, cmd);
|
|
438
|
-
|
|
439
|
-
if (ctx.session.step && !player.done) {
|
|
440
|
-
await new Promise((resolve) => {
|
|
441
|
-
process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
|
|
442
|
-
process.stdin.once('data', () => {
|
|
443
|
-
process.stdout.write('\r\x1b[K');
|
|
444
|
-
resolve();
|
|
445
|
-
});
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
ctx.session.endReplay();
|
|
450
|
-
console.log(`\n${c.green}✓${c.reset} Replay complete`);
|
|
451
|
-
ctx.conn.close();
|
|
452
|
-
process.exit(0);
|
|
453
|
-
} catch (err) {
|
|
454
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
455
|
-
ctx.conn.close();
|
|
456
|
-
process.exit(1);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// ─── Command loop (interactive) ─────────────────────────────────────────────
|
|
461
|
-
|
|
462
|
-
export function startCommandLoop(ctx) {
|
|
463
|
-
let processing = false;
|
|
464
|
-
const commandQueue = [];
|
|
465
|
-
|
|
466
|
-
async function processQueue() {
|
|
467
|
-
if (processing) return;
|
|
468
|
-
processing = true;
|
|
469
|
-
while (commandQueue.length > 0) {
|
|
470
|
-
const line = commandQueue.shift();
|
|
471
|
-
await processLine(ctx, line);
|
|
472
|
-
if (line.trim()) {
|
|
473
|
-
try {
|
|
474
|
-
fs.mkdirSync(path.dirname(ctx.historyFile), { recursive: true });
|
|
475
|
-
fs.appendFileSync(ctx.historyFile, line.trim() + '\n');
|
|
476
|
-
} catch {}
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
processing = false;
|
|
480
|
-
ctx.rl.prompt();
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
ctx.rl.prompt();
|
|
484
|
-
|
|
485
|
-
ctx.rl.on('line', (line) => {
|
|
486
|
-
commandQueue.push(line);
|
|
487
|
-
processQueue();
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
ctx.rl.on('close', async () => {
|
|
491
|
-
while (processing || commandQueue.length > 0) {
|
|
492
|
-
await new Promise(r => setTimeout(r, 50));
|
|
493
|
-
}
|
|
494
|
-
ctx.log(`\n${c.dim}Disconnecting... (daemon stays running)${c.reset}`);
|
|
495
|
-
ctx.conn.close();
|
|
496
|
-
process.exit(0);
|
|
497
|
-
});
|
|
498
|
-
|
|
499
|
-
let lastSigint = 0;
|
|
500
|
-
ctx.rl.on('SIGINT', () => {
|
|
501
|
-
const now = Date.now();
|
|
502
|
-
if (now - lastSigint < 500) {
|
|
503
|
-
ctx.conn.close();
|
|
504
|
-
process.exit(0);
|
|
505
|
-
}
|
|
506
|
-
lastSigint = now;
|
|
507
|
-
ctx.log(`\n${c.dim}(Ctrl+C again to exit, or type .exit)${c.reset}`);
|
|
508
|
-
ctx.rl.prompt();
|
|
509
|
-
});
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// ─── Prompt string ──────────────────────────────────────────────────────────
|
|
513
|
-
|
|
514
|
-
export function promptStr(ctx) {
|
|
515
|
-
const mode = ctx.session.mode;
|
|
516
|
-
const prefix = mode === 'recording' ? `${c.red}⏺${c.reset} `
|
|
517
|
-
: mode === 'paused' ? `${c.yellow}⏸${c.reset} `
|
|
518
|
-
: '';
|
|
519
|
-
return `${prefix}${c.cyan}pw>${c.reset} `;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// ─── Ghost completion (inline suggestion) ───────────────────────────────────
|
|
523
|
-
|
|
524
|
-
/**
|
|
525
|
-
* Attaches ghost-text completion to a readline interface.
|
|
526
|
-
* Shows dimmed inline suggestion after the cursor; Tab or Right Arrow accepts it.
|
|
527
|
-
*
|
|
528
|
-
* Uses _ttyWrite wrapper instead of _writeToOutput because Node 22+ optimizes
|
|
529
|
-
* single-character appends and doesn't always trigger a full line refresh.
|
|
530
|
-
*
|
|
531
|
-
* @param {readline.Interface} rl
|
|
532
|
-
* @param {Array<{cmd: string, desc: string}>} items - from buildCompletionItems()
|
|
533
|
-
*/
|
|
534
|
-
function attachGhostCompletion(rl, items) {
|
|
535
|
-
if (!process.stdin.isTTY) return; // no ghost text for piped input
|
|
536
|
-
|
|
537
|
-
const cmds = items.filter(i => !i.desc.startsWith('→')).map(i => i.cmd);
|
|
538
|
-
let ghost = '';
|
|
539
|
-
let matches = []; // all matching commands for current input
|
|
540
|
-
let matchIdx = 0; // which match is currently shown
|
|
541
|
-
|
|
542
|
-
function getMatches(input) {
|
|
543
|
-
if (input.length > 0 && !input.includes(' ')) {
|
|
544
|
-
return cmds.filter(cmd => cmd.startsWith(input) && cmd !== input);
|
|
545
|
-
}
|
|
546
|
-
return [];
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
function renderGhost(suffix) {
|
|
550
|
-
ghost = suffix;
|
|
551
|
-
rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
const origTtyWrite = rl._ttyWrite.bind(rl);
|
|
555
|
-
rl._ttyWrite = function (s, key) {
|
|
556
|
-
if (ghost && key) {
|
|
557
|
-
// Right-arrow-at-end accepts ghost suggestion
|
|
558
|
-
if (key.name === 'right' && rl.cursor === rl.line.length) {
|
|
559
|
-
const text = ghost;
|
|
560
|
-
rl.output.write('\x1b[K');
|
|
561
|
-
ghost = '';
|
|
562
|
-
matches = [];
|
|
563
|
-
rl._insertString(text);
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Tab cycles through matches
|
|
568
|
-
if (key.name === 'tab' && matches.length > 1) {
|
|
569
|
-
rl.output.write('\x1b[K');
|
|
570
|
-
matchIdx = (matchIdx + 1) % matches.length;
|
|
571
|
-
const input = rl.line || '';
|
|
572
|
-
renderGhost(matches[matchIdx].slice(input.length));
|
|
573
|
-
return;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// Tab with single match accepts it
|
|
577
|
-
if (key.name === 'tab' && matches.length === 1) {
|
|
578
|
-
const text = ghost;
|
|
579
|
-
rl.output.write('\x1b[K');
|
|
580
|
-
ghost = '';
|
|
581
|
-
matches = [];
|
|
582
|
-
rl._insertString(text);
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Tab on empty input — show all commands as ghost suggestions
|
|
588
|
-
if (key && key.name === 'tab') {
|
|
589
|
-
if ((rl.line || '') === '') {
|
|
590
|
-
matches = cmds;
|
|
591
|
-
matchIdx = 0;
|
|
592
|
-
renderGhost(matches[0]);
|
|
593
|
-
}
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Clear existing ghost text before readline processes the key
|
|
598
|
-
if (ghost) {
|
|
599
|
-
rl.output.write('\x1b[K');
|
|
600
|
-
ghost = '';
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
// Let readline handle the key normally
|
|
604
|
-
origTtyWrite(s, key);
|
|
605
|
-
|
|
606
|
-
// Render new ghost text if cursor is at end of line
|
|
607
|
-
const input = rl.line || '';
|
|
608
|
-
matches = getMatches(input);
|
|
609
|
-
matchIdx = 0;
|
|
610
|
-
if (matches.length > 0 && rl.cursor === rl.line.length) {
|
|
611
|
-
renderGhost(matches[0].slice(input.length));
|
|
612
|
-
}
|
|
613
|
-
};
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// ─── REPL ────────────────────────────────────────────────────────────────────
|
|
617
|
-
|
|
618
|
-
export async function startRepl(opts = {}) {
|
|
619
|
-
const sessionName = opts.session || 'default';
|
|
620
|
-
const silent = opts.silent || false;
|
|
621
|
-
const log = (...args) => { if (!silent) console.log(...args); };
|
|
622
|
-
|
|
623
|
-
log(`${c.bold}${c.magenta}🎭 Playwright REPL${c.reset} ${c.dim}v${replVersion}${c.reset}`);
|
|
624
|
-
log(`${c.dim}Session: ${sessionName} | Type .help for commands${c.reset}\n`);
|
|
625
|
-
|
|
626
|
-
// ─── Connect to daemon ───────────────────────────────────────────
|
|
627
|
-
|
|
628
|
-
const running = await isDaemonRunning(sessionName);
|
|
629
|
-
if (!running) {
|
|
630
|
-
await startDaemon(sessionName, opts);
|
|
631
|
-
await new Promise(r => setTimeout(r, 500));
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const conn = new DaemonConnection(socketPath(sessionName), replVersion);
|
|
635
|
-
try {
|
|
636
|
-
await conn.connect();
|
|
637
|
-
log(`${c.green}✓${c.reset} Connected to daemon${running ? '' : ' (newly started)'}\n`);
|
|
638
|
-
} catch (err) {
|
|
639
|
-
console.error(`${c.red}✗${c.reset} Failed to connect: ${err.message}`);
|
|
640
|
-
console.error(` Try: playwright-cli open`);
|
|
641
|
-
process.exit(1);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// ─── Session + readline ──────────────────────────────────────────
|
|
645
|
-
|
|
646
|
-
const session = new SessionManager();
|
|
647
|
-
const historyFile = path.join(daemonProfilesDir, '.repl-history');
|
|
648
|
-
const ctx = { conn, session, rl: null, sessionName, log, historyFile, commandCount: 0 };
|
|
649
|
-
|
|
650
|
-
// Auto-start recording if --record was passed
|
|
651
|
-
if (opts.record) {
|
|
652
|
-
const file = session.startRecording(opts.record);
|
|
653
|
-
log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
const rl = readline.createInterface({
|
|
657
|
-
input: process.stdin,
|
|
658
|
-
output: process.stdout,
|
|
659
|
-
prompt: promptStr(ctx),
|
|
660
|
-
historySize: 500,
|
|
661
|
-
});
|
|
662
|
-
ctx.rl = rl;
|
|
663
|
-
|
|
664
|
-
try {
|
|
665
|
-
const hist = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean).reverse();
|
|
666
|
-
for (const line of hist) rl.history.push(line);
|
|
667
|
-
} catch {}
|
|
668
|
-
|
|
669
|
-
attachGhostCompletion(rl, buildCompletionItems());
|
|
670
|
-
|
|
671
|
-
// ─── Start ───────────────────────────────────────────────────────
|
|
672
|
-
|
|
673
|
-
if (opts.replay) {
|
|
674
|
-
await runReplayMode(ctx, opts.replay, opts.step);
|
|
675
|
-
} else {
|
|
676
|
-
startCommandLoop(ctx);
|
|
677
|
-
}
|
|
678
|
-
}
|