playwright-repl 0.2.0 → 0.3.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/CHANGELOG.md +52 -1
- package/README.md +50 -2
- package/examples/ghost-completion-demo.mjs +122 -0
- package/package.json +1 -1
- package/src/completion-data.mjs +53 -0
- package/src/index.mjs +16 -15
- package/src/page-scripts.mjs +87 -0
- package/src/parser.mjs +151 -141
- package/src/repl.mjs +143 -147
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v0.3.0 — Page Scripts & run-code
|
|
4
|
+
|
|
5
|
+
**2026-02-17**
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
- **`run-code` auto-wrap**: Type Playwright code directly — no boilerplate needed
|
|
10
|
+
- `run-code page.title()` → auto-wraps as `async (page) => { return await page.title() }`
|
|
11
|
+
- `run-code await page.click('a')` → wraps without `return` for statement keywords
|
|
12
|
+
- `run-code async (page) => ...` → pass-through for full function expressions
|
|
13
|
+
- **Raw parsing for `run-code` / `eval`**: Expressions are preserved as a single raw string — parentheses, braces, quotes, and operators no longer get split by the tokenizer
|
|
14
|
+
- **Red error messages**: Daemon errors (`### Error` sections) now display in red
|
|
15
|
+
- **Verify commands**: `verify-text`, `verify-element`, `verify-value`, `verify-list` now use real functions via `buildRunCode` instead of template strings
|
|
16
|
+
|
|
17
|
+
### Refactored
|
|
18
|
+
|
|
19
|
+
- **Page scripts module** (`src/page-scripts.mjs`): Extracted all run-code templates into real async functions (`verifyText`, `actionByText`, `fillByText`, etc.) — testable, readable, no manual escaping
|
|
20
|
+
- **`buildRunCode` helper**: Converts real functions to daemon-compatible code strings using `fn.toString()` + `JSON.stringify()`
|
|
21
|
+
- **Consolidated `actionByText`**: Merged `clickByText`, `dblclickByText`, `hoverByText` into a single function with dynamic dispatch via `loc[action]()`
|
|
22
|
+
- **Removed `esc()` helper and ~150 lines of template strings** from `repl.mjs`
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- **Ghost completion for prefix commands**: Typing "close" now correctly cycles to both "close" and "close-all" (previously only showed "close-all")
|
|
27
|
+
- **Removed Tab-on-empty-line**: No longer shows all commands when pressing Tab on empty input
|
|
28
|
+
|
|
29
|
+
### Tests
|
|
30
|
+
|
|
31
|
+
- 100 new tests (154 → 254 total across 13 test files)
|
|
32
|
+
- New `test/page-scripts.test.mjs` — 21 tests for page-script functions and `buildRunCode`
|
|
33
|
+
- Daemon-compatibility test: verifies generated code is a valid function expression
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## v0.2.1 — Ghost Completion
|
|
38
|
+
|
|
39
|
+
**2026-02-17**
|
|
40
|
+
|
|
41
|
+
### Features
|
|
42
|
+
|
|
43
|
+
- **Ghost completion**: Fish-shell style inline suggestions — type a prefix and see dimmed suggestion text after the cursor
|
|
44
|
+
- **Tab** cycles through matches (e.g., `go` → goto, go-back, go-forward)
|
|
45
|
+
- **Right Arrow** accepts the current suggestion
|
|
46
|
+
- Aliases excluded from ghost suggestions (still work when typed)
|
|
47
|
+
|
|
48
|
+
### Removed
|
|
49
|
+
|
|
50
|
+
- Removed readline's built-in Tab completer (replaced entirely by ghost completion)
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
3
54
|
## v0.2.0 — MCP Server
|
|
4
55
|
|
|
5
56
|
**2026-02-16**
|
|
@@ -100,7 +151,7 @@ First public release of playwright-repl — an interactive REPL for Playwright b
|
|
|
100
151
|
- Connects to Playwright's MCP terminal daemon over Unix socket / named pipe
|
|
101
152
|
- Wire-compatible with `playwright-cli` — produces identical JSON messages
|
|
102
153
|
- Requires `playwright >= 1.59.0-alpha` (daemon code in `lib/mcp/terminal/`)
|
|
103
|
-
- 218 tests
|
|
154
|
+
- 218 tests at initial release
|
|
104
155
|
|
|
105
156
|
### Known Limitations
|
|
106
157
|
|
package/README.md
CHANGED
|
@@ -210,10 +210,10 @@ Or with a visible browser:
|
|
|
210
210
|
|---------|-------|-------------|
|
|
211
211
|
| `snapshot` | `s` | Accessibility tree with element refs |
|
|
212
212
|
| `screenshot` | `ss` | Take a screenshot |
|
|
213
|
-
| `eval <expr>` | `e` | Evaluate JavaScript |
|
|
213
|
+
| `eval <expr>` | `e` | Evaluate JavaScript in browser context |
|
|
214
214
|
| `console` | `con` | Browser console messages |
|
|
215
215
|
| `network` | `net` | Network requests log |
|
|
216
|
-
| `run-code <code>` | — | Run Playwright code
|
|
216
|
+
| `run-code <code>` | — | Run Playwright code with `page` object |
|
|
217
217
|
|
|
218
218
|
### Assertions
|
|
219
219
|
|
|
@@ -382,6 +382,54 @@ playwright-repl --replay examples/04-replay-session.pw --step --headed
|
|
|
382
382
|
playwright-repl --replay examples/05-ci-pipe.pw --silent
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
+
## eval & run-code
|
|
386
|
+
|
|
387
|
+
Two ways to run custom code from the REPL:
|
|
388
|
+
|
|
389
|
+
### eval — Browser Context
|
|
390
|
+
|
|
391
|
+
Runs JavaScript inside the browser page (via `page.evaluate`). Use browser globals like `document`, `window`, `location`:
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
pw> eval document.title
|
|
395
|
+
"Installation | Playwright"
|
|
396
|
+
|
|
397
|
+
pw> eval window.location.href
|
|
398
|
+
"https://playwright.dev/docs/intro"
|
|
399
|
+
|
|
400
|
+
pw> eval document.querySelectorAll('a').length
|
|
401
|
+
42
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
### run-code — Playwright API
|
|
405
|
+
|
|
406
|
+
Runs code with full access to the Playwright `page` object. The REPL auto-wraps your code — just write the body:
|
|
407
|
+
|
|
408
|
+
```
|
|
409
|
+
pw> run-code page.url()
|
|
410
|
+
→ async (page) => { return await page.url() }
|
|
411
|
+
"https://playwright.dev/docs/intro"
|
|
412
|
+
|
|
413
|
+
pw> run-code page.locator('h1').textContent()
|
|
414
|
+
→ async (page) => { return await page.locator('h1').textContent() }
|
|
415
|
+
"Installation"
|
|
416
|
+
|
|
417
|
+
pw> run-code await page.locator('.nav a').allTextContents()
|
|
418
|
+
→ async (page) => { await page.locator('.nav a').allTextContents() }
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
For multiple statements, use semicolons:
|
|
422
|
+
|
|
423
|
+
```
|
|
424
|
+
pw> run-code const u = await page.url(); const t = await page.title(); return {u, t}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
Full function expressions also work:
|
|
428
|
+
|
|
429
|
+
```
|
|
430
|
+
pw> run-code async (page) => { await page.waitForSelector('.loaded'); return await page.title(); }
|
|
431
|
+
```
|
|
432
|
+
|
|
385
433
|
## Architecture
|
|
386
434
|
|
|
387
435
|

