playwright-repl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/RELEASES.md +70 -0
- package/bin/daemon-launcher.cjs +13 -0
- package/bin/playwright-repl.mjs +79 -0
- package/examples/01-add-todos.pw +14 -0
- package/examples/02-complete-and-filter.pw +21 -0
- package/examples/03-record-session.pw +13 -0
- package/examples/04-replay-session.pw +17 -0
- package/examples/05-ci-pipe.pw +14 -0
- package/examples/06-edit-todo.pw +11 -0
- package/package.json +52 -0
- package/src/colors.mjs +17 -0
- package/src/connection.mjs +119 -0
- package/src/index.mjs +15 -0
- package/src/parser.mjs +141 -0
- package/src/recorder.mjs +241 -0
- package/src/repl.mjs +582 -0
- package/src/resolve.mjs +82 -0
- package/src/workspace.mjs +104 -0
package/src/repl.mjs
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
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, COMMANDS } 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 { c } from './colors.mjs';
|
|
18
|
+
|
|
19
|
+
// ─── Verify commands → run-code translation ─────────────────────────────────
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The daemon has browser_verify_* tools but no CLI keyword mappings.
|
|
23
|
+
* We intercept verify-* commands here and translate them to run-code calls
|
|
24
|
+
* that use the equivalent Playwright API.
|
|
25
|
+
*/
|
|
26
|
+
export function verifyToRunCode(cmdName, positionalArgs) {
|
|
27
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
28
|
+
|
|
29
|
+
switch (cmdName) {
|
|
30
|
+
case 'verify-text': {
|
|
31
|
+
const text = positionalArgs.join(' ');
|
|
32
|
+
if (!text) return null;
|
|
33
|
+
return { _: ['run-code', `async (page) => { if (await page.getByText('${esc(text)}').filter({ visible: true }).count() === 0) throw new Error('Text not found: ${esc(text)}'); }`] };
|
|
34
|
+
}
|
|
35
|
+
case 'verify-element': {
|
|
36
|
+
const [role, ...nameParts] = positionalArgs;
|
|
37
|
+
const name = nameParts.join(' ');
|
|
38
|
+
if (!role || !name) return null;
|
|
39
|
+
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)}"'); }`] };
|
|
40
|
+
}
|
|
41
|
+
case 'verify-value': {
|
|
42
|
+
const [ref, ...valueParts] = positionalArgs;
|
|
43
|
+
const value = valueParts.join(' ');
|
|
44
|
+
if (!ref || !value) return null;
|
|
45
|
+
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 + '"'); }`] };
|
|
46
|
+
}
|
|
47
|
+
case 'verify-list': {
|
|
48
|
+
const [ref, ...items] = positionalArgs;
|
|
49
|
+
if (!ref || items.length === 0) return null;
|
|
50
|
+
const checks = items.map(item => `if (await loc.getByText('${esc(item)}').count() === 0) throw new Error('Item not found: ${esc(item)}');`).join(' ');
|
|
51
|
+
return { _: ['run-code', `async (page) => { const loc = page.locator('[aria-ref="${esc(ref)}"]'); ${checks} }`] };
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── Text-to-action via Playwright native locators ──────────────────────────
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a run-code args object that uses Playwright's native text locators.
|
|
62
|
+
* e.g. click "Active" → page.getByText("Active").click()
|
|
63
|
+
* fill "Email" "test" → page.getByLabel("Email").fill("test")
|
|
64
|
+
* check "Buy groceries" → listitem with text → checkbox.check()
|
|
65
|
+
*/
|
|
66
|
+
export function textToRunCode(cmdName, textArg, extraArgs) {
|
|
67
|
+
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
68
|
+
const text = esc(textArg);
|
|
69
|
+
|
|
70
|
+
switch (cmdName) {
|
|
71
|
+
case 'click':
|
|
72
|
+
return { _: ['run-code', `async (page) => { await page.getByText('${text}', { exact: true }).click(); }`] };
|
|
73
|
+
case 'dblclick':
|
|
74
|
+
return { _: ['run-code', `async (page) => { await page.getByText('${text}', { exact: true }).dblclick(); }`] };
|
|
75
|
+
case 'hover':
|
|
76
|
+
return { _: ['run-code', `async (page) => { await page.getByText('${text}', { exact: true }).hover(); }`] };
|
|
77
|
+
case 'fill': {
|
|
78
|
+
const value = esc(extraArgs[0] || '');
|
|
79
|
+
// Try getByLabel first, fall back to getByPlaceholder, then getByRole('textbox')
|
|
80
|
+
return { _: ['run-code', `async (page) => {
|
|
81
|
+
let loc = page.getByLabel('${text}');
|
|
82
|
+
if (await loc.count() === 0) loc = page.getByPlaceholder('${text}');
|
|
83
|
+
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: '${text}' });
|
|
84
|
+
await loc.fill('${value}');
|
|
85
|
+
}`] };
|
|
86
|
+
}
|
|
87
|
+
case 'select': {
|
|
88
|
+
const value = esc(extraArgs[0] || '');
|
|
89
|
+
return { _: ['run-code', `async (page) => {
|
|
90
|
+
let loc = page.getByLabel('${text}');
|
|
91
|
+
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: '${text}' });
|
|
92
|
+
await loc.selectOption('${value}');
|
|
93
|
+
}`] };
|
|
94
|
+
}
|
|
95
|
+
case 'check':
|
|
96
|
+
// Scope to listitem/group with matching text, then find checkbox inside
|
|
97
|
+
return { _: ['run-code', `async (page) => {
|
|
98
|
+
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
99
|
+
if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
|
|
100
|
+
let loc = page.getByLabel('${text}');
|
|
101
|
+
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
102
|
+
await loc.check();
|
|
103
|
+
}`] };
|
|
104
|
+
case 'uncheck':
|
|
105
|
+
return { _: ['run-code', `async (page) => {
|
|
106
|
+
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
107
|
+
if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
|
|
108
|
+
let loc = page.getByLabel('${text}');
|
|
109
|
+
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
110
|
+
await loc.uncheck();
|
|
111
|
+
}`] };
|
|
112
|
+
default:
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── Response filtering ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
export function filterResponse(text) {
|
|
120
|
+
const sections = text.split(/^### /m).slice(1);
|
|
121
|
+
const kept = [];
|
|
122
|
+
for (const section of sections) {
|
|
123
|
+
const newline = section.indexOf('\n');
|
|
124
|
+
if (newline === -1) continue;
|
|
125
|
+
const title = section.substring(0, newline).trim();
|
|
126
|
+
const content = section.substring(newline + 1).trim();
|
|
127
|
+
if (title === 'Result' || title === 'Error' || title === 'Modal state')
|
|
128
|
+
kept.push(content);
|
|
129
|
+
}
|
|
130
|
+
return kept.length > 0 ? kept.join('\n') : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Meta-command handlers ──────────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
export function showHelp() {
|
|
136
|
+
console.log(`\n${c.bold}Available commands:${c.reset}`);
|
|
137
|
+
const categories = {
|
|
138
|
+
'Navigation': ['open', 'goto', 'go-back', 'go-forward', 'reload'],
|
|
139
|
+
'Interaction': ['click', 'dblclick', 'fill', 'type', 'press', 'hover', 'select', 'check', 'uncheck', 'drag'],
|
|
140
|
+
'Inspection': ['snapshot', 'screenshot', 'eval', 'console', 'network', 'run-code'],
|
|
141
|
+
'Tabs': ['tab-list', 'tab-new', 'tab-close', 'tab-select'],
|
|
142
|
+
'Storage': ['cookie-list', 'cookie-get', 'localstorage-list', 'localstorage-get', 'state-save', 'state-load'],
|
|
143
|
+
};
|
|
144
|
+
for (const [cat, cmds] of Object.entries(categories)) {
|
|
145
|
+
console.log(` ${c.bold}${cat}:${c.reset} ${cmds.join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
console.log(`\n ${c.dim}Use .aliases for shortcuts, or type any command with --help${c.reset}`);
|
|
148
|
+
console.log(`\n${c.bold}REPL meta-commands:${c.reset}`);
|
|
149
|
+
console.log(` .aliases Show command aliases`);
|
|
150
|
+
console.log(` .status Show connection status`);
|
|
151
|
+
console.log(` .reconnect Reconnect to daemon`);
|
|
152
|
+
console.log(` .record [filename] Start recording commands`);
|
|
153
|
+
console.log(` .save Stop recording and save`);
|
|
154
|
+
console.log(` .pause Pause/resume recording`);
|
|
155
|
+
console.log(` .discard Discard recording`);
|
|
156
|
+
console.log(` .replay <filename> Replay a recorded session`);
|
|
157
|
+
console.log(` .exit Exit REPL\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function showAliases() {
|
|
161
|
+
console.log(`\n${c.bold}Command aliases:${c.reset}`);
|
|
162
|
+
const groups = {};
|
|
163
|
+
for (const [alias, cmd] of Object.entries(ALIASES)) {
|
|
164
|
+
if (!groups[cmd]) groups[cmd] = [];
|
|
165
|
+
groups[cmd].push(alias);
|
|
166
|
+
}
|
|
167
|
+
for (const [cmd, aliases] of Object.entries(groups).sort()) {
|
|
168
|
+
console.log(` ${c.cyan}${aliases.join(', ')}${c.reset} → ${cmd}`);
|
|
169
|
+
}
|
|
170
|
+
console.log();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function showStatus(ctx) {
|
|
174
|
+
const { conn, sessionName, session } = ctx;
|
|
175
|
+
console.log(`Connected: ${conn.connected ? `${c.green}yes${c.reset}` : `${c.red}no${c.reset}`}`);
|
|
176
|
+
console.log(`Session: ${sessionName}`);
|
|
177
|
+
console.log(`Socket: ${socketPath(sessionName)}`);
|
|
178
|
+
console.log(`Commands sent: ${ctx.commandCount}`);
|
|
179
|
+
console.log(`Mode: ${session.mode}`);
|
|
180
|
+
if (session.mode === 'recording' || session.mode === 'paused') {
|
|
181
|
+
console.log(`Recording: ${c.red}⏺${c.reset} ${session.recordingFilename} (${session.recordedCount} commands${session.mode === 'paused' ? ', paused' : ''})`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Session-level commands ─────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export async function handleKillAll(ctx) {
|
|
188
|
+
try {
|
|
189
|
+
let killed = 0;
|
|
190
|
+
if (process.platform === 'win32') {
|
|
191
|
+
let result = '';
|
|
192
|
+
try {
|
|
193
|
+
result = execSync('wmic process where "CommandLine like \'%run-mcp-server%\' and CommandLine like \'%--daemon-session%\'" get ProcessId /format:list', { encoding: 'utf-8' });
|
|
194
|
+
} catch (err) {
|
|
195
|
+
result = err.stdout || '';
|
|
196
|
+
}
|
|
197
|
+
const pids = result.match(/ProcessId=(\d+)/g) || [];
|
|
198
|
+
for (const match of pids) {
|
|
199
|
+
const pid = match.split('=')[1];
|
|
200
|
+
try { process.kill(parseInt(pid, 10)); killed++; } catch {}
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
const result = execSync('ps aux', { encoding: 'utf-8' });
|
|
204
|
+
for (const ln of result.split('\n')) {
|
|
205
|
+
if (ln.includes('run-mcp-server') && ln.includes('--daemon-session')) {
|
|
206
|
+
const pid = ln.trim().split(/\s+/)[1];
|
|
207
|
+
if (pid && /^\d+$/.test(pid)) {
|
|
208
|
+
try { process.kill(parseInt(pid, 10), 'SIGKILL'); killed++; } catch {}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
console.log(killed > 0
|
|
214
|
+
? `${c.green}✓${c.reset} Killed ${killed} daemon process${killed === 1 ? '' : 'es'}`
|
|
215
|
+
: `${c.dim}No daemon processes found${c.reset}`);
|
|
216
|
+
ctx.conn.close();
|
|
217
|
+
} catch (err) {
|
|
218
|
+
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function handleClose(ctx) {
|
|
223
|
+
try {
|
|
224
|
+
await ctx.conn.send('stop', {});
|
|
225
|
+
console.log(`${c.green}✓${c.reset} Daemon stopped`);
|
|
226
|
+
ctx.conn.close();
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Session meta-commands (.record, .save, .pause, .discard, .replay) ──────
|
|
233
|
+
|
|
234
|
+
export function handleSessionCommand(ctx, line) {
|
|
235
|
+
const { session } = ctx;
|
|
236
|
+
|
|
237
|
+
if (line.startsWith('.record')) {
|
|
238
|
+
const filename = line.split(/\s+/)[1] || undefined;
|
|
239
|
+
const file = session.startRecording(filename);
|
|
240
|
+
console.log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
241
|
+
ctx.rl.setPrompt(promptStr(ctx));
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (line === '.save') {
|
|
246
|
+
const { filename, count } = session.save();
|
|
247
|
+
console.log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
|
|
248
|
+
ctx.rl.setPrompt(promptStr(ctx));
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (line === '.pause') {
|
|
253
|
+
const paused = session.togglePause();
|
|
254
|
+
console.log(paused ? `${c.yellow}⏸${c.reset} Recording paused` : `${c.red}⏺${c.reset} Recording resumed`);
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (line === '.discard') {
|
|
259
|
+
session.discard();
|
|
260
|
+
console.log(`${c.yellow}Recording discarded${c.reset}`);
|
|
261
|
+
ctx.rl.setPrompt(promptStr(ctx));
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ─── Process a single line ──────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
export async function processLine(ctx, line) {
|
|
271
|
+
line = line.trim();
|
|
272
|
+
if (!line) return;
|
|
273
|
+
|
|
274
|
+
// ── Meta-commands ────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
if (line === '.help' || line === '?') return showHelp();
|
|
277
|
+
if (line === '.aliases') return showAliases();
|
|
278
|
+
if (line === '.status') return showStatus(ctx);
|
|
279
|
+
|
|
280
|
+
if (line === '.exit' || line === '.quit') {
|
|
281
|
+
ctx.conn.close();
|
|
282
|
+
process.exit(0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (line === '.reconnect') {
|
|
286
|
+
ctx.conn.close();
|
|
287
|
+
try {
|
|
288
|
+
await ctx.conn.connect();
|
|
289
|
+
console.log(`${c.green}✓${c.reset} Reconnected`);
|
|
290
|
+
} catch (err) {
|
|
291
|
+
console.error(`${c.red}✗${c.reset} ${err.message}`);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Session commands (record/save/pause/discard) ────────────────
|
|
297
|
+
|
|
298
|
+
if (line.startsWith('.')) {
|
|
299
|
+
try {
|
|
300
|
+
if (handleSessionCommand(ctx, line)) return;
|
|
301
|
+
} catch (err) {
|
|
302
|
+
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Inline replay ──────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
if (line.startsWith('.replay')) {
|
|
310
|
+
const filename = line.split(/\s+/)[1];
|
|
311
|
+
if (!filename) {
|
|
312
|
+
console.log(`${c.yellow}Usage: .replay <filename>${c.reset}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
try {
|
|
316
|
+
const player = ctx.session.startReplay(filename);
|
|
317
|
+
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${filename}${c.reset} (${player.commands.length} commands)\n`);
|
|
318
|
+
while (!player.done) {
|
|
319
|
+
const cmd = player.next();
|
|
320
|
+
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
|
|
321
|
+
await processLine(ctx, cmd);
|
|
322
|
+
}
|
|
323
|
+
ctx.session.endReplay();
|
|
324
|
+
console.log(`\n${c.green}✓${c.reset} Replay complete`);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
327
|
+
ctx.session.endReplay();
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ── Regular command — parse and send ─────────────────────────────
|
|
333
|
+
|
|
334
|
+
let args = parseInput(line);
|
|
335
|
+
if (!args) return;
|
|
336
|
+
|
|
337
|
+
const cmdName = args._[0];
|
|
338
|
+
if (!cmdName) return;
|
|
339
|
+
|
|
340
|
+
// Validate command exists
|
|
341
|
+
const knownExtras = ['help', 'list', 'close-all', 'kill-all', 'install', 'install-browser',
|
|
342
|
+
'verify-text', 'verify-element', 'verify-value', 'verify-list'];
|
|
343
|
+
if (!ALL_COMMANDS.includes(cmdName) && !knownExtras.includes(cmdName)) {
|
|
344
|
+
console.log(`${c.yellow}Unknown command: ${cmdName}${c.reset}`);
|
|
345
|
+
console.log(`${c.dim}Type .help for available commands${c.reset}`);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ── Session-level commands (not forwarded to daemon) ──────────
|
|
350
|
+
if (cmdName === 'kill-all') return handleKillAll(ctx);
|
|
351
|
+
if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx);
|
|
352
|
+
|
|
353
|
+
// ── Verify commands → run-code translation ──────────────────
|
|
354
|
+
const verifyCommands = ['verify-text', 'verify-element', 'verify-value', 'verify-list'];
|
|
355
|
+
if (verifyCommands.includes(cmdName)) {
|
|
356
|
+
const translated = verifyToRunCode(cmdName, args._.slice(1));
|
|
357
|
+
if (translated) {
|
|
358
|
+
args = translated;
|
|
359
|
+
} else {
|
|
360
|
+
console.log(`${c.yellow}Usage: ${cmdName} <args>${c.reset}`);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Auto-resolve text to native Playwright locator ─────────
|
|
366
|
+
const refCommands = ['click', 'dblclick', 'hover', 'fill', 'select', 'check', 'uncheck'];
|
|
367
|
+
if (refCommands.includes(cmdName) && args._[1] && !/^e\d+$/.test(args._[1])) {
|
|
368
|
+
const textArg = args._[1];
|
|
369
|
+
const extraArgs = args._.slice(2);
|
|
370
|
+
const runCodeArgs = textToRunCode(cmdName, textArg, extraArgs);
|
|
371
|
+
if (runCodeArgs) {
|
|
372
|
+
ctx.log(`${c.dim}→ ${runCodeArgs._[1]}${c.reset}`);
|
|
373
|
+
args = runCodeArgs;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const startTime = performance.now();
|
|
378
|
+
try {
|
|
379
|
+
const result = await ctx.conn.run(args);
|
|
380
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
381
|
+
if (result?.text) {
|
|
382
|
+
const output = filterResponse(result.text);
|
|
383
|
+
if (output) console.log(output);
|
|
384
|
+
}
|
|
385
|
+
ctx.commandCount++;
|
|
386
|
+
ctx.session.record(line);
|
|
387
|
+
|
|
388
|
+
if (elapsed > 500) {
|
|
389
|
+
ctx.log(`${c.dim}(${elapsed}ms)${c.reset}`);
|
|
390
|
+
}
|
|
391
|
+
} catch (err) {
|
|
392
|
+
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
393
|
+
if (!ctx.conn.connected) {
|
|
394
|
+
console.log(`${c.yellow}Connection lost. Trying to reconnect...${c.reset}`);
|
|
395
|
+
try {
|
|
396
|
+
await ctx.conn.connect();
|
|
397
|
+
console.log(`${c.green}✓${c.reset} Reconnected. Try your command again.`);
|
|
398
|
+
} catch {
|
|
399
|
+
console.error(`${c.red}✗${c.reset} Could not reconnect. Use .reconnect or restart.`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ─── Replay mode (non-interactive, --replay flag) ───────────────────────────
|
|
406
|
+
|
|
407
|
+
export async function runReplayMode(ctx, replayFile, step) {
|
|
408
|
+
try {
|
|
409
|
+
const player = ctx.session.startReplay(replayFile, step);
|
|
410
|
+
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${replayFile}${c.reset} (${player.commands.length} commands)\n`);
|
|
411
|
+
while (!player.done) {
|
|
412
|
+
const cmd = player.next();
|
|
413
|
+
console.log(`${c.dim}${player.progress}${c.reset} ${cmd}`);
|
|
414
|
+
await processLine(ctx, cmd);
|
|
415
|
+
|
|
416
|
+
if (ctx.session.step && !player.done) {
|
|
417
|
+
await new Promise((resolve) => {
|
|
418
|
+
process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
|
|
419
|
+
process.stdin.once('data', () => {
|
|
420
|
+
process.stdout.write('\r\x1b[K');
|
|
421
|
+
resolve();
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
ctx.session.endReplay();
|
|
427
|
+
console.log(`\n${c.green}✓${c.reset} Replay complete`);
|
|
428
|
+
ctx.conn.close();
|
|
429
|
+
process.exit(0);
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
432
|
+
ctx.conn.close();
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Command loop (interactive) ─────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
export function startCommandLoop(ctx) {
|
|
440
|
+
let processing = false;
|
|
441
|
+
const commandQueue = [];
|
|
442
|
+
|
|
443
|
+
async function processQueue() {
|
|
444
|
+
if (processing) return;
|
|
445
|
+
processing = true;
|
|
446
|
+
while (commandQueue.length > 0) {
|
|
447
|
+
const line = commandQueue.shift();
|
|
448
|
+
await processLine(ctx, line);
|
|
449
|
+
if (line.trim()) {
|
|
450
|
+
try {
|
|
451
|
+
fs.mkdirSync(path.dirname(ctx.historyFile), { recursive: true });
|
|
452
|
+
fs.appendFileSync(ctx.historyFile, line.trim() + '\n');
|
|
453
|
+
} catch {}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
processing = false;
|
|
457
|
+
ctx.rl.prompt();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
ctx.rl.prompt();
|
|
461
|
+
|
|
462
|
+
ctx.rl.on('line', (line) => {
|
|
463
|
+
commandQueue.push(line);
|
|
464
|
+
processQueue();
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
ctx.rl.on('close', async () => {
|
|
468
|
+
while (processing || commandQueue.length > 0) {
|
|
469
|
+
await new Promise(r => setTimeout(r, 50));
|
|
470
|
+
}
|
|
471
|
+
ctx.log(`\n${c.dim}Disconnecting... (daemon stays running)${c.reset}`);
|
|
472
|
+
ctx.conn.close();
|
|
473
|
+
process.exit(0);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
let lastSigint = 0;
|
|
477
|
+
ctx.rl.on('SIGINT', () => {
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
if (now - lastSigint < 500) {
|
|
480
|
+
ctx.conn.close();
|
|
481
|
+
process.exit(0);
|
|
482
|
+
}
|
|
483
|
+
lastSigint = now;
|
|
484
|
+
ctx.log(`\n${c.dim}(Ctrl+C again to exit, or type .exit)${c.reset}`);
|
|
485
|
+
ctx.rl.prompt();
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ─── Prompt string ──────────────────────────────────────────────────────────
|
|
490
|
+
|
|
491
|
+
export function promptStr(ctx) {
|
|
492
|
+
const mode = ctx.session.mode;
|
|
493
|
+
const prefix = mode === 'recording' ? `${c.red}⏺${c.reset} `
|
|
494
|
+
: mode === 'paused' ? `${c.yellow}⏸${c.reset} `
|
|
495
|
+
: '';
|
|
496
|
+
return `${prefix}${c.cyan}pw>${c.reset} `;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ─── Tab completer ──────────────────────────────────────────────────────────
|
|
500
|
+
|
|
501
|
+
export function completer(line) {
|
|
502
|
+
const parts = line.split(/\s+/);
|
|
503
|
+
if (parts.length <= 1) {
|
|
504
|
+
const prefix = parts[0] || '';
|
|
505
|
+
const allNames = [...ALL_COMMANDS, ...Object.keys(ALIASES)];
|
|
506
|
+
const metas = ['.help', '.aliases', '.status', '.reconnect', '.exit',
|
|
507
|
+
'.record', '.save', '.replay', '.pause', '.discard'];
|
|
508
|
+
const hits = [...allNames, ...metas].filter(n => n.startsWith(prefix));
|
|
509
|
+
return [hits.length ? hits : allNames, prefix];
|
|
510
|
+
}
|
|
511
|
+
const cmd = ALIASES[parts[0]] || parts[0];
|
|
512
|
+
const helpText = COMMANDS[cmd]?.options || [];
|
|
513
|
+
const lastPart = parts[parts.length - 1];
|
|
514
|
+
if (lastPart.startsWith('--')) {
|
|
515
|
+
const hits = helpText.filter(o => o.startsWith(lastPart));
|
|
516
|
+
return [hits, lastPart];
|
|
517
|
+
}
|
|
518
|
+
return [[], line];
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ─── REPL ────────────────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
export async function startRepl(opts = {}) {
|
|
524
|
+
const sessionName = opts.session || 'default';
|
|
525
|
+
const silent = opts.silent || false;
|
|
526
|
+
const log = (...args) => { if (!silent) console.log(...args); };
|
|
527
|
+
|
|
528
|
+
log(`${c.bold}${c.magenta}🎭 Playwright REPL${c.reset} ${c.dim}v${replVersion}${c.reset}`);
|
|
529
|
+
log(`${c.dim}Session: ${sessionName} | Type .help for commands${c.reset}\n`);
|
|
530
|
+
|
|
531
|
+
// ─── Connect to daemon ───────────────────────────────────────────
|
|
532
|
+
|
|
533
|
+
const running = await isDaemonRunning(sessionName);
|
|
534
|
+
if (!running) {
|
|
535
|
+
await startDaemon(sessionName, opts);
|
|
536
|
+
await new Promise(r => setTimeout(r, 500));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const conn = new DaemonConnection(socketPath(sessionName), replVersion);
|
|
540
|
+
try {
|
|
541
|
+
await conn.connect();
|
|
542
|
+
log(`${c.green}✓${c.reset} Connected to daemon${running ? '' : ' (newly started)'}\n`);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
console.error(`${c.red}✗${c.reset} Failed to connect: ${err.message}`);
|
|
545
|
+
console.error(` Try: playwright-cli open`);
|
|
546
|
+
process.exit(1);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Session + readline ──────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
const session = new SessionManager();
|
|
552
|
+
const historyFile = path.join(daemonProfilesDir, '.repl-history');
|
|
553
|
+
const ctx = { conn, session, rl: null, sessionName, log, historyFile, commandCount: 0 };
|
|
554
|
+
|
|
555
|
+
// Auto-start recording if --record was passed
|
|
556
|
+
if (opts.record) {
|
|
557
|
+
const file = session.startRecording(opts.record);
|
|
558
|
+
log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const rl = readline.createInterface({
|
|
562
|
+
input: process.stdin,
|
|
563
|
+
output: process.stdout,
|
|
564
|
+
prompt: promptStr(ctx),
|
|
565
|
+
historySize: 500,
|
|
566
|
+
completer,
|
|
567
|
+
});
|
|
568
|
+
ctx.rl = rl;
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const hist = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean).reverse();
|
|
572
|
+
for (const line of hist) rl.history.push(line);
|
|
573
|
+
} catch {}
|
|
574
|
+
|
|
575
|
+
// ─── Start ───────────────────────────────────────────────────────
|
|
576
|
+
|
|
577
|
+
if (opts.replay) {
|
|
578
|
+
await runReplayMode(ctx, opts.replay, opts.step);
|
|
579
|
+
} else {
|
|
580
|
+
startCommandLoop(ctx);
|
|
581
|
+
}
|
|
582
|
+
}
|
package/src/resolve.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared dependencies and command vocabulary.
|
|
3
|
+
* No @playwright/cli — we start the daemon ourselves via daemon-launcher.cjs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
|
|
12
|
+
// ─── Own dependencies ────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const minimist = require('minimist');
|
|
15
|
+
|
|
16
|
+
const pkgUrl = new URL('../package.json', import.meta.url);
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(pkgUrl, 'utf-8'));
|
|
18
|
+
export const replVersion = pkg.version;
|
|
19
|
+
|
|
20
|
+
// Must match what daemon-launcher.cjs computes via require.resolve('../package.json')
|
|
21
|
+
export const packageLocation = fileURLToPath(pkgUrl);
|
|
22
|
+
|
|
23
|
+
// ─── Command vocabulary ──────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export const COMMANDS = {
|
|
26
|
+
'open': { desc: 'Open the browser', options: [] },
|
|
27
|
+
'close': { desc: 'Close the browser', options: [] },
|
|
28
|
+
'goto': { desc: 'Navigate to a URL', options: [] },
|
|
29
|
+
'go-back': { desc: 'Go back', options: [] },
|
|
30
|
+
'go-forward': { desc: 'Go forward', options: [] },
|
|
31
|
+
'reload': { desc: 'Reload page', options: [] },
|
|
32
|
+
'click': { desc: 'Click an element', options: ['--button', '--modifiers'] },
|
|
33
|
+
'dblclick': { desc: 'Double-click', options: ['--button', '--modifiers'] },
|
|
34
|
+
'fill': { desc: 'Fill a form field', options: ['--submit'] },
|
|
35
|
+
'type': { desc: 'Type text key by key', options: ['--submit'] },
|
|
36
|
+
'press': { desc: 'Press a keyboard key', options: [] },
|
|
37
|
+
'hover': { desc: 'Hover over element', options: [] },
|
|
38
|
+
'select': { desc: 'Select dropdown option', options: [] },
|
|
39
|
+
'check': { desc: 'Check a checkbox', options: [] },
|
|
40
|
+
'uncheck': { desc: 'Uncheck a checkbox', options: [] },
|
|
41
|
+
'upload': { desc: 'Upload a file', options: [] },
|
|
42
|
+
'drag': { desc: 'Drag and drop', options: [] },
|
|
43
|
+
'snapshot': { desc: 'Accessibility snapshot', options: ['--filename'] },
|
|
44
|
+
'screenshot': { desc: 'Take a screenshot', options: ['--filename', '--fullPage'] },
|
|
45
|
+
'eval': { desc: 'Evaluate JavaScript', options: [] },
|
|
46
|
+
'console': { desc: 'Console messages', options: ['--clear'] },
|
|
47
|
+
'network': { desc: 'Network requests', options: ['--clear', '--includeStatic'] },
|
|
48
|
+
'run-code': { desc: 'Run Playwright code', options: [] },
|
|
49
|
+
'tab-list': { desc: 'List tabs', options: [] },
|
|
50
|
+
'tab-new': { desc: 'New tab', options: [] },
|
|
51
|
+
'tab-close': { desc: 'Close tab', options: [] },
|
|
52
|
+
'tab-select': { desc: 'Select tab', options: [] },
|
|
53
|
+
'cookie-list': { desc: 'List cookies', options: [] },
|
|
54
|
+
'cookie-get': { desc: 'Get cookie', options: [] },
|
|
55
|
+
'cookie-set': { desc: 'Set cookie', options: [] },
|
|
56
|
+
'cookie-delete': { desc: 'Delete cookie', options: [] },
|
|
57
|
+
'cookie-clear': { desc: 'Clear cookies', options: [] },
|
|
58
|
+
'localstorage-list': { desc: 'List localStorage', options: [] },
|
|
59
|
+
'localstorage-get': { desc: 'Get localStorage', options: [] },
|
|
60
|
+
'localstorage-set': { desc: 'Set localStorage', options: [] },
|
|
61
|
+
'localstorage-delete': { desc: 'Delete localStorage', options: [] },
|
|
62
|
+
'localstorage-clear': { desc: 'Clear localStorage', options: [] },
|
|
63
|
+
'sessionstorage-list': { desc: 'List sessionStorage', options: [] },
|
|
64
|
+
'sessionstorage-get': { desc: 'Get sessionStorage', options: [] },
|
|
65
|
+
'sessionstorage-set': { desc: 'Set sessionStorage', options: [] },
|
|
66
|
+
'sessionstorage-delete':{ desc: 'Delete sessionStorage', options: [] },
|
|
67
|
+
'sessionstorage-clear': { desc: 'Clear sessionStorage', options: [] },
|
|
68
|
+
'state-save': { desc: 'Save storage state', options: ['--filename'] },
|
|
69
|
+
'state-load': { desc: 'Load storage state', options: [] },
|
|
70
|
+
'dialog-accept': { desc: 'Accept dialog', options: [] },
|
|
71
|
+
'dialog-dismiss': { desc: 'Dismiss dialog', options: [] },
|
|
72
|
+
'route': { desc: 'Add network route', options: [] },
|
|
73
|
+
'route-list': { desc: 'List routes', options: [] },
|
|
74
|
+
'unroute': { desc: 'Remove route', options: [] },
|
|
75
|
+
'resize': { desc: 'Resize window', options: [] },
|
|
76
|
+
'pdf': { desc: 'Save as PDF', options: ['--filename'] },
|
|
77
|
+
'config-print': { desc: 'Print config', options: [] },
|
|
78
|
+
'install-browser': { desc: 'Install browser', options: [] },
|
|
79
|
+
'list': { desc: 'List sessions', options: [] },
|
|
80
|
+
'close-all': { desc: 'Close all sessions', options: [] },
|
|
81
|
+
'kill-all': { desc: 'Kill all daemons', options: [] },
|
|
82
|
+
};
|