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 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, 96% statement coverage
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 directly |
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
  ![Architecture](architecture-diagram.png)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-repl",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Interactive REPL for Playwright browser automation — keyword-driven testing from your terminal",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- export function parseInput(line) {
111
- const tokens = tokenize(line);
112
- if (tokens.length === 0) return null;
113
-
114
- // Resolve alias
115
- const cmd = tokens[0].toLowerCase();
116
- if (ALIASES[cmd]) tokens[0] = ALIASES[cmd];
117
-
118
- // Parse with minimist (same lib and boolean set as playwright-cli)
119
- const args = minimist(tokens, { boolean: [...booleanOptions] });
120
-
121
- // Stringify non-boolean values (playwright-cli does this)
122
- for (const key of Object.keys(args)) {
123
- if (key === '_') continue;
124
- if (typeof args[key] !== 'boolean')
125
- args[key] = String(args[key]);
126
- }
127
- for (let i = 0; i < args._.length; i++)
128
- args._[i] = String(args._[i]);
129
-
130
- // Remove boolean options set to false that weren't explicitly passed.
131
- // minimist sets all declared booleans to false by default, but the
132
- // daemon rejects unknown options like --headed false.
133
- for (const opt of booleanOptions) {
134
- if (args[opt] === false) {
135
- const hasExplicitNo = tokens.some(t => t === `--no-${opt}`);
136
- if (!hasExplicitNo) delete args[opt];
137
- }
138
- }
139
-
140
- return args;
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, COMMANDS } from './resolve.mjs';
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 === 'Result' || title === 'Error' || title === 'Modal state')
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 verifyCommands = ['verify-text', 'verify-element', 'verify-value', 'verify-list'];
377
- if (verifyCommands.includes(cmdName)) {
378
- const translated = verifyToRunCode(cmdName, args._.slice(1));
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 refCommands = ['click', 'dblclick', 'hover', 'fill', 'select', 'check', 'uncheck'];
389
- if (refCommands.includes(cmdName) && args._[1] && !/^e\d+$/.test(args._[1])) {
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 runCodeArgs = textToRunCode(cmdName, textArg, extraArgs);
393
- if (runCodeArgs) {
394
- ctx.log(`${c.dim}→ ${runCodeArgs._[1]}${c.reset}`);
395
- args = runCodeArgs;
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
- // ─── Tab completer ──────────────────────────────────────────────────────────
522
-
523
- export function completer(line) {
524
- const parts = line.split(/\s+/);
525
- if (parts.length <= 1) {
526
- const prefix = parts[0] || '';
527
- const allNames = [...ALL_COMMANDS, ...Object.keys(ALIASES)];
528
- const metas = ['.help', '.aliases', '.status', '.reconnect', '.exit',
529
- '.record', '.save', '.replay', '.pause', '.discard'];
530
- const hits = [...allNames, ...metas].filter(n => n.startsWith(prefix));
531
- return [hits.length ? hits : allNames, prefix];
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
- const cmd = ALIASES[parts[0]] || parts[0];
534
- const helpText = COMMANDS[cmd]?.options || [];
535
- const lastPart = parts[parts.length - 1];
536
- if (lastPart.startsWith('--')) {
537
- const hits = helpText.filter(o => o.startsWith(lastPart));
538
- return [hits, lastPart];
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
- return [[], line];
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) {