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 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, 96% statement coverage
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 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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playwright-repl",
3
- "version": "0.2.1",
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": {
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Completion data — builds the list of items for dropdown autocomplete.
2
+ * Completion data — builds the list of items for ghost completion.
3
3
  *
4
- * Sources: COMMANDS from resolve.mjs, ALIASES from parser.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, aliases (with "→ target" description), and meta-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
- // Aliases show "→ target" as description
41
- for (const [alias, target] of Object.entries(ALIASES)) {
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
- 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
@@ -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 === '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')
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 verifyCommands = ['verify-text', 'verify-element', 'verify-value', 'verify-list'];
378
- if (verifyCommands.includes(cmdName)) {
379
- 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
+ }
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 refCommands = ['click', 'dblclick', 'hover', 'fill', 'select', 'check', 'uncheck'];
390
- 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])) {
391
298
  const textArg = args._[1];
392
299
  const extraArgs = args._.slice(2);
393
- const runCodeArgs = textToRunCode(cmdName, textArg, extraArgs);
394
- if (runCodeArgs) {
395
- ctx.log(`${c.dim}→ ${runCodeArgs._[1]}${c.reset}`);
396
- args = runCodeArgs;
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.filter(i => !i.desc.startsWith('→')).map(i => i.cmd);
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
- if (ghost && key) {
557
- // Right-arrow-at-end accepts ghost suggestion
558
- if (key.name === 'right' && rl.cursor === rl.line.length) {
559
- const text = ghost;
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
- renderGhost(matches[matchIdx].slice(input.length));
491
+ const suffix = matches[matchIdx].slice(input.length);
492
+ if (suffix) renderGhost(suffix);
573
493
  return;
574
494
  }
575
-
576
- // Tab with single match accepts it
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
- // Tab on empty input — show all commands as ghost suggestions
588
- if (key && key.name === 'tab') {
589
- if ((rl.line || '') === '') {
590
- matches = cmds;
591
- matchIdx = 0;
592
- renderGhost(matches[0]);
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 = getMatches(input);
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));