pw-repl 0.2.0 → 0.2.1

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/bin/pw-repl.js CHANGED
@@ -67,9 +67,13 @@ function parseSendArgs(args, allowCommand) {
67
67
  }
68
68
  const words = args.slice(i);
69
69
  if (!allowCommand && words.length) usage();
70
- // A word the shell kept whole (fill "text=Your name" Ada) is quoted again, so
71
- // it stays one word; a command given as a single word is sent as it is.
72
- const quoted = words.length > 1 ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
70
+ // For a command that reads a quoted selector (fill "text=Your name" Ada), a
71
+ // word the shell kept whole is quoted again so it stays one word. Any other
72
+ // command takes the rest of its line as it is (eval, route's JSON), so its
73
+ // words are joined as they are.
74
+ const { SELECTOR_FIRST } = require('../lib/syntax');
75
+ const requote = words.length > 1 && SELECTOR_FIRST.has(words[0]);
76
+ const quoted = requote ? words.map(w => (/[\s"']/.test(w) ? JSON.stringify(w) : w)) : words;
73
77
  options.command = quoted.join(' ').trim();
74
78
  return options;
75
79
  }
package/lib/commands.js CHANGED
@@ -3,6 +3,7 @@ const fs = require('fs');
3
3
  const { state, withTimeout, onShutdown, shutdown } = require('./state');
4
4
  const out = require('./output');
5
5
  const HELP = require('./help');
6
+ const { toSelector, unquote, splitSelector } = require('./syntax');
6
7
 
7
8
  const { printOutput, OUTPUT_LIMIT } = out;
8
9
  const SCREENSHOT_DIR = process.env.PW_SCREENSHOT_DIR || '/tmp';
@@ -666,28 +667,6 @@ async function allModesOff() {
666
667
  if (!any) out.log('No modes were on.');
667
668
  }
668
669
 
669
- // A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
670
- const REF = /^(?:f\d+)?e\d+$/;
671
-
672
- function toSelector(word) {
673
- return REF.test(word) ? `aria-ref=${word}` : word;
674
- }
675
-
676
- // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
677
- function unquote(text) {
678
- const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
679
- if (!match) return text;
680
- return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
681
- }
682
-
683
- // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
684
- function splitSelector(args) {
685
- const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
686
- if (!match) return null;
687
- const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
688
- return { word, selector: toSelector(word), rest: match[4] };
689
- }
690
-
691
670
  // Commands that take only a selector take the whole line, spaces and all.
692
671
  function soleSelector(args, usage) {
693
672
  const text = unquote((args || '').trim());
@@ -703,14 +682,19 @@ function selectorAndValue(args, usage, example) {
703
682
  return { selector: parsed.selector, value: unquote(parsed.rest), shown: parsed.word };
704
683
  }
705
684
 
706
- // A ref that no longer matches usually means the page changed since the snapshot.
685
+ // An element that never appeared is said plainly, not as Playwright's call
686
+ // log. A ref that no longer matches usually means the page changed since the
687
+ // snapshot it came from.
707
688
  async function onElement(selector, action) {
708
689
  try {
709
690
  return await action();
710
691
  } catch (error) {
711
- if (selector.startsWith('aria-ref=') && !/resolved to/.test(error.message)) {
712
- error.message += `\n${selector.slice(9)} is a snapshot ref; if the page changed since that snapshot, take a new one.`;
713
- }
692
+ if (/resolved to/.test(error.message)) throw error;
693
+ const ref = selector.startsWith('aria-ref=') ? selector.slice(9) : null;
694
+ const staleRef = ref ? `\n${ref} is a snapshot ref; if the page changed since that snapshot, take a new one.` : '';
695
+ const waited = /Timeout (\d+)ms exceeded[\s\S]*waiting for locator/.exec(error.message);
696
+ if (waited) throw new Error(`No element matches ${ref || selector} (waited ${waited[1] / 1000}s)${staleRef}`);
697
+ error.message += staleRef;
714
698
  throw error;
715
699
  }
716
700
  }
@@ -802,13 +786,56 @@ function discardCapture() {
802
786
  cap = null;
803
787
  }
804
788
 
805
- // A tab of the REPL's own: closing it can go back to the tab before.
789
+ // A tab of the REPL's own: closing it can go back to the tab before. It opens
790
+ // in the background, so it does not take the front of the window from the
791
+ // person using the browser; Playwright's newPage would bring it to the front.
792
+ const OPEN_TIMEOUT = 10000;
793
+
806
794
  async function openTab() {
807
- const opened = await state.browser.contexts()[0].newPage();
795
+ const ctx = state.browser.contexts()[0];
796
+ let opened = null;
797
+ const session = await state.browser.newBrowserCDPSession().catch(() => null);
798
+ if (session) {
799
+ try {
800
+ const { targetId } = await session.send('Target.createTarget', { url: 'about:blank', background: true });
801
+ opened = await pageForTarget(ctx, targetId);
802
+ } catch {} finally {
803
+ await session.detach().catch(() => {});
804
+ }
805
+ }
806
+ // A browser that cannot open one in the background (e.g. some headless ones) opens it as usual.
807
+ if (!opened) opened = await ctx.newPage();
808
808
  openedTabs.add(opened);
809
809
  return opened;
810
810
  }
811
811
 
812
+ // page -> its CDP target id, asked for once per page.
813
+ const targetIds = new WeakMap();
814
+
815
+ async function targetIdOf(ctx, p) {
816
+ if (!targetIds.has(p)) {
817
+ const cdp = await ctx.newCDPSession(p).catch(() => null);
818
+ if (!cdp) return null;
819
+ const info = await cdp.send('Target.getTargetInfo').catch(() => null);
820
+ await cdp.detach().catch(() => {});
821
+ if (!info) return null;
822
+ targetIds.set(p, info.targetInfo.targetId);
823
+ }
824
+ return targetIds.get(p);
825
+ }
826
+
827
+ async function pageForTarget(ctx, targetId) {
828
+ const deadline = Date.now() + OPEN_TIMEOUT;
829
+ while (Date.now() < deadline) {
830
+ for (const p of ctx.pages()) {
831
+ if (openedTabs.has(p)) continue;
832
+ if (await targetIdOf(ctx, p) === targetId) return p;
833
+ }
834
+ await new Promise(resolve => setTimeout(resolve, 50));
835
+ }
836
+ return null;
837
+ }
838
+
812
839
  // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
813
840
  async function listTabs() {
814
841
  const all = state.browser.contexts().flatMap(c => c.pages());
@@ -1073,6 +1100,9 @@ const commands = {
1073
1100
  }
1074
1101
  // Named after the countdown so a default filename timestamps the capture.
1075
1102
  const filepath = nextScreenshotPath(name);
1103
+ // Chrome does not draw a tab that is not in front, and tabs open in the
1104
+ // background, so the tab is brought to the front for the shot.
1105
+ await state.page.bringToFront();
1076
1106
  const image = await state.page.screenshot({ fullPage: full });
1077
1107
  try {
1078
1108
  fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
@@ -1226,7 +1256,8 @@ const commands = {
1226
1256
  // The numbers skip what is hidden; say so, or they look like requests went missing.
1227
1257
  const first = matches[0].id;
1228
1258
  const last = matches[matches.length - 1].id;
1229
- const hidden = everything ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
1259
+ // With a filter, the numbers skip what does not match too, so the count would mislead.
1260
+ const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
1230
1261
  if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
1231
1262
  },
1232
1263
 
@@ -1247,7 +1278,8 @@ const commands = {
1247
1278
  const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
1248
1279
  let buffer;
1249
1280
  try { buffer = await Promise.race([entry.response.body(), late]); }
1250
- catch (error) { throw new Error(`The body of #${id} is not available: ${error.message}`); }
1281
+ // Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
1282
+ catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates)`); }
1251
1283
  finally { clearTimeout(timer); }
1252
1284
  const type = entry.response.headers()['content-type'] || '';
1253
1285
  out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
package/lib/help.js CHANGED
@@ -72,7 +72,7 @@ const COMMANDS = {
72
72
  tab: {
73
73
  usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
74
74
  summary: 'list tabs (* is selected); select, open or close one',
75
- detail: 'tab <index> uses the numbers from the latest tab listing; they change when tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens and selects a tab. tab close closes the selected tab, or the one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
75
+ detail: 'tab <index> uses the numbers from the latest tab listing; they change when tabs open or close.\n\ntab <url-part> selects the one tab whose URL contains it, and refuses if none or several do.\n\ntab new opens a tab behind the one in front, so it does not take the window from whoever is using it,\nand selects it. tab close closes the selected tab, or the one matching url-part.\n\nClosing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is\nselected, and commands that need one refuse until one is.',
76
76
  },
77
77
  goto: { usage: 'goto <url>', summary: 'navigate the selected tab; https:// is assumed' },
78
78
  back: { usage: 'back', summary: 'go back one history entry' },
@@ -112,7 +112,7 @@ const COMMANDS = {
112
112
  screenshot: {
113
113
  usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
114
114
  summary: 'save a PNG of the viewport (or --full page)',
115
- detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp).\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
115
+ detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR (default /tmp). It brings the tab to\nthe front of its window first: Chrome draws only the tab in front.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
116
116
  },
117
117
  viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
118
118
  wait: {
@@ -139,7 +139,7 @@ const COMMANDS = {
139
139
  capture: {
140
140
  usage: 'capture on [requests|console] [secs] | off',
141
141
  summary: 'record requests and console together, in time order',
142
- detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints; the REPL waits meanwhile.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
142
+ detail: 'capture on records both until capture off, which prints them; requests or console records only one.\nWith secs (1-3600 seconds) it records that long, then prints. Other commands wait until a timed\ncapture ends, so it suits recording what someone does in the browser; around your own commands, use\ncapture on and capture off.\n\nOne capture runs at a time, on the tab selected when it started. Unlike requests and console it\nkeeps more than the last 200 and lists both together.\n\ncapture on its own says whether one is running, or shows the last one.',
143
143
  },
144
144
  route: {
145
145
  usage: 'route <glob> <status> <json> | off <glob>|--all',
@@ -166,7 +166,7 @@ const COMMANDS = {
166
166
  modes: {
167
167
  usage: 'modes [off]',
168
168
  summary: 'the modes on in every tab; modes off turns them all off',
169
- detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
169
+ detail: 'The modes are watch, network off, route and capture. Each is turned on and off with its own command:\nwatch on|off, network off|on, route <glob> ... | route off <glob>|--all, capture on|off.\n\nmodes off turns off every one in every tab; a capture it stops is kept for capture to show.',
170
170
  },
171
171
  help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
172
172
  quit: {
package/lib/runner.js CHANGED
@@ -72,6 +72,8 @@ async function executeCommand(text) {
72
72
  try {
73
73
  await commands[cmd](args, all);
74
74
  } catch (e) {
75
+ // Playwright colours its call log; the codes are noise anywhere but a terminal.
76
+ e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
75
77
  out.error(`Error: ${e.message}`);
76
78
  const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
77
79
  if (uncertain) out.error('Outcome unknown: it timed out after it began acting on the page. Check the page before trying again.');
package/lib/send.js CHANGED
@@ -122,10 +122,15 @@ async function where(options) {
122
122
  if (way.explicit) return 64;
123
123
  }
124
124
  const session = options.session || process.env.PW_TMUX_SESSION || 'playwright-repl';
125
- if (!hasSession(session)) { console.error(`tmux: no session '${session}'`); return 64; }
125
+ if (!hasSession(session)) {
126
+ console.error(`tmux: no session '${session}'`);
127
+ console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
128
+ return 64;
129
+ }
126
130
  const running = tmux('display-message', '-p', '-t', session, '#{pane_current_command}').trim();
127
131
  if (running === 'node') { console.log(`tmux: session '${session}' (the REPL was started with pw-repl run; no server)`); return 0; }
128
132
  console.error(`tmux: session '${session}' is running '${running}', not the REPL`);
133
+ console.error('A REPL on a socket of its own is found with -e <socket> or PW_SOCKET.');
129
134
  return 64;
130
135
  }
131
136
 
package/lib/syntax.js ADDED
@@ -0,0 +1,29 @@
1
+ // How a command line's words are read, shared by the REPL and pw-repl send.
2
+
3
+ // Commands whose first word is a selector, quoted if it has spaces, and whose
4
+ // value is the rest of the line. Other commands take the rest of the line as it is.
5
+ const SELECTOR_FIRST = new Set(['fill', 'type', 'select', 'press']);
6
+
7
+ // A snapshot ref (e5, or f1e5 in newer Playwright) stands for aria-ref=e5.
8
+ const REF = /^(?:f\d+)?e\d+$/;
9
+
10
+ function toSelector(word) {
11
+ return REF.test(word) ? `aria-ref=${word}` : word;
12
+ }
13
+
14
+ // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
15
+ function unquote(text) {
16
+ const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
17
+ if (!match) return text;
18
+ return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
19
+ }
20
+
21
+ // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
22
+ function splitSelector(args) {
23
+ const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
24
+ if (!match) return null;
25
+ const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
26
+ return { word, selector: toSelector(word), rest: match[4] };
27
+ }
28
+
29
+ module.exports = { SELECTOR_FIRST, toSelector, unquote, splitSelector };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -11,6 +11,12 @@ shared session: a person can have their own tabs open in it and be using it whil
11
11
  `pw-repl` below is the command the `pw-repl` npm package installs. Without a global install,
12
12
  `npx pw-repl` works the same; from a clone, `<clone>/bin/pw-repl.js`.
13
13
 
14
+ ## Arguments
15
+
16
+ Freeform args describe what to do in the browser: a page to look at, a flow to try, a failure to
17
+ reproduce (e.g. `pw-repl find out why the cart total shows 0 after adding an item`). They are the task;
18
+ the sections below are how to carry it out. With no args, get a REPL running and ask what to do.
19
+
14
20
  ## Start it
15
21
 
16
22
  `pw-repl where` says whether a REPL is running and how `send` reaches it. There are three ways to run
@@ -29,8 +35,18 @@ one; each takes an optional start URL, which opens in a new tab.
29
35
  tmux send-keys -t playwright-repl Enter
30
36
  ```
31
37
 
32
- `serve <port>` listens on TCP 127.0.0.1 instead of the socket, with no access control. A REPL in a
33
- terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
38
+ `serve` and `serve --background` take a socket path of your own instead of the default
39
+ (`pw-repl serve --background /tmp/mine.sock`); `send`, `attach`, `stop` and `where` then need
40
+ `-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`. The log of a background REPL is next to its socket
41
+ (`/tmp/mine.log`); `tail -f` on it follows along without a terminal to attach from. `serve <port>`
42
+ listens on TCP 127.0.0.1 instead of a socket, with no access control.
43
+
44
+ Several REPLs can run at once, each on its own socket, e.g. one per agent. Each has its own selected tab,
45
+ command queue and modes, so they do not wait on or select for each other. They share the browser,
46
+ though: each sees every tab, `modes` lists only its own REPL's modes, and two REPLs acting on the same
47
+ tab can undo each other's routes or network setting.
48
+
49
+ A REPL in a terminal stops at its prompt (`quit`, or Ctrl-C); `send quit` is refused.
34
50
 
35
51
  ## Send commands
36
52
 
@@ -40,8 +56,9 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
40
56
  ```
41
57
 
42
58
  Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
43
- are the command, and a word quoted in your shell stays one word:
44
- `pw-repl send fill "text=Your name" Ada`. It works however the REPL was started:
59
+ are the command. For `fill`, `type`, `select` and `press`, a word quoted in your shell stays one word
60
+ (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are
61
+ (`pw-repl send eval "document.title + ' x'"`). It works however the REPL was started:
45
62
 
46
63
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
47
64
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle