pw-repl 0.3.1 → 0.3.2

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/AGENTS.md CHANGED
@@ -27,6 +27,29 @@ If you hit a limitation or write a workaround, consider adding the capability to
27
27
  - `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
28
28
  used, and leave its Custom rules section empty.
29
29
 
30
+ ## Waves
31
+
32
+ Work goes in waves, and each one ends with a release that is ready to push.
33
+
34
+ 1. Build what was asked, with tests, and try it in a running REPL.
35
+ 2. Try it on a fresh agent that has not seen the code or the change (a guinea pig), on something real: a
36
+ small local page whose planted bugs need the change to find.
37
+ - Brief it with goals only: what to find out or do in the browser, never the commands.
38
+ - First it reads back its plan, learned from `pw-repl skill` and help alone: the commands, where it
39
+ found them, and what is unclear. It runs nothing yet.
40
+ - A wrong or unsure plan means the docs or the brief were unclear: find out which, fix that, then
41
+ ask again.
42
+ - Then it carries the plan out, in a browser and on a socket of its own, and reports as it goes:
43
+ above all, where the tool did something other than what the docs led it to expect.
44
+ 3. Triage what it reports. Fix what matters, and what is small and useful; don't put off something
45
+ useful for later. Leave trivia alone, and say why.
46
+ 4. Repeat 2 and 3, with a fresh agent each time, until a pass comes back clean: no real bugs, and
47
+ nothing unclear in the docs or the brief that changed the plan. A pass after fixes tries the fixes,
48
+ and something not tried yet.
49
+ 5. Only then cut the release (Releasing, steps 1-3), and say it is ready. The maintainer pushes and
50
+ publishes it, and the next wave starts from what they find. A release cut too early and not pushed
51
+ yet is undone (its commit and tag) and cut again after the fixes.
52
+
30
53
  ## Releasing
31
54
 
32
55
  Commits follow [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`,
