playwright-repl 0.2.1 → 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 +35 -2
- package/README.md +50 -2
- package/package.json +1 -1
- package/src/completion-data.mjs +12 -9
- package/src/page-scripts.mjs +87 -0
- package/src/parser.mjs +151 -141
- package/src/repl.mjs +81 -159
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
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
|
+
|
|
3
37
|
## v0.2.1 — Ghost Completion
|
|
4
38
|
|
|
5
39
|
**2026-02-17**
|
|
@@ -9,7 +43,6 @@
|
|
|
9
43
|
- **Ghost completion**: Fish-shell style inline suggestions — type a prefix and see dimmed suggestion text after the cursor
|
|
10
44
|
- **Tab** cycles through matches (e.g., `go` → goto, go-back, go-forward)
|
|
11
45
|
- **Right Arrow** accepts the current suggestion
|
|
12
|
-
- **Tab on empty line** cycles through all commands
|
|
13
46
|
- Aliases excluded from ghost suggestions (still work when typed)
|
|
14
47
|
|
|
15
48
|
### Removed
|
|
@@ -118,7 +151,7 @@ First public release of playwright-repl — an interactive REPL for Playwright b
|
|
|
118
151
|
- Connects to Playwright's MCP terminal daemon over Unix socket / named pipe
|
|
119
152
|
- Wire-compatible with `playwright-cli` — produces identical JSON messages
|
|
120
153
|
- Requires `playwright >= 1.59.0-alpha` (daemon code in `lib/mcp/terminal/`)
|
|
121
|
-
- 218 tests
|
|
154
|
+
- 218 tests at initial release
|
|
122
155
|
|
|
123
156
|
### Known Limitations
|
|
124
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
|

