playwright-repl 0.26.0 → 0.27.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/README.md +26 -31
- package/dist/index.d.ts +2 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -3
- package/dist/playwright-repl.d.ts +1 -1
- package/dist/playwright-repl.js +5 -12
- package/dist/pw-cli.d.ts +0 -1
- package/dist/pw-cli.d.ts.map +1 -1
- package/dist/pw-cli.js +76 -6
- package/dist/recorder.d.ts +4 -91
- package/dist/recorder.d.ts.map +1 -1
- package/dist/recorder.js +4 -203
- package/dist/repl.d.ts +10 -8
- package/dist/repl.d.ts.map +1 -1
- package/dist/repl.js +277 -287
- package/package.json +3 -3
- package/dist/engine.d.ts +0 -66
- package/dist/engine.d.ts.map +0 -1
- package/dist/engine.js +0 -398
- package/dist/engine.js.map +0 -1
- package/dist/http-client.d.ts +0 -29
- package/dist/http-client.js +0 -173
- package/dist/index.js.map +0 -1
- package/dist/playwright-repl.js.map +0 -1
- package/dist/recorder.js.map +0 -1
- package/dist/repl.js.map +0 -1
package/dist/repl.js
CHANGED
|
@@ -8,9 +8,7 @@ import path from 'node:path';
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import os from 'node:os';
|
|
10
10
|
import http from 'node:http';
|
|
11
|
-
import { replVersion, parseInput, ALIASES, ALL_COMMANDS,
|
|
12
|
-
import { Engine } from './engine.js';
|
|
13
|
-
import { SessionManager } from './recorder.js';
|
|
11
|
+
import { replVersion, parseInput, ALIASES, ALL_COMMANDS, c, prettyJson, CDPRelayServer, COMMANDS, CATEGORIES, JS_CATEGORIES, filterResponse as filterResponseBase, resolveArgs, resolveCommand, } from '@playwright-repl/core';
|
|
14
12
|
// ─── Response filtering ─────────────────────────────────────────────────────
|
|
15
13
|
/** CLI wrapper: uses core filterResponse + ANSI-colors for error lines. */
|
|
16
14
|
export function filterResponse(text, cmdName, opts) {
|
|
@@ -23,16 +21,14 @@ export function filterResponse(text, cmdName, opts) {
|
|
|
23
21
|
return body.replace(/^(Error: .*)$/gm, `${c.red}$1${c.reset}`);
|
|
24
22
|
}
|
|
25
23
|
// ─── Meta-command handlers ──────────────────────────────────────────────────
|
|
26
|
-
export function showHelp(
|
|
24
|
+
export function showHelp() {
|
|
27
25
|
console.log(`\n${c.bold}Available commands:${c.reset}`);
|
|
28
26
|
for (const [cat, cmds] of Object.entries(CATEGORIES)) {
|
|
29
27
|
console.log(` ${c.bold}${cat}:${c.reset} ${cmds.join(', ')}`);
|
|
30
28
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
console.log(` ${c.dim}Type .help js for available Playwright methods${c.reset}`);
|
|
35
|
-
}
|
|
29
|
+
console.log(`\n${c.bold}JavaScript mode:${c.reset}`);
|
|
30
|
+
console.log(` ${c.dim}Use Playwright API directly: await page.title(), page.locator('h1').click(), ...${c.reset}`);
|
|
31
|
+
console.log(` ${c.dim}Type .help js for available Playwright methods${c.reset}`);
|
|
36
32
|
console.log(`\n ${c.dim}Type .help <command> for details, or .aliases for shortcuts${c.reset}`);
|
|
37
33
|
console.log(`\n${c.bold}REPL meta-commands:${c.reset}`);
|
|
38
34
|
console.log(` .aliases Show command aliases`);
|
|
@@ -48,12 +44,8 @@ export function showHelp(bridge = false) {
|
|
|
48
44
|
console.log(` .history clear Clear command history`);
|
|
49
45
|
console.log(` .exit Exit REPL\n`);
|
|
50
46
|
}
|
|
51
|
-
export function showCommandHelp(cmd
|
|
47
|
+
export function showCommandHelp(cmd) {
|
|
52
48
|
if (cmd === 'js' || cmd === 'javascript') {
|
|
53
|
-
if (!bridge) {
|
|
54
|
-
console.log(`\n${c.dim}JavaScript mode is only available in bridge mode (--bridge).${c.reset}\n`);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
49
|
console.log(`\n${c.bold}JavaScript mode — Playwright API:${c.reset}`);
|
|
58
50
|
console.log(` ${c.dim}Prefix with ${c.reset}await${c.dim} for async methods${c.reset}\n`);
|
|
59
51
|
console.log(` ${c.bold}Available globals:${c.reset}`);
|
|
@@ -127,6 +119,55 @@ export async function handleClose(ctx) {
|
|
|
127
119
|
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
128
120
|
}
|
|
129
121
|
}
|
|
122
|
+
// ─── Recording commands (start-recording, stop-recording, etc.) ────────────
|
|
123
|
+
function handleRecordingCommand(ctx, cmdName, args) {
|
|
124
|
+
const { session } = ctx;
|
|
125
|
+
if (cmdName === 'start-recording') {
|
|
126
|
+
try {
|
|
127
|
+
const filename = args._[1] || undefined;
|
|
128
|
+
const file = session.startRecording(filename);
|
|
129
|
+
console.log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
130
|
+
ctx.rl?.setPrompt(promptStr(ctx));
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (cmdName === 'stop-recording') {
|
|
138
|
+
try {
|
|
139
|
+
const { filename, count } = session.save();
|
|
140
|
+
console.log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
|
|
141
|
+
ctx.rl?.setPrompt(promptStr(ctx));
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
145
|
+
}
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
if (cmdName === 'pause-recording') {
|
|
149
|
+
try {
|
|
150
|
+
const paused = session.togglePause();
|
|
151
|
+
console.log(paused ? `${c.yellow}⏸${c.reset} Recording paused` : `${c.red}⏺${c.reset} Recording resumed`);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (cmdName === 'discard-recording') {
|
|
159
|
+
try {
|
|
160
|
+
session.discard();
|
|
161
|
+
console.log(`${c.yellow}Recording discarded${c.reset}`);
|
|
162
|
+
ctx.rl?.setPrompt(promptStr(ctx));
|
|
163
|
+
}
|
|
164
|
+
catch (err) {
|
|
165
|
+
console.log(`${c.yellow}${err.message}${c.reset}`);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
130
171
|
// ─── Session meta-commands (.record, .save, .pause, .discard, .replay) ──────
|
|
131
172
|
export function handleSessionCommand(ctx, line) {
|
|
132
173
|
const { session } = ctx;
|
|
@@ -213,13 +254,23 @@ export async function processLine(ctx, line) {
|
|
|
213
254
|
}
|
|
214
255
|
// ── Inline replay ──────────────────────────────────────────────
|
|
215
256
|
if (line.startsWith('.replay')) {
|
|
216
|
-
const
|
|
257
|
+
const parts = line.split(/\s+/);
|
|
258
|
+
const filename = parts[1];
|
|
217
259
|
if (!filename) {
|
|
218
|
-
console.log(`${c.yellow}Usage: .replay <filename
|
|
260
|
+
console.log(`${c.yellow}Usage: .replay <filename> [--variable key=value ...]${c.reset}`);
|
|
219
261
|
return;
|
|
220
262
|
}
|
|
263
|
+
// Parse --variable key=value args
|
|
264
|
+
const variables = {};
|
|
265
|
+
for (let i = 2; i < parts.length; i++) {
|
|
266
|
+
if (parts[i] === '--variable' && parts[i + 1]) {
|
|
267
|
+
const [key, ...rest] = parts[++i].split('=');
|
|
268
|
+
if (key && rest.length > 0)
|
|
269
|
+
variables[key] = rest.join('=');
|
|
270
|
+
}
|
|
271
|
+
}
|
|
221
272
|
try {
|
|
222
|
-
const player = ctx.session.startReplay(filename);
|
|
273
|
+
const player = ctx.session.startReplay(filename, false, Object.keys(variables).length > 0 ? variables : undefined);
|
|
223
274
|
console.log(`${c.blue}▶${c.reset} Replaying ${c.bold}${filename}${c.reset} (${player.commands.length} commands)\n`);
|
|
224
275
|
while (!player.done) {
|
|
225
276
|
const cmd = player.next();
|
|
@@ -254,6 +305,9 @@ export async function processLine(ctx, line) {
|
|
|
254
305
|
return handleKillAll(ctx);
|
|
255
306
|
if (cmdName === 'close' || cmdName === 'close-all')
|
|
256
307
|
return handleClose(ctx);
|
|
308
|
+
// ── Recording commands (start/stop/pause/discard-recording) ──
|
|
309
|
+
if (handleRecordingCommand(ctx, cmdName, args))
|
|
310
|
+
return;
|
|
257
311
|
// ── Command transformations (verify, role-based, text, run-code) ──
|
|
258
312
|
const resolved = resolveArgs(args);
|
|
259
313
|
if (resolved._[0] === args._[0] && cmdName === 'verify') {
|
|
@@ -278,6 +332,10 @@ export async function processLine(ctx, line) {
|
|
|
278
332
|
if (filtered !== null)
|
|
279
333
|
console.log(filtered);
|
|
280
334
|
}
|
|
335
|
+
// Feed snapshot to recorder for ref-to-locator resolution
|
|
336
|
+
if (cmdName === 'snapshot' && result?.text && !result.isError) {
|
|
337
|
+
ctx.session.setSnapshot(result.text);
|
|
338
|
+
}
|
|
281
339
|
if (result?.isError)
|
|
282
340
|
ctx.errors++;
|
|
283
341
|
ctx.commandCount++;
|
|
@@ -583,7 +641,7 @@ function attachGhostCompletion(rl, items) {
|
|
|
583
641
|
// ─── Multi-line completion check ────────────────────────────────────────────
|
|
584
642
|
/**
|
|
585
643
|
* Returns true if the code string has balanced brackets/parens/braces.
|
|
586
|
-
* Used to detect multi-line continuation
|
|
644
|
+
* Used to detect multi-line continuation.
|
|
587
645
|
*/
|
|
588
646
|
function isComplete(code) {
|
|
589
647
|
let depth = 0;
|
|
@@ -610,8 +668,8 @@ function isComplete(code) {
|
|
|
610
668
|
}
|
|
611
669
|
return depth <= 0;
|
|
612
670
|
}
|
|
613
|
-
// ───
|
|
614
|
-
function
|
|
671
|
+
// ─── Display helpers ────────────────────────────────────────────────────────
|
|
672
|
+
function displayResult(result, silent) {
|
|
615
673
|
if (result.image) {
|
|
616
674
|
const isPdf = result.image.startsWith('data:application/pdf');
|
|
617
675
|
const outDir = path.join(os.homedir(), isPdf ? 'pw-pdfs' : 'pw-screenshots');
|
|
@@ -621,8 +679,7 @@ function displayBridgeResult(result, silent) {
|
|
|
621
679
|
const outPath = path.join(outDir, `${prefix}-${Date.now()}${ext}`);
|
|
622
680
|
const b64 = result.image.replace(/^data:[^;]+;base64,/, '');
|
|
623
681
|
fs.writeFileSync(outPath, Buffer.from(b64, 'base64'));
|
|
624
|
-
|
|
625
|
-
console.log(`${isPdf ? 'PDF' : 'Screenshot'} saved to ${outPath}`);
|
|
682
|
+
console.log(`${isPdf ? 'PDF' : 'Screenshot'} saved to ${outPath}`);
|
|
626
683
|
}
|
|
627
684
|
else if (result.text) {
|
|
628
685
|
const t = result.text.trim();
|
|
@@ -634,109 +691,148 @@ function displayBridgeResult(result, silent) {
|
|
|
634
691
|
}
|
|
635
692
|
}
|
|
636
693
|
}
|
|
637
|
-
// ───
|
|
638
|
-
async function
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
694
|
+
// ─── Relay execution (keyword + JS) ─────────────────────────────────────────
|
|
695
|
+
const AsyncFn = Object.getPrototypeOf(async function () { }).constructor;
|
|
696
|
+
function isRelayExpression(code) {
|
|
697
|
+
const trimmed = code.trim();
|
|
698
|
+
if (trimmed.includes('\n'))
|
|
699
|
+
return false;
|
|
700
|
+
const withoutTrailing = trimmed.replace(/;$/, '');
|
|
701
|
+
if (withoutTrailing.includes(';'))
|
|
702
|
+
return false;
|
|
703
|
+
if (/^(const |let |var |if |for |while |switch |try |class |function )/.test(trimmed))
|
|
704
|
+
return false;
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
function formatRelayResult(value) {
|
|
708
|
+
if (value === undefined || value === null)
|
|
709
|
+
return 'Done';
|
|
710
|
+
if (typeof value === 'string')
|
|
711
|
+
return value;
|
|
712
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
713
|
+
return String(value);
|
|
714
|
+
try {
|
|
715
|
+
return JSON.stringify(value, null, 2);
|
|
644
716
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
return { passed: false, commandsRun, errorMsg: `failed at [${commandsRun}/${commands.length}]: ${cmd}` };
|
|
659
|
-
}
|
|
660
|
-
continue;
|
|
661
|
-
}
|
|
662
|
-
const result = await srv.run(cmd);
|
|
663
|
-
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
664
|
-
displayBridgeResult(result, silent);
|
|
665
|
-
log(`${c.dim}(${elapsed}ms)${c.reset}`);
|
|
666
|
-
if (result.isError) {
|
|
667
|
-
return { passed: false, commandsRun, errorMsg: `failed at [${commandsRun}/${commands.length}]: ${cmd}` };
|
|
717
|
+
catch {
|
|
718
|
+
return String(value);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function relayExec(command, page, context, expect) {
|
|
722
|
+
const trimmed = command.trim();
|
|
723
|
+
// Keyword command → resolveCommand → jsExpr → direct execution
|
|
724
|
+
const resolved = resolveCommand(trimmed);
|
|
725
|
+
if (resolved) {
|
|
726
|
+
try {
|
|
727
|
+
const fn = new AsyncFn('page', 'context', 'expect', resolved.jsExpr);
|
|
728
|
+
const result = await fn(page, context, expect);
|
|
729
|
+
return { text: formatRelayResult(result), isError: false };
|
|
668
730
|
}
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
|
|
672
|
-
process.stdin.once('data', () => { process.stdout.write('\r\x1b[K'); resolve(); });
|
|
673
|
-
});
|
|
731
|
+
catch (e) {
|
|
732
|
+
return { text: e instanceof Error ? e.message : String(e), isError: true };
|
|
674
733
|
}
|
|
675
734
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
735
|
+
// JavaScript → AsyncFunction
|
|
736
|
+
const script = isRelayExpression(trimmed)
|
|
737
|
+
? `return ${trimmed.replace(/;$/, '')}`
|
|
738
|
+
: trimmed;
|
|
739
|
+
try {
|
|
740
|
+
const fn = new AsyncFn('page', 'context', 'expect', script);
|
|
741
|
+
const result = await fn(page, context, expect);
|
|
742
|
+
return { text: formatRelayResult(result), isError: false };
|
|
743
|
+
}
|
|
744
|
+
catch (e) {
|
|
745
|
+
return { text: e instanceof Error ? e.message : String(e), isError: true };
|
|
746
|
+
}
|
|
679
747
|
}
|
|
680
|
-
|
|
748
|
+
// ─── Relay replay mode ──────────────────────────────────────────────────────
|
|
749
|
+
async function runRelayReplayMode(opts, relay, browser, page, context, expect) {
|
|
681
750
|
const silent = opts.silent || false;
|
|
682
751
|
const log = (...args) => { if (!silent)
|
|
683
752
|
console.log(...args); };
|
|
684
|
-
log('Waiting for extension to connect...');
|
|
685
|
-
try {
|
|
686
|
-
await srv.waitForConnection(30000);
|
|
687
|
-
}
|
|
688
|
-
catch (err) {
|
|
689
|
-
console.error(`${c.red}Error:${c.reset} ${err.message}`);
|
|
690
|
-
await srv.close();
|
|
691
|
-
process.exit(1);
|
|
692
|
-
}
|
|
693
|
-
log(`${c.green}✓${c.reset} Extension connected`);
|
|
694
753
|
const files = resolveReplayFiles(opts.replay, ['.pw', '.js']);
|
|
695
754
|
if (files.length === 0) {
|
|
696
755
|
console.error(`${c.red}Error:${c.reset} No .pw or .js files found`);
|
|
697
|
-
await
|
|
756
|
+
await browser.close().catch(() => { });
|
|
757
|
+
if (relay)
|
|
758
|
+
await relay.close();
|
|
698
759
|
process.exit(1);
|
|
699
760
|
}
|
|
700
|
-
if (files.length === 1) {
|
|
701
|
-
const { passed } = await runSingleBridgeFile(srv, files[0], opts.step || false, silent);
|
|
702
|
-
await srv.close();
|
|
703
|
-
process.exit(passed ? 0 : 1);
|
|
704
|
-
}
|
|
705
|
-
// Multi-file
|
|
706
|
-
log(`${c.blue}▶${c.reset} Running ${c.bold}${files.length}${c.reset} files\n`);
|
|
707
761
|
const results = [];
|
|
708
762
|
const totalStart = performance.now();
|
|
763
|
+
if (files.length > 1)
|
|
764
|
+
log(`${c.blue}▶${c.reset} Running ${c.bold}${files.length}${c.reset} files\n`);
|
|
709
765
|
for (const file of files) {
|
|
710
766
|
const basename = path.basename(file);
|
|
711
|
-
|
|
767
|
+
const commands = loadReplayFile(file);
|
|
768
|
+
const prefixed = files.length > 1;
|
|
769
|
+
if (prefixed)
|
|
770
|
+
log(`${c.blue}▶${c.reset} ${c.bold}${basename}${c.reset}`);
|
|
771
|
+
else
|
|
772
|
+
log(`${c.blue}▶${c.reset} Replaying ${c.bold}${file}${c.reset} (${commands.length} commands)\n`);
|
|
712
773
|
const fileStart = performance.now();
|
|
713
|
-
|
|
774
|
+
let commandsRun = 0;
|
|
775
|
+
let passed = true;
|
|
776
|
+
let errorMsg;
|
|
777
|
+
for (const cmd of commands) {
|
|
778
|
+
commandsRun++;
|
|
779
|
+
const indent = prefixed ? ' ' : '';
|
|
780
|
+
log(`${indent}${c.dim}[${commandsRun}/${commands.length}]${c.reset} ${cmd}`);
|
|
781
|
+
const startTime = performance.now();
|
|
782
|
+
const result = await relayExec(cmd, page, context, expect);
|
|
783
|
+
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
784
|
+
if (result.text && result.text !== 'Done') {
|
|
785
|
+
log(`${indent}${result.isError ? `${c.red}${result.text}${c.reset}` : result.text}`);
|
|
786
|
+
}
|
|
787
|
+
log(`${indent}${c.dim}(${elapsed}ms)${c.reset}`);
|
|
788
|
+
if (result.isError) {
|
|
789
|
+
passed = false;
|
|
790
|
+
errorMsg = `failed at [${commandsRun}/${commands.length}]: ${cmd}`;
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
if (opts.step && commandsRun < commands.length) {
|
|
794
|
+
await new Promise((resolve) => {
|
|
795
|
+
process.stdout.write(`${c.dim} Press Enter to continue...${c.reset}`);
|
|
796
|
+
process.stdin.once('data', () => { process.stdout.write('\r\x1b[K'); resolve(); });
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
714
800
|
const fileElapsed = ((performance.now() - fileStart) / 1000).toFixed(1);
|
|
715
|
-
|
|
716
|
-
|
|
801
|
+
if (prefixed) {
|
|
802
|
+
const status = passed ? `${c.green}PASS${c.reset}` : `${c.red}FAIL${c.reset}`;
|
|
803
|
+
log(` ${status} ${basename} ${c.dim}(${fileElapsed}s)${c.reset}\n`);
|
|
804
|
+
}
|
|
805
|
+
else if (passed) {
|
|
806
|
+
log(`\n${c.green}✓${c.reset} Replay complete`);
|
|
807
|
+
}
|
|
717
808
|
results.push({ file: basename, passed, commands: commandsRun, error: errorMsg });
|
|
718
809
|
}
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
810
|
+
// Multi-file summary
|
|
811
|
+
if (files.length > 1) {
|
|
812
|
+
const totalElapsed = ((performance.now() - totalStart) / 1000).toFixed(1);
|
|
813
|
+
const passCount = results.filter(r => r.passed).length;
|
|
814
|
+
const failCount = results.filter(r => !r.passed).length;
|
|
815
|
+
log(`${c.bold}─── Results ───${c.reset}`);
|
|
816
|
+
for (const r of results) {
|
|
817
|
+
const icon = r.passed ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
|
|
818
|
+
log(` ${icon} ${r.file}${r.error ? ` — ${r.error}` : ''}`);
|
|
819
|
+
}
|
|
820
|
+
log(`\n${passCount} passed, ${failCount} failed (${results.length} total, ${totalElapsed}s)`);
|
|
726
821
|
}
|
|
727
|
-
|
|
728
|
-
|
|
822
|
+
await browser.close().catch(() => { });
|
|
823
|
+
if (relay)
|
|
824
|
+
await relay.close();
|
|
825
|
+
const failCount = results.filter(r => !r.passed).length;
|
|
729
826
|
process.exit(failCount > 0 ? 1 : 0);
|
|
730
827
|
}
|
|
731
|
-
// ───
|
|
732
|
-
async function
|
|
828
|
+
// ─── Relay REPL loop ────────────────────────────────────────────────────────
|
|
829
|
+
async function startRelayLoop(opts, relay, browser, page, context, expect) {
|
|
733
830
|
const silent = opts.silent || false;
|
|
734
831
|
const log = (...args) => { if (!silent)
|
|
735
832
|
console.log(...args); };
|
|
736
833
|
const historyDir = path.join(os.homedir(), '.playwright-repl');
|
|
737
834
|
const historyFile = path.join(historyDir, '.repl-history');
|
|
738
|
-
const
|
|
739
|
-
const promptReady = `${c.cyan}pw>${c.reset} `;
|
|
835
|
+
const promptReady = `${c.cyan}relay>${c.reset} `;
|
|
740
836
|
const promptCont = `${c.dim}...${c.reset} `;
|
|
741
837
|
const rl = readline.createInterface({
|
|
742
838
|
input: process.stdin,
|
|
@@ -750,12 +846,10 @@ async function startBridgeLoop(opts, srv) {
|
|
|
750
846
|
rl.history.push(line);
|
|
751
847
|
}
|
|
752
848
|
catch { /* ignore */ }
|
|
753
|
-
attachGhostCompletion(rl, buildCompletionItems());
|
|
754
849
|
let buffer = '';
|
|
755
850
|
let processing = false;
|
|
756
851
|
const commandQueue = [];
|
|
757
852
|
async function handleLine(line) {
|
|
758
|
-
// Multi-line accumulation
|
|
759
853
|
buffer = buffer ? buffer + '\n' + line : line;
|
|
760
854
|
if (!isComplete(buffer)) {
|
|
761
855
|
rl.setPrompt(promptCont);
|
|
@@ -768,58 +862,33 @@ async function startBridgeLoop(opts, srv) {
|
|
|
768
862
|
return;
|
|
769
863
|
// Meta-commands
|
|
770
864
|
if (command === '.exit' || command === '.quit') {
|
|
771
|
-
await
|
|
865
|
+
await browser.close().catch(() => { });
|
|
866
|
+
if (relay)
|
|
867
|
+
await relay.close();
|
|
772
868
|
process.exit(0);
|
|
773
869
|
}
|
|
774
870
|
if (command === '.clear') {
|
|
775
871
|
console.clear();
|
|
776
872
|
return;
|
|
777
873
|
}
|
|
778
|
-
if (command === '.history clear') {
|
|
779
|
-
sessionHistory.length = 0;
|
|
780
|
-
log('History cleared.');
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
if (command === '.history') {
|
|
784
|
-
log(sessionHistory.length ? sessionHistory.join('\n') : '(no history)');
|
|
785
|
-
return;
|
|
786
|
-
}
|
|
787
|
-
if (command === '.help' || command === '?') {
|
|
788
|
-
showHelp(true);
|
|
789
|
-
return;
|
|
790
|
-
}
|
|
791
|
-
if (command.startsWith('.help ')) {
|
|
792
|
-
showCommandHelp(command.slice(6).trim(), true);
|
|
793
|
-
return;
|
|
794
|
-
}
|
|
795
|
-
if (command === '.aliases') {
|
|
796
|
-
showAliases();
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
874
|
// Record to history
|
|
800
|
-
sessionHistory.push(command);
|
|
801
875
|
try {
|
|
802
876
|
fs.mkdirSync(path.dirname(historyFile), { recursive: true });
|
|
803
877
|
fs.appendFileSync(historyFile, command + '\n');
|
|
804
878
|
}
|
|
805
|
-
catch
|
|
806
|
-
console.error(`${c.dim}Warning: could not write history: ${err.message}${c.reset}`);
|
|
807
|
-
}
|
|
808
|
-
// ── Local commands (video, etc. — need Node.js filesystem) ─────
|
|
809
|
-
const localResult = await handleLocalCommand(command, srv.context);
|
|
810
|
-
if (localResult) {
|
|
811
|
-
log(localResult.isError ? `${c.red}${localResult.text}${c.reset}` : localResult.text);
|
|
812
|
-
return;
|
|
813
|
-
}
|
|
814
|
-
if (!srv.connected) {
|
|
815
|
-
log(`${c.yellow}[not connected] Waiting for extension...${c.reset}`);
|
|
816
|
-
return;
|
|
817
|
-
}
|
|
879
|
+
catch { /* ignore */ }
|
|
818
880
|
const startTime = performance.now();
|
|
819
|
-
const
|
|
820
|
-
const result = await srv.run(command, runOpts);
|
|
881
|
+
const result = await relayExec(command, page, context, expect);
|
|
821
882
|
const elapsed = (performance.now() - startTime).toFixed(0);
|
|
822
|
-
|
|
883
|
+
if (result.text) {
|
|
884
|
+
// Pass command name so filterResponse keeps the right sections
|
|
885
|
+
const parsed = parseInput(command);
|
|
886
|
+
const cmdName = parsed?._[0];
|
|
887
|
+
const filtered = filterResponse(result.text, cmdName);
|
|
888
|
+
if (filtered !== null) {
|
|
889
|
+
log(result.isError ? `${c.red}${filtered}${c.reset}` : filtered);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
823
892
|
log(`${c.dim}(${elapsed}ms)${c.reset}`);
|
|
824
893
|
}
|
|
825
894
|
async function processQueue() {
|
|
@@ -832,23 +901,14 @@ async function startBridgeLoop(opts, srv) {
|
|
|
832
901
|
processing = false;
|
|
833
902
|
rl.prompt();
|
|
834
903
|
}
|
|
835
|
-
// Print a status message above the current prompt line
|
|
836
|
-
function printStatus(msg) {
|
|
837
|
-
process.stdout.write('\r\x1b[K'); // clear current prompt line
|
|
838
|
-
console.log(msg);
|
|
839
|
-
rl.prompt(true);
|
|
840
|
-
}
|
|
841
|
-
if (!silent) {
|
|
842
|
-
srv.onConnect(() => printStatus(`${c.green}✓${c.reset} Extension connected`));
|
|
843
|
-
srv.onDisconnect(() => printStatus(`${c.yellow}Extension disconnected${c.reset}`));
|
|
844
|
-
srv.onEvent((event) => {
|
|
845
|
-
if (event.type === 'tab-attached' && event.url)
|
|
846
|
-
printStatus(`${c.green}✓${c.reset} Attached to tab: ${event.url}`);
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
904
|
rl.prompt();
|
|
850
905
|
rl.on('line', (line) => { commandQueue.push(line); processQueue(); });
|
|
851
|
-
rl.on('close', async () => {
|
|
906
|
+
rl.on('close', async () => {
|
|
907
|
+
await browser.close().catch(() => { });
|
|
908
|
+
if (relay)
|
|
909
|
+
await relay.close();
|
|
910
|
+
process.exit(0);
|
|
911
|
+
});
|
|
852
912
|
rl.on('SIGINT', () => {
|
|
853
913
|
if (buffer) {
|
|
854
914
|
buffer = '';
|
|
@@ -970,9 +1030,14 @@ async function tryHttpCommand(command, port) {
|
|
|
970
1030
|
req.write(body);
|
|
971
1031
|
req.end();
|
|
972
1032
|
});
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
1033
|
+
if (result.image) {
|
|
1034
|
+
displayResult(result, false);
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
const text = (result.text ?? '').replace(/^\s*\n+|\n+\s*$/g, '');
|
|
1038
|
+
if (text)
|
|
1039
|
+
process.stdout.write(text + '\n');
|
|
1040
|
+
}
|
|
976
1041
|
process.exit(result.isError ? 1 : 0);
|
|
977
1042
|
}
|
|
978
1043
|
catch (e) {
|
|
@@ -1000,145 +1065,70 @@ export async function startRepl(opts = {}) {
|
|
|
1000
1065
|
process.exit(1);
|
|
1001
1066
|
}
|
|
1002
1067
|
log(`${c.bold}${c.magenta}🎭 Playwright REPL${c.reset} ${c.dim}v${replVersion}${c.reset}`);
|
|
1003
|
-
//
|
|
1004
|
-
if (!opts.
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
await waitForShutdown(log);
|
|
1034
|
-
await conn.close();
|
|
1035
|
-
process.exit(0);
|
|
1036
|
-
}
|
|
1037
|
-
log(`${c.dim}Type .help for commands, JavaScript supported${c.reset}\n`);
|
|
1038
|
-
if (opts.replay && opts.replay.length > 0) {
|
|
1039
|
-
await runBridgeReplayMode(opts, conn);
|
|
1040
|
-
}
|
|
1041
|
-
else {
|
|
1042
|
-
await startBridgeLoop(opts, conn);
|
|
1043
|
-
}
|
|
1044
|
-
return;
|
|
1045
|
-
}
|
|
1046
|
-
catch (err) {
|
|
1047
|
-
log(`${c.yellow}⚠${c.reset} ${c.dim}Could not launch with extension: ${err.message}${c.reset}`);
|
|
1048
|
-
log(`${c.dim}Falling back to standard engine...${c.reset}\n`);
|
|
1068
|
+
// Default to standalone mode (launch own browser)
|
|
1069
|
+
if (!opts.relay && !opts.connect) {
|
|
1070
|
+
opts.relay = true;
|
|
1071
|
+
}
|
|
1072
|
+
// ─── Relay mode ───────────────────────────────────────────────────
|
|
1073
|
+
if (opts.relay || opts.connect) {
|
|
1074
|
+
const dynamicImport = Function('m', 'return import(m)');
|
|
1075
|
+
const { chromium } = await dynamicImport('playwright');
|
|
1076
|
+
const { expect: pwExpect } = await dynamicImport('@playwright/test').catch(() => ({ expect: undefined }));
|
|
1077
|
+
let browser;
|
|
1078
|
+
let relayCtx;
|
|
1079
|
+
let relayPage;
|
|
1080
|
+
let relay = null;
|
|
1081
|
+
const headless = opts.headed === false; // explicit --headless
|
|
1082
|
+
if (opts.connect) {
|
|
1083
|
+
// Connect mode: attach to existing Chrome via extension + CDP relay
|
|
1084
|
+
relay = new CDPRelayServer();
|
|
1085
|
+
await relay.start();
|
|
1086
|
+
log(`CDP relay listening on ${relay.cdpEndpoint()}`);
|
|
1087
|
+
log(`Extension endpoint: ${relay.relayEndpoint()}`);
|
|
1088
|
+
log('Waiting for extension to connect...');
|
|
1089
|
+
await relay.waitForExtension(30000);
|
|
1090
|
+
log(`${c.green}✓${c.reset} Extension connected`);
|
|
1091
|
+
browser = await chromium.connectOverCDP(relay.cdpEndpoint());
|
|
1092
|
+
relayCtx = browser.contexts()[0];
|
|
1093
|
+
relayPage = relayCtx.pages()[0];
|
|
1094
|
+
if (!relayPage) {
|
|
1095
|
+
console.error(`${c.red}✗${c.reset} No page found — make sure a tab is open in Chrome`);
|
|
1096
|
+
await relay.close();
|
|
1097
|
+
process.exit(1);
|
|
1049
1098
|
}
|
|
1050
1099
|
}
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1100
|
+
else {
|
|
1101
|
+
// Standalone mode (default): launch own browser
|
|
1102
|
+
log(`${c.dim}Launching ${headless ? 'headless' : 'headed'} browser...${c.reset}`);
|
|
1103
|
+
browser = await chromium.launch({
|
|
1104
|
+
headless,
|
|
1105
|
+
args: ['--no-first-run', '--no-default-browser-check'],
|
|
1106
|
+
});
|
|
1107
|
+
relayCtx = await browser.newContext();
|
|
1108
|
+
relayPage = await relayCtx.newPage();
|
|
1109
|
+
}
|
|
1110
|
+
log(`${c.green}✓${c.reset} Connected to page: ${relayPage.url()}`);
|
|
1111
|
+
const cleanup = async () => {
|
|
1112
|
+
await browser.close().catch(() => { });
|
|
1113
|
+
if (relay)
|
|
1114
|
+
await relay.close();
|
|
1115
|
+
};
|
|
1058
1116
|
if (opts.command) {
|
|
1059
|
-
await
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1117
|
+
const result = await relayExec(opts.command, relayPage, relayCtx, pwExpect);
|
|
1118
|
+
if (result.text)
|
|
1119
|
+
process.stdout.write(result.text + '\n');
|
|
1120
|
+
await cleanup();
|
|
1063
1121
|
process.exit(result.isError ? 1 : 0);
|
|
1064
1122
|
}
|
|
1065
|
-
// Start HTTP server if --http
|
|
1066
|
-
if (opts.http) {
|
|
1067
|
-
try {
|
|
1068
|
-
await startHttpServer(opts.httpPort ?? DEFAULT_HTTP_PORT, srv, log);
|
|
1069
|
-
}
|
|
1070
|
-
catch (e) {
|
|
1071
|
-
log(`${c.yellow}⚠${c.reset} HTTP server failed: ${e.message}`);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
1123
|
if (opts.replay && opts.replay.length > 0) {
|
|
1075
|
-
await
|
|
1076
|
-
|
|
1077
|
-
else if (opts.http && !opts.interactive) {
|
|
1078
|
-
log(`${c.dim}Console mode — send commands via HTTP. Ctrl+C to exit.${c.reset}\n`);
|
|
1079
|
-
await waitForShutdown(log);
|
|
1080
|
-
srv.close();
|
|
1081
|
-
process.exit(0);
|
|
1082
|
-
}
|
|
1083
|
-
else {
|
|
1084
|
-
log('Waiting for extension to connect...');
|
|
1085
|
-
await startBridgeLoop(opts, srv);
|
|
1124
|
+
await runRelayReplayMode(opts, relay, browser, relayPage, relayCtx, pwExpect);
|
|
1125
|
+
return;
|
|
1086
1126
|
}
|
|
1127
|
+
log(`${c.dim}Type .help for commands, JavaScript supported${c.reset}\n`);
|
|
1128
|
+
await startRelayLoop(opts, relay, browser, relayPage, relayCtx, pwExpect);
|
|
1087
1129
|
return;
|
|
1088
1130
|
}
|
|
1089
|
-
//
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
try {
|
|
1093
|
-
await conn.start(opts);
|
|
1094
|
-
log(`${c.green}✓${c.reset} Browser ready\n`);
|
|
1095
|
-
}
|
|
1096
|
-
catch (err) {
|
|
1097
|
-
console.error(`${c.red}✗${c.reset} Failed to start: ${err.message}`);
|
|
1098
|
-
process.exit(1);
|
|
1099
|
-
}
|
|
1100
|
-
if (opts.command) {
|
|
1101
|
-
const parsed = parseInput(opts.command);
|
|
1102
|
-
if (!parsed) {
|
|
1103
|
-
process.stderr.write('Invalid command\n');
|
|
1104
|
-
conn.close();
|
|
1105
|
-
process.exit(1);
|
|
1106
|
-
return; // unreachable, but satisfies TS control-flow
|
|
1107
|
-
}
|
|
1108
|
-
const result = await conn.run(parsed);
|
|
1109
|
-
process.stdout.write((result.text ?? '') + '\n');
|
|
1110
|
-
conn.close();
|
|
1111
|
-
process.exit(result.isError ? 1 : 0);
|
|
1112
|
-
}
|
|
1113
|
-
// ─── Session + readline ──────────────────────────────────────────
|
|
1114
|
-
const session = new SessionManager();
|
|
1115
|
-
const historyDir = path.join(os.homedir(), '.playwright-repl');
|
|
1116
|
-
const historyFile = path.join(historyDir, '.repl-history');
|
|
1117
|
-
const ctx = { conn, session, rl: null, opts, log, historyFile, sessionHistory: [], commandCount: 0, errors: 0 };
|
|
1118
|
-
// Auto-start recording if --record was passed
|
|
1119
|
-
if (opts.record) {
|
|
1120
|
-
const file = session.startRecording(opts.record);
|
|
1121
|
-
log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
|
|
1122
|
-
}
|
|
1123
|
-
const rl = readline.createInterface({
|
|
1124
|
-
input: process.stdin,
|
|
1125
|
-
output: process.stdout,
|
|
1126
|
-
prompt: promptStr(ctx),
|
|
1127
|
-
historySize: 500,
|
|
1128
|
-
});
|
|
1129
|
-
ctx.rl = rl;
|
|
1130
|
-
try {
|
|
1131
|
-
const hist = fs.readFileSync(historyFile, 'utf-8').split('\n').filter(Boolean).reverse();
|
|
1132
|
-
for (const line of hist)
|
|
1133
|
-
rl.history.push(line);
|
|
1134
|
-
}
|
|
1135
|
-
catch { /* ignore */ }
|
|
1136
|
-
attachGhostCompletion(rl, buildCompletionItems());
|
|
1137
|
-
// ─── Start ───────────────────────────────────────────────────────
|
|
1138
|
-
if (opts.replay && opts.replay.length > 0) {
|
|
1139
|
-
await runMultiReplayMode(ctx, opts.replay, opts.step || false);
|
|
1140
|
-
}
|
|
1141
|
-
else {
|
|
1142
|
-
startCommandLoop(ctx);
|
|
1143
|
-
}
|
|
1131
|
+
// No mode matched — this shouldn't happen since relay is now the default
|
|
1132
|
+
console.error(`${c.red}✗${c.reset} No execution mode selected. Use --relay or --connect.`);
|
|
1133
|
+
process.exit(1);
|
|
1144
1134
|
}
|