package/lib/commands.js CHANGED
@@ -154,12 +154,12 @@ async function viewportText() {
154
154
 
155
155
  // A page restored from the back/forward cache never fires its load events
156
156
  // again, so back and forward wait only for the navigation, then briefly for
157
- // the page, rather than time out on a page that is already there.
157
+ // the document to be parsed, which a restored one already is.
158
158
  async function historyStep(go, direction) {
159
159
  const before = state.page.url();
160
160
  const response = await go();
161
161
  if (response === null && state.page.url() === before) throw new Error(`No page to go ${direction} to`);
162
- await state.page.waitForLoadState('domcontentloaded', { timeout: 3000 }).catch(() => {});
162
+ await state.page.waitForFunction(() => document.readyState !== 'loading', null, { timeout: 3000 }).catch(() => {});
163
163
  }
164
164
 
165
165
  const SCREENSHOT_DELAY_MAX = 60;
@@ -212,10 +212,18 @@ function emulatedKinds(e) {
212
212
  return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
213
213
  }
214
214
 
215
+ const DEVICES_SHOWN = 20;
216
+
215
217
  function deviceNamed(name) {
216
- const found = Object.keys(devices).find(d => d.toLowerCase() === name.toLowerCase());
217
- if (!found) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
218
- return found;
218
+ const names = Object.keys(devices);
219
+ const found = names.find(d => d.toLowerCase() === name.toLowerCase());
220
+ if (found) return found;
221
+ // The names that have every word typed, so a near miss finds the exact name.
222
+ const words = name.toLowerCase().split(/\s+/);
223
+ const close = names.filter(d => !/ landscape$/.test(d) && words.every(w => d.toLowerCase().includes(w)));
224
+ if (!close.length) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
225
+ const more = close.length > DEVICES_SHOWN ? `, and ${close.length - DEVICES_SHOWN} more` : '';
226
+ throw new Error(`No device ${JSON.stringify(name)}; matching: ${close.slice(0, DEVICES_SHOWN).join(', ')}${more} (each also as "<name> landscape")`);
219
227
  }
220
228
 
221
229
  function deviceText(name) {
@@ -225,6 +233,14 @@ function deviceText(name) {
225
233
 
226
234
  // Sends only what changed. Chrome takes the user agent and the languages
227
235
  // together, so a change to either sends both.
236
+ function deviceMetrics(session, name) {
237
+ const d = devices[name];
238
+ return session.send('Emulation.setDeviceMetricsOverride', {
239
+ width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
240
+ screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
241
+ });
242
+ }
243
+
228
244
  async function setEmulation(p, next) {
229
245
  const session = await keptSession(p);
230
246
  const previous = emulations.get(p) || {};
@@ -232,10 +248,7 @@ async function setEmulation(p, next) {
232
248
  if (next.device !== previous.device) {
233
249
  const d = devices[next.device];
234
250
  if (d) {
235
- await send('Emulation.setDeviceMetricsOverride', {
236
- width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
237
- screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
238
- });
251
+ await deviceMetrics(session, next.device);
239
252
  } else {
240
253
  await send('Emulation.clearDeviceMetricsOverride');
241
254
  }
@@ -1042,7 +1055,11 @@ function grepSnapshot(text, needle) {
1042
1055
  const indent = line.length - line.trimStart().length;
1043
1056
  while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
1044
1057
  if (line.toLowerCase().includes(lower)) {
1045
- const path = stack.filter(a => !/^generic(?: \[|$)/.test(a.label)).map(a => a.label.replace(/ \[ref=[^\]]+\]/g, ''));
1058
+ const around = stack.filter(a => !/^generic(?: \[|$)/.test(a.label));
1059
+ // A hit with no ref of its own (text) keeps the ref of what holds it, so
1060
+ // snapshot <ref> can show what sits beside it: the value next to a label.
1061
+ const own = /\[ref=/.test(line);
1062
+ const path = around.map((a, i) => (!own && i === around.length - 1 ? a.label : a.label.replace(/ \[ref=[^\]]+\]/g, '')));
1046
1063
  hits.push([...path, label(line)].join(' › '));
1047
1064
  }
1048
1065
  stack.push({ indent, label: label(line) });
@@ -1473,7 +1490,19 @@ const commands = {
1473
1490
  // Chrome does not draw a tab that is not in front, and tabs open in the
1474
1491
  // background, so the tab is brought to the front for the shot.
1475
1492
  await state.page.bringToFront();
1476
- const image = await state.page.screenshot({ fullPage: full });
1493
+ let image;
1494
+ try {
1495
+ image = await state.page.screenshot({ fullPage: full });
1496
+ } finally {
1497
+ // Playwright's screenshot resets the screen size and pixel ratio it
1498
+ // finds; an emulated phone's are set again.
1499
+ const device = emulations.get(state.page)?.device;
1500
+ if (device) {
1501
+ const session = await keptSession(state.page);
1502
+ await session.send('Emulation.clearDeviceMetricsOverride');
1503
+ await deviceMetrics(session, device);
1504
+ }
1505
+ }
1477
1506
  try {
1478
1507
  fs.writeFileSync(filepath, image, { flag: 'wx', mode: 0o600 });
1479
1508
  } catch (error) {
@@ -1570,6 +1599,8 @@ const commands = {
1570
1599
  if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
1571
1600
  if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
1572
1601
  const timeout = seconds * 1000;
1602
+ // Said plainly, not as Playwright's call log.
1603
+ const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
1573
1604
  const kind = tokens[0];
1574
1605
  if (kind === 'load' && tokens.length === 1) {
1575
1606
  if (gone) throw new Error(usage);
@@ -1582,13 +1613,13 @@ const commands = {
1582
1613
  return waitForRequest(what, timeout);
1583
1614
  }
1584
1615
  if (gone) return waitGone(state.page.getByText(what), what, timeout);
1585
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
1616
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s`));
1586
1617
  out.log(`Visible: ${what}`);
1587
1618
  return;
1588
1619
  }
1589
1620
  const selector = toSelector(unquote(tokens.join(' ')));
1590
1621
  if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
1591
- await state.page.waitForSelector(selector, { state: 'attached', timeout });
1622
+ await state.page.waitForSelector(selector, { state: 'attached', timeout }).catch(plainly(`No element matches ${tokens.join(' ')} within ${seconds}s`));
1592
1623
  out.log(`Found: ${tokens.join(' ')}`);
1593
1624
  },
1594
1625
 
package/lib/help.js CHANGED
@@ -52,7 +52,7 @@ const TOPICS = {
52
52
  commands: ['requests', 'body', 'console', 'capture', 'route', 'network'],
53
53
  },
54
54
  devtools: {
55
- intro: 'Cookie and storage listings omit values. eval, html, screenshots and URLs can still show sensitive data.',
55
+ intro: 'Cookie and storage listings omit values. eval, cdp, html, screenshots and URLs can still show\nsensitive data (cdp Network.getCookies shows cookie values).',
56
56
  commands: ['eval', 'cdp', 'cookies', 'storage'],
57
57
  },
58
58
  session: {
@@ -84,8 +84,12 @@ const COMMANDS = {
84
84
  summary: 'navigate the selected tab',
85
85
  detail: 'Without a scheme, http:// is assumed for localhost, 127.x and [::1], and https:// otherwise, as in\nthe address bar. tab new <url> reads its URL the same way.',
86
86
  },
87
- back: { usage: 'back', summary: 'go back one history entry' },
88
- forward: { usage: 'forward', summary: 'go forward one history entry' },
87
+ back: {
88
+ usage: 'back',
89
+ summary: 'go back one history entry',
90
+ detail: 'The browser may bring the page back from its back/forward cache, as it was: nothing loads or runs\nagain, so a loading message or a request the page makes on load does not come back. reload loads it\nafresh.',
91
+ },
92
+ forward: { usage: 'forward', summary: 'go forward one history entry', detail: 'Like back, it may bring the page back from the back/forward cache without loading it again.' },
89
93
  reload: { usage: 'reload', summary: 'reload the selected tab' },
90
94
  info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
91
95
  click: { usage: 'click <selector>', summary: 'click the first match' },
@@ -109,7 +113,7 @@ const COMMANDS = {
109
113
  snapshot: {
110
114
  usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
111
115
  summary: 'outline by role and name, with [ref=eN] labels',
112
- detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
116
+ detail: 'Playwright\'s accessibility snapshot, with unnamed layout wrappers (generic) and cursor hints left\nout; --full shows it unchanged.\n\n--grep <text> prints only the lines containing text (role, name or flag such as [disabled], any\ncase), each with the named elements around it. A hit with no named element around it prints without\na path. A hit on text (a label) keeps the ref of the element holding it, so snapshot <that ref>\nshows what sits beside it, such as the value next to the label.\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3 in newer Playwright) works as a selector in any\ncommand: click e3. Refs can change when the page changes (e102 may become f4e98), so take a new\nsnapshot after it does.\n\nOutput over 60 lines ends with a line count.',
113
117
  },
114
118
  watch: {
115
119
  usage: 'watch on [--changes] [--live] | off | [n|new]',
@@ -131,18 +135,18 @@ const COMMANDS = {
131
135
  screenshot: {
132
136
  usage: 'screenshot [--full] [--delay|-d <seconds>] [name]',
133
137
  summary: 'save a PNG of the viewport (or --full page)',
134
- 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.',
138
+ 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. The image is at CSS pixel size,\none image pixel per CSS pixel, also while emulate mobile is on.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
135
139
  },
136
140
  viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
137
141
  emulate: {
138
142
  usage: 'emulate [<what> [off] | off]',
139
143
  summary: 'emulate a phone, dark mode, a locale or a timezone',
140
- detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nemulate mobile or emulate locale. While mobile is on, viewport refuses: the device sets the size.',
144
+ detail: 'emulate mobile [device] a phone\'s screen, touch and user agent: a Pixel 7, or a device named as in\n Playwright\'s list, e.g. emulate mobile iPhone 13; a name that is not\n exact lists the devices it matches (emulate mobile galaxy)\nemulate dark | light the color scheme the page\'s CSS and matchMedia see\nemulate locale <tag> the language (Accept-Language, navigator.language) and date and number\n formats, e.g. fr-FR\nemulate timezone <zone> an IANA timezone, e.g. Asia/Tokyo\nemulate <what> off stop one (dark or light off stops the color scheme); emulate off stops all\n\nPer tab, until turned off or the REPL exits. emulate on its own shows what is on.\n\nThe page sees the user agent, touch and navigator.languages from its next load: reload after\nturning mobile or locale on or off. While mobile is on, viewport refuses: the device sets the size.',
141
145
  },
142
146
  wait: {
143
147
  usage: 'wait [text|request] <what> [--gone] [secs]',
144
148
  summary: 'wait for an element, text, a response or a load (10s)',
145
- detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nwait load waits for the page to finish loading (its load event). A navigation that began since the\nprevious command began counts, so click a link, then wait load, waits for the new page.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
149
+ detail: 'wait <selector> waits for a matching element; wait text <text> for text to be visible; wait request\n<url-part|glob> for a matching response, counting one that finished since the previous command began\n(so click, then wait request, does not miss it).\n\nwait load waits for the page to finish loading (its load event). A navigation that began since the\nprevious command began counts, so click a link, then wait load, waits for the new page. A page\nthat changes its own URL without loading (an app\'s own routing) is not a load: wait for something on\nthe new view instead.\n\nwait text matches any part of an element\'s text, in any case.\n\n--gone waits instead until nothing matching is visible (removed or hidden): wait .spinner --gone.\nIt is done at once if nothing matches yet, so wait for it to appear first if it may not have.\n\nUp to 120s. A wait that times out is an error; the REPL carries on.',
146
150
  },
147
151
  sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
148
152
  requests: {
@@ -158,7 +162,7 @@ const COMMANDS = {
158
162
  console: {
159
163
  usage: 'console [--all] [n] [filter]',
160
164
  summary: 'the last n console messages and page errors',
161
- detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions.\n\nfilter matches the type or the text, e.g. console error.',
165
+ detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: time, [type],\ntext. Types are console levels (log, warning, error, ...) and pageerror for uncaught exceptions.\n\nfilter matches the type or the text, e.g. console error.\n\nA page brought back from the back/forward cache (back, forward) reports its earlier messages again,\nat the time it comes back, though nothing loaded again.',
162
166
  },
163
167
  capture: {
164
168
  usage: 'capture on [requests|console] [secs] | off',
@@ -173,7 +177,7 @@ const COMMANDS = {
173
177
  network: {
174
178
  usage: 'network [on|off|slow [<ms> [<kbps>]]]',
175
179
  summary: 'cut, slow or restore the tab\'s network',
176
- detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow adds latency to each request and limits its speed: by default as DevTools\' Slow 4G\n(563ms, 1440 kbps down, 675 up); network slow <ms> [<kbps>] sets them. To slow one API, use route\n<glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
180
+ detail: 'network off cuts it, like dropped wifi. Stopping a service is not the same: a dev proxy in front of\nit usually holds the request open, so the page spins instead of failing.\n\nnetwork slow makes each request take at least <ms> (Chrome\'s latency is a minimum, not added to a\nslow server\'s own time) and limits its speed: by default as DevTools\' Slow 4G (563ms, 1440 kbps\ndown, 675 up); network slow <ms> [<kbps>] sets them, kbps both ways (left out, it stays Slow\n4G\'s). To slow one API, use route <glob> delay <secs>.\n\nPer tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow.',
177
181
  },
178
182
  eval: {
179
183
  usage: 'eval [--all] <JavaScript>',
package/lib/send.js CHANGED
@@ -117,7 +117,8 @@ async function where(options) {
117
117
  // use, so a REPL that will not start can be told apart from one that is not running.
118
118
  const cdp = info?.browser || process.env.PW_CDP_URL || 'http://localhost:9222';
119
119
  const version = await fetch(`${cdp}/json/version`, { signal: AbortSignal.timeout(2000) }).then(r => r.json()).catch(() => null);
120
- const whose = info?.launched ? ', launched by this REPL' : '';
120
+ // With no REPL running, it is only where one would connect, not a browser of a REPL that stopped.
121
+ const whose = info?.launched ? ', launched by this REPL' : info ? '' : '; run and serve connect here without --launch';
121
122
  console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
122
123
  if (way.kind === 'server') {
123
124
  const name = client.describe(way.endpoint);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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
@@ -59,9 +59,10 @@ pw-repl send -t 90 'screenshot -d 60' # wait longer than the 20s default
59
59
  ```
60
60
 
61
61
  Always send commands with `pw-repl send`; don't type into the pane yourself. The words after `send`
62
- are the command. For `fill`, `type`, `select` and `press`, a word quoted in your shell stays one word
63
- (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are
64
- (`pw-repl send eval "document.title + ' x'"`). It works however the REPL was started:
62
+ are the command. For `fill`, `type`, `select`, `press` and `upload`, a word quoted in your shell stays
63
+ one word (`pw-repl send fill "text=Your name" Ada`); other commands get the words as they are, joined by
64
+ spaces (`pw-repl send eval "document.title + ' x'"`). A command that takes only a selector takes the whole
65
+ line, spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL was started:
65
66
 
66
67
  - `run` (in tmux): `send` types the command into the tmux pane and reads the result back off the screen.
67
68
  It types only when the pane's last line is a bare prompt, so nothing lands in a shell or in the middle
@@ -114,8 +115,9 @@ help is the command reference; this file does not repeat it.
114
115
  one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
115
116
  - `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
116
117
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
117
- - `PW_SCREENSHOT_DIR` — where screenshots go (default `/tmp`). They are all named `screenshot-*.png`,
118
- so `rm /tmp/screenshot-*.png` cleans up.
118
+ - `PW_SCREENSHOT_DIR` — where the REPL saves screenshots (default `/tmp`), read when it starts, not by
119
+ `send`. `screenshot` prints each file's path; other REPLs may save theirs there too. Remove only your
120
+ own, and keep those too if they are needed, e.g. as evidence or as something to hand over.
119
121
  - `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `send`
120
122
  looks for when neither is given (default `/tmp/playwright-repl.sock`).
121
123