|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Ghost completion demo — standalone test for inline suggestions.
|
|
4
|
+
*
|
|
5
|
+
* Run: node examples/ghost-completion-demo.mjs
|
|
6
|
+
*
|
|
7
|
+
* Type a prefix (e.g. "go") and see dimmed suggestion text.
|
|
8
|
+
* Tab cycles through matches, Right Arrow accepts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
|
|
13
|
+
const COMMANDS = [
|
|
14
|
+
'click', 'check', 'close', 'console', 'cookie-get', 'cookie-list',
|
|
15
|
+
'dblclick', 'drag', 'eval', 'fill', 'goto', 'go-back', 'go-forward',
|
|
16
|
+
'hover', 'network', 'open', 'press', 'reload', 'screenshot', 'select',
|
|
17
|
+
'snapshot', 'type', 'uncheck', 'upload',
|
|
18
|
+
'.help', '.aliases', '.status', '.exit',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const rl = readline.createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
prompt: '\x1b[36mpw>\x1b[0m ',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ─── Ghost completion via _ttyWrite ─────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
let ghost = '';
|
|
30
|
+
let matches = [];
|
|
31
|
+
let matchIdx = 0;
|
|
32
|
+
|
|
33
|
+
function getMatches(input) {
|
|
34
|
+
if (input.length > 0 && !input.includes(' ')) {
|
|
35
|
+
return COMMANDS.filter(cmd => cmd.startsWith(input) && cmd !== input);
|
|
36
|
+
}
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderGhost(suffix) {
|
|
41
|
+
ghost = suffix;
|
|
42
|
+
rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const origTtyWrite = rl._ttyWrite.bind(rl);
|
|
46
|
+
rl._ttyWrite = function (s, key) {
|
|
47
|
+
if (ghost && key) {
|
|
48
|
+
// Right-arrow-at-end accepts ghost suggestion
|
|
49
|
+
if (key.name === 'right' && rl.cursor === rl.line.length) {
|
|
50
|
+
const text = ghost;
|
|
51
|
+
rl.output.write('\x1b[K');
|
|
52
|
+
ghost = '';
|
|
53
|
+
matches = [];
|
|
54
|
+
rl._insertString(text);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Tab cycles through matches (or accepts if only one)
|
|
59
|
+
if (key.name === 'tab') {
|
|
60
|
+
if (matches.length > 1) {
|
|
61
|
+
rl.output.write('\x1b[K');
|
|
62
|
+
matchIdx = (matchIdx + 1) % matches.length;
|
|
63
|
+
const input = rl.line || '';
|
|
64
|
+
renderGhost(matches[matchIdx].slice(input.length));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Single match — accept it
|
|
68
|
+
const text = ghost;
|
|
69
|
+
rl.output.write('\x1b[K');
|
|
70
|
+
ghost = '';
|
|
71
|
+
matches = [];
|
|
72
|
+
rl._insertString(text);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Tab on empty input — show all commands as ghost suggestions
|
|
78
|
+
if (key && key.name === 'tab') {
|
|
79
|
+
if ((rl.line || '') === '') {
|
|
80
|
+
matches = COMMANDS;
|
|
81
|
+
matchIdx = 0;
|
|
82
|
+
renderGhost(matches[0]);
|
|
83
|
+
}
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Clear existing ghost text before readline processes the key
|
|
88
|
+
if (ghost) {
|
|
89
|
+
rl.output.write('\x1b[K');
|
|
90
|
+
ghost = '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Let readline handle the key normally
|
|
94
|
+
origTtyWrite(s, key);
|
|
95
|
+
|
|
96
|
+
// Render new ghost text if cursor is at end of line
|
|
97
|
+
const input = rl.line || '';
|
|
98
|
+
matches = getMatches(input);
|
|
99
|
+
matchIdx = 0;
|
|
100
|
+
if (matches.length > 0 && rl.cursor === rl.line.length) {
|
|
101
|
+
renderGhost(matches[0].slice(input.length));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// ─── REPL loop ──────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
console.log('Ghost completion demo — Tab cycles matches, Right Arrow accepts\n');
|
|
108
|
+
rl.prompt();
|
|
109
|
+
|
|
110
|
+
rl.on('line', (line) => {
|
|
111
|
+
if (line.trim() === '.exit') {
|
|
112
|
+
rl.close();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
console.log(` → ${line.trim() || '(empty)'}`);
|
|
116
|
+
rl.prompt();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
rl.on('close', () => {
|
|
120
|
+
console.log('\nBye!');
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completion data — builds the list of items for ghost completion.
|
|
3
|
+
*
|
|
4
|
+
* Sources: COMMANDS from resolve.mjs plus REPL meta-commands (.help, .exit, etc.).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { COMMANDS } from './resolve.mjs';
|
|
8
|
+
|
|
9
|
+
// ─── Meta-commands ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const META_COMMANDS = [
|
|
12
|
+
{ cmd: '.help', desc: 'Show available commands' },
|
|
13
|
+
{ cmd: '.aliases', desc: 'Show command aliases' },
|
|
14
|
+
{ cmd: '.status', desc: 'Show connection status' },
|
|
15
|
+
{ cmd: '.reconnect', desc: 'Reconnect to daemon' },
|
|
16
|
+
{ cmd: '.record', desc: 'Start recording commands' },
|
|
17
|
+
{ cmd: '.save', desc: 'Stop recording and save' },
|
|
18
|
+
{ cmd: '.pause', desc: 'Pause/resume recording' },
|
|
19
|
+
{ cmd: '.discard', desc: 'Discard current recording' },
|
|
20
|
+
{ cmd: '.replay', desc: 'Replay a recorded session' },
|
|
21
|
+
{ cmd: '.exit', desc: 'Exit REPL' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const EXTRA_COMMANDS = [
|
|
25
|
+
{ cmd: 'verify-text', desc: 'Assert text is visible on page' },
|
|
26
|
+
{ cmd: 'verify-element', desc: 'Assert element exists by role and name' },
|
|
27
|
+
{ cmd: 'verify-value', desc: 'Assert input/select/checkbox value' },
|
|
28
|
+
{ cmd: 'verify-list', desc: 'Assert list contains expected items' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
// ─── Build completion items ──────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns a sorted array of `{ cmd, desc }` for all completable items:
|
|
35
|
+
* commands and meta-commands.
|
|
36
|
+
*/
|
|
37
|
+
export function buildCompletionItems() {
|
|
38
|
+
const items = [];
|
|
39
|
+
|
|
40
|
+
// Primary commands
|
|
41
|
+
for (const [name, info] of Object.entries(COMMANDS)) {
|
|
42
|
+
items.push({ cmd: name, desc: info.desc });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Extra commands (not in COMMANDS but handled by REPL)
|
|
46
|
+
items.push(...EXTRA_COMMANDS);
|
|
47
|
+
|
|
48
|
+
// Meta-commands
|
|
49
|
+
items.push(...META_COMMANDS);
|
|
50
|
+
|
|
51
|
+
items.sort((a, b) => a.cmd.localeCompare(b.cmd));
|
|
52
|
+
return items;
|
|
53
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* playwright-repl — public API
|
|
3
|
-
*
|
|
4
|
-
* Usage as CLI:
|
|
5
|
-
* npx playwright-repl [options]
|
|
6
|
-
*
|
|
7
|
-
* Usage as library:
|
|
8
|
-
* import { DaemonConnection, parseInput, SessionRecorder } from 'playwright-repl';
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export { DaemonConnection } from './connection.mjs';
|
|
12
|
-
export { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
13
|
-
export { SessionRecorder, SessionPlayer } from './recorder.mjs';
|
|
14
|
-
export { socketPath, isDaemonRunning, startDaemon, findWorkspaceDir } from './workspace.mjs';
|
|
15
|
-
export { startRepl } from './repl.mjs';
|
|
1
|
+
/**
|
|
2
|
+
* playwright-repl — public API
|
|
3
|
+
*
|
|
4
|
+
* Usage as CLI:
|
|
5
|
+
* npx playwright-repl [options]
|
|
6
|
+
*
|
|
7
|
+
* Usage as library:
|
|
8
|
+
* import { DaemonConnection, parseInput, SessionRecorder } from 'playwright-repl';
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { DaemonConnection } from './connection.mjs';
|
|
12
|
+
export { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
13
|
+
export { SessionRecorder, SessionPlayer } from './recorder.mjs';
|
|
14
|
+
export { socketPath, isDaemonRunning, startDaemon, findWorkspaceDir } from './workspace.mjs';
|
|
15
|
+
export { startRepl } from './repl.mjs';
|
|
16
|
+
export { buildCompletionItems } from './completion-data.mjs';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-context functions for run-code commands.
|
|
3
|
+
*
|
|
4
|
+
* Each function is a real, testable async function that takes (page, ...args).
|
|
5
|
+
* buildRunCode() converts them to code strings via Function.toString(),
|
|
6
|
+
* following the same pattern as playwright-repl-extension/lib/page-scripts.js.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Helper ─────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wraps a function into a run-code args object.
|
|
13
|
+
* Uses fn.toString() + JSON.stringify() — no manual escaping needed.
|
|
14
|
+
*
|
|
15
|
+
* The daemon's browser_run_code calls: `await (code)(page)`
|
|
16
|
+
* So `code` must be a function expression, not an IIFE.
|
|
17
|
+
*/
|
|
18
|
+
export function buildRunCode(fn, ...args) {
|
|
19
|
+
const serialized = args.map(a => JSON.stringify(a)).join(', ');
|
|
20
|
+
return { _: ['run-code', `async (page) => (${fn.toString()})(page, ${serialized})`] };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ─── Verify functions ───────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export async function verifyText(page, text) {
|
|
26
|
+
if (await page.getByText(text).filter({ visible: true }).count() === 0)
|
|
27
|
+
throw new Error('Text not found: ' + text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function verifyElement(page, role, name) {
|
|
31
|
+
if (await page.getByRole(role, { name }).count() === 0)
|
|
32
|
+
throw new Error('Element not found: ' + role + ' "' + name + '"');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function verifyValue(page, ref, expected) {
|
|
36
|
+
const el = page.locator('[aria-ref="' + ref + '"]');
|
|
37
|
+
const v = await el.inputValue();
|
|
38
|
+
if (v !== expected)
|
|
39
|
+
throw new Error('Expected "' + expected + '", got "' + v + '"');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function verifyList(page, ref, items) {
|
|
43
|
+
const loc = page.locator('[aria-ref="' + ref + '"]');
|
|
44
|
+
for (const item of items) {
|
|
45
|
+
if (await loc.getByText(item).count() === 0)
|
|
46
|
+
throw new Error('Item not found: ' + item);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Text locator actions ───────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export async function actionByText(page, text, action) {
|
|
53
|
+
let loc = page.getByText(text, { exact: true });
|
|
54
|
+
if (await loc.count() === 0) loc = page.getByRole('button', { name: text });
|
|
55
|
+
if (await loc.count() === 0) loc = page.getByRole('link', { name: text });
|
|
56
|
+
if (await loc.count() === 0) loc = page.getByText(text);
|
|
57
|
+
await loc[action]();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fillByText(page, text, value) {
|
|
61
|
+
let loc = page.getByLabel(text);
|
|
62
|
+
if (await loc.count() === 0) loc = page.getByPlaceholder(text);
|
|
63
|
+
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: text });
|
|
64
|
+
await loc.fill(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function selectByText(page, text, value) {
|
|
68
|
+
let loc = page.getByLabel(text);
|
|
69
|
+
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: text });
|
|
70
|
+
await loc.selectOption(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function checkByText(page, text) {
|
|
74
|
+
const item = page.getByRole('listitem').filter({ hasText: text });
|
|
75
|
+
if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
|
|
76
|
+
let loc = page.getByLabel(text);
|
|
77
|
+
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: text });
|
|
78
|
+
await loc.check();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function uncheckByText(page, text) {
|
|
82
|
+
const item = page.getByRole('listitem').filter({ hasText: text });
|
|
83
|
+
if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
|
|
84
|
+
let loc = page.getByLabel(text);
|
|
85
|
+
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: text });
|
|
86
|
+
await loc.uncheck();
|
|
87
|
+
}
|
package/src/parser.mjs
CHANGED
|
@@ -1,141 +1,151 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Input parser — transforms human input into minimist-style args.
|
|
3
|
-
*
|
|
4
|
-
* Flow: "c e5" → alias resolve → ["click", "e5"] → minimist → { _: ["click", "e5"] }
|
|
5
|
-
*
|
|
6
|
-
* The resulting object is sent to the daemon as-is. The daemon runs
|
|
7
|
-
* parseCliCommand() which maps it to a tool call.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { minimist, COMMANDS } from './resolve.mjs';
|
|
11
|
-
|
|
12
|
-
// ─── Command aliases ─────────────────────────────────────────────────────────
|
|
13
|
-
|
|
14
|
-
export const ALIASES = {
|
|
15
|
-
// Navigation
|
|
16
|
-
'o': 'open',
|
|
17
|
-
'g': 'goto',
|
|
18
|
-
'go': 'goto',
|
|
19
|
-
'back': 'go-back',
|
|
20
|
-
'fwd': 'go-forward',
|
|
21
|
-
'r': 'reload',
|
|
22
|
-
|
|
23
|
-
// Interaction
|
|
24
|
-
'c': 'click',
|
|
25
|
-
'dc': 'dblclick',
|
|
26
|
-
't': 'type',
|
|
27
|
-
'f': 'fill',
|
|
28
|
-
'h': 'hover',
|
|
29
|
-
'p': 'press',
|
|
30
|
-
'sel': 'select',
|
|
31
|
-
'chk': 'check',
|
|
32
|
-
'unchk':'uncheck',
|
|
33
|
-
|
|
34
|
-
// Inspection
|
|
35
|
-
's': 'snapshot',
|
|
36
|
-
'snap': 'snapshot',
|
|
37
|
-
'ss': 'screenshot',
|
|
38
|
-
'e': 'eval',
|
|
39
|
-
'con': 'console',
|
|
40
|
-
'net': 'network',
|
|
41
|
-
|
|
42
|
-
// Tabs
|
|
43
|
-
'tl': 'tab-list',
|
|
44
|
-
'tn': 'tab-new',
|
|
45
|
-
'tc': 'tab-close',
|
|
46
|
-
'ts': 'tab-select',
|
|
47
|
-
|
|
48
|
-
// Assertions (Phase 2 — mapped to daemon tools that exist but have no CLI keywords)
|
|
49
|
-
'vt': 'verify-text',
|
|
50
|
-
've': 'verify-element',
|
|
51
|
-
'vv': 'verify-value',
|
|
52
|
-
'vl': 'verify-list',
|
|
53
|
-
|
|
54
|
-
// Session
|
|
55
|
-
'q': 'close',
|
|
56
|
-
'ls': 'list',
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
// ─── Known boolean options ───────────────────────────────────────────────────
|
|
60
|
-
|
|
61
|
-
export const booleanOptions = new Set([
|
|
62
|
-
'headed', 'persistent', 'extension', 'submit', 'clear',
|
|
63
|
-
'fullPage', 'includeStatic',
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
// ─── All known commands ──────────────────────────────────────────────────────
|
|
67
|
-
|
|
68
|
-
export const ALL_COMMANDS = Object.keys(COMMANDS);
|
|
69
|
-
|
|
70
|
-
// ─── Tokenizer ───────────────────────────────────────────────────────────────
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Tokenize input respecting quoted strings.
|
|
74
|
-
* "fill e7 'hello world'" → ["fill", "e7", "hello world"]
|
|
75
|
-
*/
|
|
76
|
-
function tokenize(line) {
|
|
77
|
-
const tokens = [];
|
|
78
|
-
let current = '';
|
|
79
|
-
let inQuote = null;
|
|
80
|
-
|
|
81
|
-
for (let i = 0; i < line.length; i++) {
|
|
82
|
-
const ch = line[i];
|
|
83
|
-
if (inQuote) {
|
|
84
|
-
if (ch === inQuote) {
|
|
85
|
-
inQuote = null;
|
|
86
|
-
} else {
|
|
87
|
-
current += ch;
|
|
88
|
-
}
|
|
89
|
-
} else if (ch === '"' || ch === "'") {
|
|
90
|
-
inQuote = ch;
|
|
91
|
-
} else if (ch === ' ' || ch === '\t') {
|
|
92
|
-
if (current) {
|
|
93
|
-
tokens.push(current);
|
|
94
|
-
current = '';
|
|
95
|
-
}
|
|
96
|
-
} else {
|
|
97
|
-
current += ch;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
if (current) tokens.push(current);
|
|
101
|
-
return tokens;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ─── Main parse function ─────────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Parse a REPL input line into a minimist args object ready for the daemon.
|
|
108
|
-
* Returns null if the line is empty.
|
|
109
|
-
*/
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (args[
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Input parser — transforms human input into minimist-style args.
|
|
3
|
+
*
|
|
4
|
+
* Flow: "c e5" → alias resolve → ["click", "e5"] → minimist → { _: ["click", "e5"] }
|
|
5
|
+
*
|
|
6
|
+
* The resulting object is sent to the daemon as-is. The daemon runs
|
|
7
|
+
* parseCliCommand() which maps it to a tool call.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { minimist, COMMANDS } from './resolve.mjs';
|
|
11
|
+
|
|
12
|
+
// ─── Command aliases ─────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
export const ALIASES = {
|
|
15
|
+
// Navigation
|
|
16
|
+
'o': 'open',
|
|
17
|
+
'g': 'goto',
|
|
18
|
+
'go': 'goto',
|
|
19
|
+
'back': 'go-back',
|
|
20
|
+
'fwd': 'go-forward',
|
|
21
|
+
'r': 'reload',
|
|
22
|
+
|
|
23
|
+
// Interaction
|
|
24
|
+
'c': 'click',
|
|
25
|
+
'dc': 'dblclick',
|
|
26
|
+
't': 'type',
|
|
27
|
+
'f': 'fill',
|
|
28
|
+
'h': 'hover',
|
|
29
|
+
'p': 'press',
|
|
30
|
+
'sel': 'select',
|
|
31
|
+
'chk': 'check',
|
|
32
|
+
'unchk':'uncheck',
|
|
33
|
+
|
|
34
|
+
// Inspection
|
|
35
|
+
's': 'snapshot',
|
|
36
|
+
'snap': 'snapshot',
|
|
37
|
+
'ss': 'screenshot',
|
|
38
|
+
'e': 'eval',
|
|
39
|
+
'con': 'console',
|
|
40
|
+
'net': 'network',
|
|
41
|
+
|
|
42
|
+
// Tabs
|
|
43
|
+
'tl': 'tab-list',
|
|
44
|
+
'tn': 'tab-new',
|
|
45
|
+
'tc': 'tab-close',
|
|
46
|
+
'ts': 'tab-select',
|
|
47
|
+
|
|
48
|
+
// Assertions (Phase 2 — mapped to daemon tools that exist but have no CLI keywords)
|
|
49
|
+
'vt': 'verify-text',
|
|
50
|
+
've': 'verify-element',
|
|
51
|
+
'vv': 'verify-value',
|
|
52
|
+
'vl': 'verify-list',
|
|
53
|
+
|
|
54
|
+
// Session
|
|
55
|
+
'q': 'close',
|
|
56
|
+
'ls': 'list',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ─── Known boolean options ───────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
export const booleanOptions = new Set([
|
|
62
|
+
'headed', 'persistent', 'extension', 'submit', 'clear',
|
|
63
|
+
'fullPage', 'includeStatic',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// ─── All known commands ──────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export const ALL_COMMANDS = Object.keys(COMMANDS);
|
|
69
|
+
|
|
70
|
+
// ─── Tokenizer ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Tokenize input respecting quoted strings.
|
|
74
|
+
* "fill e7 'hello world'" → ["fill", "e7", "hello world"]
|
|
75
|
+
*/
|
|
76
|
+
function tokenize(line) {
|
|
77
|
+
const tokens = [];
|
|
78
|
+
let current = '';
|
|
79
|
+
let inQuote = null;
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < line.length; i++) {
|
|
82
|
+
const ch = line[i];
|
|
83
|
+
if (inQuote) {
|
|
84
|
+
if (ch === inQuote) {
|
|
85
|
+
inQuote = null;
|
|
86
|
+
} else {
|
|
87
|
+
current += ch;
|
|
88
|
+
}
|
|
89
|
+
} else if (ch === '"' || ch === "'") {
|
|
90
|
+
inQuote = ch;
|
|
91
|
+
} else if (ch === ' ' || ch === '\t') {
|
|
92
|
+
if (current) {
|
|
93
|
+
tokens.push(current);
|
|
94
|
+
current = '';
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
current += ch;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (current) tokens.push(current);
|
|
101
|
+
return tokens;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Main parse function ─────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse a REPL input line into a minimist args object ready for the daemon.
|
|
108
|
+
* Returns null if the line is empty.
|
|
109
|
+
*/
|
|
110
|
+
// Commands where everything after the keyword is a single raw argument
|
|
111
|
+
const RAW_COMMANDS = new Set(['run-code', 'eval']);
|
|
112
|
+
|
|
113
|
+
export function parseInput(line) {
|
|
114
|
+
const tokens = tokenize(line);
|
|
115
|
+
if (tokens.length === 0) return null;
|
|
116
|
+
|
|
117
|
+
// Resolve alias
|
|
118
|
+
const cmd = tokens[0].toLowerCase();
|
|
119
|
+
if (ALIASES[cmd]) tokens[0] = ALIASES[cmd];
|
|
120
|
+
|
|
121
|
+
// For run-code / eval, preserve the rest of the line as a single raw string
|
|
122
|
+
if (RAW_COMMANDS.has(tokens[0])) {
|
|
123
|
+
const cmdLen = line.match(/^\s*\S+/)[0].length;
|
|
124
|
+
const rest = line.slice(cmdLen).trim();
|
|
125
|
+
return rest ? { _: [tokens[0], rest] } : { _: [tokens[0]] };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Parse with minimist (same lib and boolean set as playwright-cli)
|
|
129
|
+
const args = minimist(tokens, { boolean: [...booleanOptions] });
|
|
130
|
+
|
|
131
|
+
// Stringify non-boolean values (playwright-cli does this)
|
|
132
|
+
for (const key of Object.keys(args)) {
|
|
133
|
+
if (key === '_') continue;
|
|
134
|
+
if (typeof args[key] !== 'boolean')
|
|
135
|
+
args[key] = String(args[key]);
|
|
136
|
+
}
|
|
137
|
+
for (let i = 0; i < args._.length; i++)
|
|
138
|
+
args._[i] = String(args._[i]);
|
|
139
|
+
|
|
140
|
+
// Remove boolean options set to false that weren't explicitly passed.
|
|
141
|
+
// minimist sets all declared booleans to false by default, but the
|
|
142
|
+
// daemon rejects unknown options like --headed false.
|
|
143
|
+
for (const opt of booleanOptions) {
|
|
144
|
+
if (args[opt] === false) {
|
|
145
|
+
const hasExplicitNo = tokens.some(t => t === `--no-${opt}`);
|
|
146
|
+
if (!hasExplicitNo) delete args[opt];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return args;
|
|
151
|
+
}
|
package/src/repl.mjs
CHANGED
|
@@ -9,129 +9,18 @@ import path from 'node:path';
|
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import { execSync } from 'node:child_process';
|
|
11
11
|
|
|
12
|
-
import { replVersion
|
|
12
|
+
import { replVersion } from './resolve.mjs';
|
|
13
13
|
import { DaemonConnection } from './connection.mjs';
|
|
14
14
|
import { socketPath, daemonProfilesDir, isDaemonRunning, startDaemon } from './workspace.mjs';
|
|
15
15
|
import { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
16
16
|
import { SessionManager } from './recorder.mjs';
|
|
17
|
+
import { buildCompletionItems } from './completion-data.mjs';
|
|
18
|
+
import {
|
|
19
|
+
buildRunCode, verifyText, verifyElement, verifyValue, verifyList,
|
|
20
|
+
actionByText, fillByText, selectByText, checkByText, uncheckByText,
|
|
21
|
+
} from './page-scripts.mjs';
|
|
17
22
|
import { c } from './colors.mjs';
|
|
18
23
|
|
|
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) => {
|
|
73
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
74
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
75
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
76
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
77
|
-
await loc.click();
|
|
78
|
-
}`] };
|
|
79
|
-
case 'dblclick':
|
|
80
|
-
return { _: ['run-code', `async (page) => {
|
|
81
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
82
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
83
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
84
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
85
|
-
await loc.dblclick();
|
|
86
|
-
}`] };
|
|
87
|
-
case 'hover':
|
|
88
|
-
return { _: ['run-code', `async (page) => {
|
|
89
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
90
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
91
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
92
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
93
|
-
await loc.hover();
|
|
94
|
-
}`] };
|
|
95
|
-
case 'fill': {
|
|
96
|
-
const value = esc(extraArgs[0] || '');
|
|
97
|
-
// Try getByLabel first, fall back to getByPlaceholder, then getByRole('textbox')
|
|
98
|
-
return { _: ['run-code', `async (page) => {
|
|
99
|
-
let loc = page.getByLabel('${text}');
|
|
100
|
-
if (await loc.count() === 0) loc = page.getByPlaceholder('${text}');
|
|
101
|
-
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: '${text}' });
|
|
102
|
-
await loc.fill('${value}');
|
|
103
|
-
}`] };
|
|
104
|
-
}
|
|
105
|
-
case 'select': {
|
|
106
|
-
const value = esc(extraArgs[0] || '');
|
|
107
|
-
return { _: ['run-code', `async (page) => {
|
|
108
|
-
let loc = page.getByLabel('${text}');
|
|
109
|
-
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: '${text}' });
|
|
110
|
-
await loc.selectOption('${value}');
|
|
111
|
-
}`] };
|
|
112
|
-
}
|
|
113
|
-
case 'check':
|
|
114
|
-
// Scope to listitem/group with matching text, then find checkbox inside
|
|
115
|
-
return { _: ['run-code', `async (page) => {
|
|
116
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
117
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
|
|
118
|
-
let loc = page.getByLabel('${text}');
|
|
119
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
120
|
-
await loc.check();
|
|
121
|
-
}`] };
|
|
122
|
-
case 'uncheck':
|
|
123
|
-
return { _: ['run-code', `async (page) => {
|
|
124
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
125
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
|
|
126
|
-
let loc = page.getByLabel('${text}');
|
|
127
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
128
|
-
await loc.uncheck();
|
|
129
|
-
}`] };
|
|
130
|
-
default:
|
|
131
|
-
return null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
24
|
// ─── Response filtering ─────────────────────────────────────────────────────
|
|
136
25
|
|
|
137
26
|
export function filterResponse(text) {
|
|
@@ -142,7 +31,9 @@ export function filterResponse(text) {
|
|
|
142
31
|
if (newline === -1) continue;
|
|
143
32
|
const title = section.substring(0, newline).trim();
|
|
144
33
|
const content = section.substring(newline + 1).trim();
|
|
145
|
-
if (title === '
|
|
34
|
+
if (title === 'Error')
|
|
35
|
+
kept.push(`${c.red}${content}${c.reset}`);
|
|
36
|
+
else if (title === 'Result' || title === 'Modal state')
|
|
146
37
|
kept.push(content);
|
|
147
38
|
}
|
|
148
39
|
return kept.length > 0 ? kept.join('\n') : null;
|
|
@@ -373,9 +264,23 @@ export async function processLine(ctx, line) {
|
|
|
373
264
|
if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx);
|
|
374
265
|
|
|
375
266
|
// ── Verify commands → run-code translation ──────────────────
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
267
|
+
const verifyFns = {
|
|
268
|
+
'verify-text': verifyText,
|
|
269
|
+
'verify-element': verifyElement,
|
|
270
|
+
'verify-value': verifyValue,
|
|
271
|
+
'verify-list': verifyList,
|
|
272
|
+
};
|
|
273
|
+
if (verifyFns[cmdName]) {
|
|
274
|
+
const pos = args._.slice(1);
|
|
275
|
+
const fn = verifyFns[cmdName];
|
|
276
|
+
let translated = null;
|
|
277
|
+
if (cmdName === 'verify-text') {
|
|
278
|
+
const text = pos.join(' ');
|
|
279
|
+
if (text) translated = buildRunCode(fn, text);
|
|
280
|
+
} else if (pos[0] && pos.length >= 2) {
|
|
281
|
+
const rest = cmdName === 'verify-list' ? pos.slice(1) : pos.slice(1).join(' ');
|
|
282
|
+
translated = buildRunCode(fn, pos[0], rest);
|
|
283
|
+
}
|
|
379
284
|
if (translated) {
|
|
380
285
|
args = translated;
|
|
381
286
|
} else {
|
|
@@ -385,15 +290,31 @@ export async function processLine(ctx, line) {
|
|
|
385
290
|
}
|
|
386
291
|
|
|
387
292
|
// ── Auto-resolve text to native Playwright locator ─────────
|
|
388
|
-
const
|
|
389
|
-
|
|
293
|
+
const textFns = {
|
|
294
|
+
click: actionByText, dblclick: actionByText, hover: actionByText,
|
|
295
|
+
fill: fillByText, select: selectByText, check: checkByText, uncheck: uncheckByText,
|
|
296
|
+
};
|
|
297
|
+
if (textFns[cmdName] && args._[1] && !/^e\d+$/.test(args._[1])) {
|
|
390
298
|
const textArg = args._[1];
|
|
391
299
|
const extraArgs = args._.slice(2);
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
300
|
+
const fn = textFns[cmdName];
|
|
301
|
+
let runCodeArgs;
|
|
302
|
+
if (fn === actionByText) runCodeArgs = buildRunCode(fn, textArg, cmdName);
|
|
303
|
+
else if (cmdName === 'fill' || cmdName === 'select') runCodeArgs = buildRunCode(fn, textArg, extraArgs[0] || '');
|
|
304
|
+
else runCodeArgs = buildRunCode(fn, textArg);
|
|
305
|
+
const argsHint = extraArgs.length > 0 ? ` ${extraArgs.join(' ')}` : '';
|
|
306
|
+
ctx.log(`${c.dim}→ ${cmdName} "${textArg}"${argsHint} (via run-code)${c.reset}`);
|
|
307
|
+
args = runCodeArgs;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Auto-wrap run-code body with async (page) => { ... } ──
|
|
311
|
+
if (cmdName === 'run-code' && args._[1] && !args._[1].startsWith('async')) {
|
|
312
|
+
const STMT = /^(await|return|const|let|var|for|if|while|throw|try)\b/;
|
|
313
|
+
const body = !args._[1].includes(';') && !STMT.test(args._[1])
|
|
314
|
+
? `return await ${args._[1]}`
|
|
315
|
+
: args._[1];
|
|
316
|
+
args = { _: ['run-code', `async (page) => { ${body} }`] };
|
|
317
|
+
ctx.log(`${c.dim}→ ${args._[1]}${c.reset}`);
|
|
397
318
|
}
|
|
398
319
|
|
|
399
320
|
const startTime = performance.now();
|
|
@@ -518,26 +439,100 @@ export function promptStr(ctx) {
|
|
|
518
439
|
return `${prefix}${c.cyan}pw>${c.reset} `;
|
|
519
440
|
}
|
|
520
441
|
|
|
521
|
-
// ───
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
442
|
+
// ─── Ghost completion (inline suggestion) ───────────────────────────────────
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Attaches ghost-text completion to a readline interface.
|
|
446
|
+
* Shows dimmed inline suggestion after the cursor; Tab or Right Arrow accepts it.
|
|
447
|
+
*
|
|
448
|
+
* Uses _ttyWrite wrapper instead of _writeToOutput because Node 22+ optimizes
|
|
449
|
+
* single-character appends and doesn't always trigger a full line refresh.
|
|
450
|
+
*
|
|
451
|
+
* @param {readline.Interface} rl
|
|
452
|
+
* @param {Array<{cmd: string, desc: string}>} items - from buildCompletionItems()
|
|
453
|
+
*/
|
|
454
|
+
/**
|
|
455
|
+
* Returns matching commands for ghost completion.
|
|
456
|
+
* When the input exactly matches a command AND there are longer matches,
|
|
457
|
+
* the exact match is included so the user can cycle through all options.
|
|
458
|
+
*/
|
|
459
|
+
export function getGhostMatches(cmds, input) {
|
|
460
|
+
if (input.length > 0 && !input.includes(' ')) {
|
|
461
|
+
const longer = cmds.filter(cmd => cmd.startsWith(input) && cmd !== input);
|
|
462
|
+
if (longer.length > 0 && cmds.includes(input)) longer.push(input);
|
|
463
|
+
return longer;
|
|
532
464
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function attachGhostCompletion(rl, items) {
|
|
469
|
+
if (!process.stdin.isTTY) return; // no ghost text for piped input
|
|
470
|
+
|
|
471
|
+
const cmds = items.map(i => i.cmd);
|
|
472
|
+
let ghost = '';
|
|
473
|
+
let matches = []; // all matching commands for current input
|
|
474
|
+
let matchIdx = 0; // which match is currently shown
|
|
475
|
+
|
|
476
|
+
function renderGhost(suffix) {
|
|
477
|
+
ghost = suffix;
|
|
478
|
+
rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
|
|
539
479
|
}
|
|
540
|
-
|
|
480
|
+
|
|
481
|
+
const origTtyWrite = rl._ttyWrite.bind(rl);
|
|
482
|
+
rl._ttyWrite = function (s, key) {
|
|
483
|
+
// Tab handling — based on matches, not ghost text
|
|
484
|
+
if (key && key.name === 'tab') {
|
|
485
|
+
// Cycle through multiple matches
|
|
486
|
+
if (matches.length > 1) {
|
|
487
|
+
rl.output.write('\x1b[K');
|
|
488
|
+
ghost = '';
|
|
489
|
+
matchIdx = (matchIdx + 1) % matches.length;
|
|
490
|
+
const input = rl.line || '';
|
|
491
|
+
const suffix = matches[matchIdx].slice(input.length);
|
|
492
|
+
if (suffix) renderGhost(suffix);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Single match — accept it
|
|
496
|
+
if (ghost && matches.length === 1) {
|
|
497
|
+
const text = ghost;
|
|
498
|
+
rl.output.write('\x1b[K');
|
|
499
|
+
ghost = '';
|
|
500
|
+
matches = [];
|
|
501
|
+
rl._insertString(text);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (ghost && key) {
|
|
508
|
+
// Right-arrow-at-end accepts ghost suggestion
|
|
509
|
+
if (key.name === 'right' && rl.cursor === rl.line.length) {
|
|
510
|
+
const text = ghost;
|
|
511
|
+
rl.output.write('\x1b[K');
|
|
512
|
+
ghost = '';
|
|
513
|
+
matches = [];
|
|
514
|
+
rl._insertString(text);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Clear existing ghost text before readline processes the key
|
|
520
|
+
if (ghost) {
|
|
521
|
+
rl.output.write('\x1b[K');
|
|
522
|
+
ghost = '';
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Let readline handle the key normally
|
|
526
|
+
origTtyWrite(s, key);
|
|
527
|
+
|
|
528
|
+
// Render new ghost text if cursor is at end of line
|
|
529
|
+
const input = rl.line || '';
|
|
530
|
+
matches = getGhostMatches(cmds, input);
|
|
531
|
+
matchIdx = 0;
|
|
532
|
+
if (matches.length > 0 && rl.cursor === rl.line.length) {
|
|
533
|
+
renderGhost(matches[0].slice(input.length));
|
|
534
|
+
}
|
|
535
|
+
};
|
|
541
536
|
}
|
|
542
537
|
|
|
543
538
|
// ─── REPL ────────────────────────────────────────────────────────────────────
|
|
@@ -585,7 +580,6 @@ export async function startRepl(opts = {}) {
|
|
|
585
580
|
output: process.stdout,
|
|
586
581
|
prompt: promptStr(ctx),
|
|
587
582
|
historySize: 500,
|
|
588
|
-
completer,
|
|
589
583
|
});
|
|
590
584
|
ctx.rl = rl;
|
|
591
585
|
|
|
@@ -594,6 +588,8 @@ export async function startRepl(opts = {}) {
|
|
|
594
588
|
for (const line of hist) rl.history.push(line);
|
|
595
589
|
} catch {}
|
|
596
590
|
|
|
591
|
+
attachGhostCompletion(rl, buildCompletionItems());
|
|
592
|
+
|
|
597
593
|
// ─── Start ───────────────────────────────────────────────────────
|
|
598
594
|
|
|
599
595
|
if (opts.replay) {
|