|
package/package.json
CHANGED
package/src/completion-data.mjs
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Completion data — builds the list of items for
|
|
2
|
+
* Completion data — builds the list of items for ghost completion.
|
|
3
3
|
*
|
|
4
|
-
* Sources: COMMANDS from resolve.mjs
|
|
5
|
-
* plus REPL meta-commands (.help, .exit, etc.).
|
|
4
|
+
* Sources: COMMANDS from resolve.mjs plus REPL meta-commands (.help, .exit, etc.).
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import { COMMANDS } from './resolve.mjs';
|
|
9
|
-
import { ALIASES } from './parser.mjs';
|
|
10
8
|
|
|
11
9
|
// ─── Meta-commands ───────────────────────────────────────────────────────────
|
|
12
10
|
|
|
@@ -23,11 +21,18 @@ const META_COMMANDS = [
|
|
|
23
21
|
{ cmd: '.exit', desc: 'Exit REPL' },
|
|
24
22
|
];
|
|
25
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
|
+
|
|
26
31
|
// ─── Build completion items ──────────────────────────────────────────────────
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
34
|
* Returns a sorted array of `{ cmd, desc }` for all completable items:
|
|
30
|
-
* commands
|
|
35
|
+
* commands and meta-commands.
|
|
31
36
|
*/
|
|
32
37
|
export function buildCompletionItems() {
|
|
33
38
|
const items = [];
|
|
@@ -37,10 +42,8 @@ export function buildCompletionItems() {
|
|
|
37
42
|
items.push({ cmd: name, desc: info.desc });
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
items.push({ cmd: alias, desc: `→ ${target}` });
|
|
43
|
-
}
|
|
45
|
+
// Extra commands (not in COMMANDS but handled by REPL)
|
|
46
|
+
items.push(...EXTRA_COMMANDS);
|
|
44
47
|
|
|
45
48
|
// Meta-commands
|
|
46
49
|
items.push(...META_COMMANDS);
|
|
@@ -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
|
@@ -15,124 +15,12 @@ import { socketPath, daemonProfilesDir, isDaemonRunning, startDaemon } from './w
|
|
|
15
15
|
import { parseInput, ALIASES, ALL_COMMANDS } from './parser.mjs';
|
|
16
16
|
import { SessionManager } from './recorder.mjs';
|
|
17
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';
|
|
18
22
|
import { c } from './colors.mjs';
|
|
19
23
|
|
|
20
|
-
// ─── Verify commands → run-code translation ─────────────────────────────────
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The daemon has browser_verify_* tools but no CLI keyword mappings.
|
|
24
|
-
* We intercept verify-* commands here and translate them to run-code calls
|
|
25
|
-
* that use the equivalent Playwright API.
|
|
26
|
-
*/
|
|
27
|
-
export function verifyToRunCode(cmdName, positionalArgs) {
|
|
28
|
-
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
29
|
-
|
|
30
|
-
switch (cmdName) {
|
|
31
|
-
case 'verify-text': {
|
|
32
|
-
const text = positionalArgs.join(' ');
|
|
33
|
-
if (!text) return null;
|
|
34
|
-
return { _: ['run-code', `async (page) => { if (await page.getByText('${esc(text)}').filter({ visible: true }).count() === 0) throw new Error('Text not found: ${esc(text)}'); }`] };
|
|
35
|
-
}
|
|
36
|
-
case 'verify-element': {
|
|
37
|
-
const [role, ...nameParts] = positionalArgs;
|
|
38
|
-
const name = nameParts.join(' ');
|
|
39
|
-
if (!role || !name) return null;
|
|
40
|
-
return { _: ['run-code', `async (page) => { if (await page.getByRole('${esc(role)}', { name: '${esc(name)}' }).count() === 0) throw new Error('Element not found: ${esc(role)} "${esc(name)}"'); }`] };
|
|
41
|
-
}
|
|
42
|
-
case 'verify-value': {
|
|
43
|
-
const [ref, ...valueParts] = positionalArgs;
|
|
44
|
-
const value = valueParts.join(' ');
|
|
45
|
-
if (!ref || !value) return null;
|
|
46
|
-
return { _: ['run-code', `async (page) => { const el = page.locator('[aria-ref="${esc(ref)}"]'); const v = await el.inputValue(); if (v !== '${esc(value)}') throw new Error('Expected "${esc(value)}", got "' + v + '"'); }`] };
|
|
47
|
-
}
|
|
48
|
-
case 'verify-list': {
|
|
49
|
-
const [ref, ...items] = positionalArgs;
|
|
50
|
-
if (!ref || items.length === 0) return null;
|
|
51
|
-
const checks = items.map(item => `if (await loc.getByText('${esc(item)}').count() === 0) throw new Error('Item not found: ${esc(item)}');`).join(' ');
|
|
52
|
-
return { _: ['run-code', `async (page) => { const loc = page.locator('[aria-ref="${esc(ref)}"]'); ${checks} }`] };
|
|
53
|
-
}
|
|
54
|
-
default:
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// ─── Text-to-action via Playwright native locators ──────────────────────────
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Build a run-code args object that uses Playwright's native text locators.
|
|
63
|
-
* e.g. click "Active" → page.getByText("Active").click()
|
|
64
|
-
* fill "Email" "test" → page.getByLabel("Email").fill("test")
|
|
65
|
-
* check "Buy groceries" → listitem with text → checkbox.check()
|
|
66
|
-
*/
|
|
67
|
-
export function textToRunCode(cmdName, textArg, extraArgs) {
|
|
68
|
-
const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
69
|
-
const text = esc(textArg);
|
|
70
|
-
|
|
71
|
-
switch (cmdName) {
|
|
72
|
-
case 'click':
|
|
73
|
-
return { _: ['run-code', `async (page) => {
|
|
74
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
75
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
76
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
77
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
78
|
-
await loc.click();
|
|
79
|
-
}`] };
|
|
80
|
-
case 'dblclick':
|
|
81
|
-
return { _: ['run-code', `async (page) => {
|
|
82
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
83
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
84
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
85
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
86
|
-
await loc.dblclick();
|
|
87
|
-
}`] };
|
|
88
|
-
case 'hover':
|
|
89
|
-
return { _: ['run-code', `async (page) => {
|
|
90
|
-
let loc = page.getByText('${text}', { exact: true });
|
|
91
|
-
if (await loc.count() === 0) loc = page.getByRole('button', { name: '${text}' });
|
|
92
|
-
if (await loc.count() === 0) loc = page.getByRole('link', { name: '${text}' });
|
|
93
|
-
if (await loc.count() === 0) loc = page.getByText('${text}');
|
|
94
|
-
await loc.hover();
|
|
95
|
-
}`] };
|
|
96
|
-
case 'fill': {
|
|
97
|
-
const value = esc(extraArgs[0] || '');
|
|
98
|
-
// Try getByLabel first, fall back to getByPlaceholder, then getByRole('textbox')
|
|
99
|
-
return { _: ['run-code', `async (page) => {
|
|
100
|
-
let loc = page.getByLabel('${text}');
|
|
101
|
-
if (await loc.count() === 0) loc = page.getByPlaceholder('${text}');
|
|
102
|
-
if (await loc.count() === 0) loc = page.getByRole('textbox', { name: '${text}' });
|
|
103
|
-
await loc.fill('${value}');
|
|
104
|
-
}`] };
|
|
105
|
-
}
|
|
106
|
-
case 'select': {
|
|
107
|
-
const value = esc(extraArgs[0] || '');
|
|
108
|
-
return { _: ['run-code', `async (page) => {
|
|
109
|
-
let loc = page.getByLabel('${text}');
|
|
110
|
-
if (await loc.count() === 0) loc = page.getByRole('combobox', { name: '${text}' });
|
|
111
|
-
await loc.selectOption('${value}');
|
|
112
|
-
}`] };
|
|
113
|
-
}
|
|
114
|
-
case 'check':
|
|
115
|
-
// Scope to listitem/group with matching text, then find checkbox inside
|
|
116
|
-
return { _: ['run-code', `async (page) => {
|
|
117
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
118
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').check(); return; }
|
|
119
|
-
let loc = page.getByLabel('${text}');
|
|
120
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
121
|
-
await loc.check();
|
|
122
|
-
}`] };
|
|
123
|
-
case 'uncheck':
|
|
124
|
-
return { _: ['run-code', `async (page) => {
|
|
125
|
-
const item = page.getByRole('listitem').filter({ hasText: '${text}' });
|
|
126
|
-
if (await item.count() > 0) { await item.getByRole('checkbox').uncheck(); return; }
|
|
127
|
-
let loc = page.getByLabel('${text}');
|
|
128
|
-
if (await loc.count() === 0) loc = page.getByRole('checkbox', { name: '${text}' });
|
|
129
|
-
await loc.uncheck();
|
|
130
|
-
}`] };
|
|
131
|
-
default:
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
24
|
// ─── Response filtering ─────────────────────────────────────────────────────
|
|
137
25
|
|
|
138
26
|
export function filterResponse(text) {
|
|
@@ -143,7 +31,9 @@ export function filterResponse(text) {
|
|
|
143
31
|
if (newline === -1) continue;
|
|
144
32
|
const title = section.substring(0, newline).trim();
|
|
145
33
|
const content = section.substring(newline + 1).trim();
|
|
146
|
-
if (title === '
|
|
34
|
+
if (title === 'Error')
|
|
35
|
+
kept.push(`${c.red}${content}${c.reset}`);
|
|
36
|
+
else if (title === 'Result' || title === 'Modal state')
|
|
147
37
|
kept.push(content);
|
|
148
38
|
}
|
|
149
39
|
return kept.length > 0 ? kept.join('\n') : null;
|
|
@@ -374,9 +264,23 @@ export async function processLine(ctx, line) {
|
|
|
374
264
|
if (cmdName === 'close' || cmdName === 'close-all') return handleClose(ctx);
|
|
375
265
|
|
|
376
266
|
// ── Verify commands → run-code translation ──────────────────
|
|
377
|
-
const
|
|
378
|
-
|
|
379
|
-
|
|
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
|
+
}
|
|
380
284
|
if (translated) {
|
|
381
285
|
args = translated;
|
|
382
286
|
} else {
|
|
@@ -386,15 +290,31 @@ export async function processLine(ctx, line) {
|
|
|
386
290
|
}
|
|
387
291
|
|
|
388
292
|
// ── Auto-resolve text to native Playwright locator ─────────
|
|
389
|
-
const
|
|
390
|
-
|
|
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])) {
|
|
391
298
|
const textArg = args._[1];
|
|
392
299
|
const extraArgs = args._.slice(2);
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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}`);
|
|
398
318
|
}
|
|
399
319
|
|
|
400
320
|
const startTime = performance.now();
|
|
@@ -531,21 +451,28 @@ export function promptStr(ctx) {
|
|
|
531
451
|
* @param {readline.Interface} rl
|
|
532
452
|
* @param {Array<{cmd: string, desc: string}>} items - from buildCompletionItems()
|
|
533
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;
|
|
464
|
+
}
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
|
|
534
468
|
function attachGhostCompletion(rl, items) {
|
|
535
469
|
if (!process.stdin.isTTY) return; // no ghost text for piped input
|
|
536
470
|
|
|
537
|
-
const cmds = items.
|
|
471
|
+
const cmds = items.map(i => i.cmd);
|
|
538
472
|
let ghost = '';
|
|
539
473
|
let matches = []; // all matching commands for current input
|
|
540
474
|
let matchIdx = 0; // which match is currently shown
|
|
541
475
|
|
|
542
|
-
function getMatches(input) {
|
|
543
|
-
if (input.length > 0 && !input.includes(' ')) {
|
|
544
|
-
return cmds.filter(cmd => cmd.startsWith(input) && cmd !== input);
|
|
545
|
-
}
|
|
546
|
-
return [];
|
|
547
|
-
}
|
|
548
|
-
|
|
549
476
|
function renderGhost(suffix) {
|
|
550
477
|
ghost = suffix;
|
|
551
478
|
rl.output.write(`\x1b[2m${ghost}\x1b[0m\x1b[${ghost.length}D`);
|
|
@@ -553,28 +480,20 @@ function attachGhostCompletion(rl, items) {
|
|
|
553
480
|
|
|
554
481
|
const origTtyWrite = rl._ttyWrite.bind(rl);
|
|
555
482
|
rl._ttyWrite = function (s, key) {
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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) {
|
|
560
487
|
rl.output.write('\x1b[K');
|
|
561
488
|
ghost = '';
|
|
562
|
-
matches = [];
|
|
563
|
-
rl._insertString(text);
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Tab cycles through matches
|
|
568
|
-
if (key.name === 'tab' && matches.length > 1) {
|
|
569
|
-
rl.output.write('\x1b[K');
|
|
570
489
|
matchIdx = (matchIdx + 1) % matches.length;
|
|
571
490
|
const input = rl.line || '';
|
|
572
|
-
|
|
491
|
+
const suffix = matches[matchIdx].slice(input.length);
|
|
492
|
+
if (suffix) renderGhost(suffix);
|
|
573
493
|
return;
|
|
574
494
|
}
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
if (key.name === 'tab' && matches.length === 1) {
|
|
495
|
+
// Single match — accept it
|
|
496
|
+
if (ghost && matches.length === 1) {
|
|
578
497
|
const text = ghost;
|
|
579
498
|
rl.output.write('\x1b[K');
|
|
580
499
|
ghost = '';
|
|
@@ -582,16 +501,19 @@ function attachGhostCompletion(rl, items) {
|
|
|
582
501
|
rl._insertString(text);
|
|
583
502
|
return;
|
|
584
503
|
}
|
|
504
|
+
return;
|
|
585
505
|
}
|
|
586
506
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if (
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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;
|
|
593
516
|
}
|
|
594
|
-
return;
|
|
595
517
|
}
|
|
596
518
|
|
|
597
519
|
// Clear existing ghost text before readline processes the key
|
|
@@ -605,7 +527,7 @@ function attachGhostCompletion(rl, items) {
|
|
|
605
527
|
|
|
606
528
|
// Render new ghost text if cursor is at end of line
|
|
607
529
|
const input = rl.line || '';
|
|
608
|
-
matches =
|
|
530
|
+
matches = getGhostMatches(cmds, input);
|
|
609
531
|
matchIdx = 0;
|
|
610
532
|
if (matches.length > 0 && rl.cursor === rl.line.length) {
|
|
611
533
|
renderGhost(matches[0].slice(input.length));
|