pw-repl 0.12.7 → 0.12.8

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/lib/help.js CHANGED
@@ -22,36 +22,50 @@ function wrap(text, width = WIDTH) {
22
22
  }).join('\n\n');
23
23
  }
24
24
 
25
+ // The common tasks, as the README's table lists them too (a unit test keeps the two the same).
26
+ const TASKS = [
27
+ ['see where I am', 'tab, info'],
28
+ ['see what is on the page', 'snapshot, screenshot'],
29
+ ['do something on it', 'click, fill, type, press, drag'],
30
+ ['click what has no selector (a canvas)', 'mouseclick <x> <y>, mousedblclick <x> <y>'],
31
+ ['run some JavaScript in the page', 'eval document.title; eval --file=check.js for a long one'],
32
+ ['wait for a page or element', 'wait load, wait <selector> [--gone], wait text <text>'],
33
+ ['choose a file in a file input', 'upload <selector> <file>'],
34
+ ['see it as a phone, or in dark mode', 'emulate mobile, emulate dark'],
35
+ ['see what the page requested', 'requests, then body <#> for what one got back'],
36
+ ['see console messages and errors', 'console'],
37
+ ['show an agent what I do', 'watch on, click around, then watch'],
38
+ ['see each step as I click', 'watch on --live'],
39
+ ['watch a tab someone will open', 'watch on --next-tab [url-part]'],
40
+ ['replay what I clicked through', 'watch save flow.txt, then pw-repl send --file flow.txt'],
41
+ ['record requests and console together', 'capture on, then capture off'],
42
+ ['find what makes the page slow', 'profile on, then profile off; profile reload for a load'],
43
+ ['fake a backend response', 'route <glob> <status> <json>, route <glob> abort'],
44
+ ['change or slow an API response', 'route <glob> patch <json>, route <glob> delay <secs>'],
45
+ ['cut or slow the whole network', 'network off, network slow, then network on'],
46
+ ['record a video of the page', 'record on, then record off (help video for a take)'],
47
+ ['show a pointer where the agent acts', 'cursor on, then cursor off'],
48
+ ['say what is happening on screen', 'toast <text>, then toast off'],
49
+ ['point something out on the page', 'highlight <ref>, then highlight off'],
50
+ ['clean up', 'modes off'],
51
+ ];
52
+
53
+ // The commands that turn on a mode, as the README lists them too.
54
+ const MODES = 'watch, capture, profile, record, route, network off or slow, emulate, viewport, highlight, cursor and toast';
55
+
25
56
  const OVERVIEW = wrap(`pw-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
26
57
  The browser may be a shared session, with other users' tabs open in it.
27
58
 
28
59
  Common tasks:
29
- where am I? tab, info
30
- what is on the page? snapshot, screenshot
31
- do something on it click, fill, press
32
- wait for a page or element wait load, wait <selector>, wait <selector> --gone
33
- choose a file in a file input upload <selector> <file>
34
- see it as a phone, or dark emulate mobile, emulate dark
35
- what did the page request? requests, then body <#> for what one got back
36
- console messages and errors console
37
- show an agent what I do watch on, click around, then watch
38
- see each step as I click watch on --live
39
- watch a tab someone will open watch on --next-tab [url-part]
40
- requests and console together capture on, then capture off
41
- a video of what I do help video
42
- break the backend on purpose route <glob> <status> <json>, route <glob> abort, network off
43
- change or slow an API response route <glob> patch <json>, route <glob> delay <secs>
44
- slow the whole network network slow
45
- clean up modes off
46
-
47
- Modes (watch, capture, record, route, network off or slow, emulate, viewport) stay on until turned off;
48
- the prompt shows the selected tab's: (watch routes:1) pw>. tab, watch, capture, record, route, network,
49
- emulate and modes on their own show their state and what you can run next.
60
+ ${TASKS.map(([task, commands]) => ` ${task.padEnd(38)} ${commands}`).join('\n')}
61
+
62
+ Modes (${MODES}) stay on until turned off; the prompt shows the selected tab's: (watch routes:1) pw>.
63
+ Each of them on its own, and modes, shows its state and what you can run next.
50
64
 
51
65
  Topics (help <topic>):
52
66
  tabs open, select, close, navigate network requests, bodies, console, fakes, network off
53
67
  interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
54
- inspect snapshot, watch, text, screenshots session modes, markers, server, quitting
68
+ inspect snapshot, watch, wait, screenshots session modes, server, clients, quitting
55
69
  video record a take, with a pointer
56
70
 
57
71
  help <command> for usage and caveats (e.g. help route), help <command> --all for all of it; help --all
@@ -71,7 +85,7 @@ tab on its own lists them all.`,
71
85
  Commands use the first match (click, dblclick and hover the first they can use: help click); see help
72
86
  fill for selectors with spaces. mousemove and the other mouse commands act at a point instead. An action
73
87
  scrolls its element into view first, as Playwright does; profile counts that scroll in the action's step.`,
74
- commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel'],
88
+ commands: ['click', 'dblclick', 'hover', 'drag', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel'],
75
89
  },
76
90
  inspect: {
77
91
  intro: `Output is capped; --all right after the command, or at the end, shows everything (text --all body).`,
@@ -124,12 +138,7 @@ still show sensitive data (cdp Network.getCookies shows cookie values).`,
124
138
  commands: ['eval', 'cdp', 'cookies', 'storage'],
125
139
  },
126
140
  session: {
127
- intro: `Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.
128
-
129
- Unknown outcome: a command that timed out after it began acting on the page may or may not have done
130
- it. It says so (pw-repl send exits 2), and the REPL carries on.
131
-
132
- Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
141
+ intro: `Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
133
142
 
134
143
  Clients: pw-repl send -c <name> (or $PW_CLIENT) sends as a client of its own. It has a selected tab of
135
144
  its own, so clients do not move each other's; its commands show as [server:<name>] lines. modes and tab
@@ -143,7 +152,12 @@ selected tab, if any: (watch network:off routes:2 capture) pw>. modes lists them
143
152
 
144
153
  Times (requests, console, watch) are local, with their UTC offset: 18:16:15.721-06:00.
145
154
 
146
- Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.`,
155
+ Dialogs are reported and never answered on their own; dialog accept or dismiss answers one, from any client, whoever opened it.
156
+
157
+ Unknown outcome: a command that timed out after it began acting on the page may or may not have done
158
+ it. It says so (pw-repl send exits 2), and the REPL carries on.
159
+
160
+ Completion: @<id> <command> makes the REPL print [[pw-done:<id>:ok|error]] when it finishes.`,
147
161
  commands: ['modes', 'dialog', 'help', 'quit'],
148
162
  },
149
163
  };
@@ -197,6 +211,8 @@ afresh.`,
197
211
  ['[button]', 'left, right or middle, after a ref or a quoted selector: click e5 right. Default: left.'],
198
212
  ],
199
213
  options: [
214
+ ['--button=<button>', 'the button, after any selector: click #menu --button=right. Over send, the shell takes the quotes, so pw-repl send click \'#menu\' right clicks a selector "#menu right": use --button, or quote it twice (\'"#menu"\').'],
215
+ ['--click-count=<n>', 'clicks this many times at once, as Playwright\'s clickCount: 2 is a double-click (as dblclick), 3 a triple-click, which selects a line of text or all of a field\'s. 1 to 3. Default: 1.'],
200
216
  ['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
201
217
  ],
202
218
  detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
@@ -208,8 +224,23 @@ it to be ready.
208
224
  Timed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts pointer
209
225
  events) and that the outcome is unknown; Playwright's full call log goes to the REPL's pane or log.`,
210
226
  },
211
- dblclick: { usage: 'dblclick <selector> [button]', summary: 'double-click, choosing the match as click does' },
227
+ dblclick: {
228
+ usage: 'dblclick <selector> [button]',
229
+ summary: 'double-click, as click does, with its options',
230
+ detail: `click --click-count=2, as Playwright's dblclick is. For a triple-click: click --click-count=3.`,
231
+ },
212
232
  hover: { usage: 'hover <selector>', summary: 'hover, choosing the match as click does' },
233
+ drag: {
234
+ usage: 'drag <from> <to>',
235
+ summary: 'drag one element onto another',
236
+ args: [
237
+ ['<from> <to>', 'selectors or snapshot refs, each quoted if it has spaces: drag e5 e9, drag "text=Item 1" "#done ul".'],
238
+ ],
239
+ detail: `As Playwright's dragTo: it hovers the first match of <from>, presses the left button, hovers the
240
+ first match of <to>, and releases, waiting up to 5s in all for each to be ready. An HTML5 drag (a
241
+ draggable element) gets dragstart, dragover and drop; any other gets pointer and mouse events. A drag
242
+ through points of its own: mousemove, mousedown, mousemove, mouseup.`,
243
+ },
213
244
  mousemove: {
214
245
  usage: 'mousemove <x> <y>',
215
246
  summary: 'move the mouse to a point in the viewport',
@@ -220,7 +251,7 @@ events) and that the outcome is unknown; Playwright's full call log goes to the
220
251
  eval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")
221
252
  .getBoundingClientRect())
222
253
 
223
- A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
254
+ A click at a point: mouseclick <x> <y>; a double-click: mousedblclick <x> <y>. A drag: mousedown, then mousemove to another point, then mouseup.`,
224
255
  },
225
256
  mouseclick: {
226
257
  usage: 'mouseclick <x> <y> [button]',
@@ -230,14 +261,30 @@ A click at a point: mouseclick <x> <y>. A drag: mousedown, then mousemove to ano
230
261
  ['[button]', 'left, right or middle. Default: left.'],
231
262
  ],
232
263
  options: [
264
+ ['--button=<button>', 'the button, as click\'s: mouseclick 400 300 --button=right.'],
265
+ ['--click-count=<n>', 'as click\'s: 2 is a double-click (as mousedblclick), 3 a triple-click. 1 to 3. Default: 1.'],
233
266
  ['--delay=<ms>', 'holds the button down this long between press and release, as Playwright\'s mouse.click delay. Default: 0.'],
234
267
  ],
235
268
  detail: `For what has no selector, such as a marker drawn on a canvas; anything a selector finds is better
236
269
  clicked with click, which waits for it. help mousemove shows how to find a point. The mouse stays at the point, for a mousewheel or mousedown after
237
270
  it. The cursor (help cursor) moves there first and shows a ring at the press.`,
238
271
  },
239
- mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)' },
240
- mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is' },
272
+ mousedblclick: {
273
+ usage: 'mousedblclick <x> <y> [button]',
274
+ summary: 'double-click at a point in the viewport',
275
+ args: [
276
+ ['<x> <y>', 'CSS pixels from the top left of the tab\'s viewport, as for mousemove.'],
277
+ ['[button]', 'left, right or middle. Default: left.'],
278
+ ],
279
+ options: [
280
+ ['--button=<button>', 'the button, as click\'s.'],
281
+ ],
282
+ detail: `mouseclick --click-count=2, as Playwright's mouse.dblclick is: the page gets two clicks and a
283
+ dblclick. Two mouseclicks are two single clicks, which fire no dblclick. For a triple-click: mouseclick
284
+ <x> <y> --click-count=3.`,
285
+ },
286
+ mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button at the mouse (left, right, middle)', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
287
+ mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is', detail: 'The button is a word or --button=<button>, as for mouseclick.' },
241
288
  mousewheel: {
242
289
  usage: 'mousewheel <dx> <dy>',
243
290
  summary: 'turn the wheel where the mouse is',
@@ -259,14 +306,19 @@ scrolled or redrawn. While recording, it scrolls in eased steps over up to 1s (h
259
306
  ['--submit', 'at the end: presses Enter in it afterwards.'],
260
307
  ],
261
308
  detail: `Examples: fill #name Ada Lovelace, fill "text=Your name" Ada, fill e7 Ada. It sets the whole value at
262
- once, even while recording: type shows it being typed.`,
309
+ once, even while recording: type shows it being typed.
310
+
311
+ At the prompt and in a send --file line, \\n, \\t and \\r in double quotes are a newline, a tab and a
312
+ carriage return (fill #notes "line 1\\nline 2"), and \\\\ is a backslash. Over send, the shell's quoting comes
313
+ first, and its "a\\nb" is a literal \\n, typed as a backslash and an n: give it a real newline, as
314
+ $'line 1\\nline 2'.`,
263
315
  },
264
316
  type: {
265
317
  usage: 'type [--delay=<ms>] [<selector>] <text> [--submit]',
266
318
  summary: 'type key by key after what the field holds',
267
319
  args: [
268
320
  ['[<selector>]', 'as for fill. Without it (one word, or one quoted), into what has focus: type "garden hose".'],
269
- ['<text>', 'the rest of the line, as fill\'s value.'],
321
+ ['<text>', 'the rest of the line, as fill\'s value: a newline in it presses Enter, which in a textarea starts a new line (help fill says how to give one).'],
270
322
  ],
271
323
  options: [
272
324
  ['--delay=<ms>', 'right after type: the gap between keys. Default: none, or 60 while the tab records.'],
@@ -324,6 +376,9 @@ all.`,
324
376
  to the tab whose snapshot gave it, and can change when the page changes (e102 may become f4e98): take a
325
377
  new snapshot after it does.
326
378
 
379
+ Quotes: some names and text are quoted, YAML-style, as one with a quote mark in it is, and their quotes
380
+ are escaped: paragraph: "Say \\"hi\\"" is the text Say "hi" on the page, with no backslashes.
381
+
327
382
  --grep: a hit with no named element around it prints without a path. A hit on text (a label) keeps the
328
383
  ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
329
384
  to the label.
@@ -381,7 +436,14 @@ one no selector finds alone) stays a comment saying why, and the save says how m
381
436
  ends with the last step, not with what it brought: to end on a result that comes later (search results
382
437
  loading), add a wait for it at the end (wait text ...).
383
438
 
384
- Shared: every client sees the same steps, and watch new's place is shared.`,
439
+ Watching someone: read a response body over 100 KB that matters as soon as it shows in requests, since
440
+ the browser drops it once the tab navigates. watch leaves out images and other static files, failed ones
441
+ too; console error and requests --all show what failed. Without a url-part, --next-tab takes the first
442
+ tab anyone opens: check tab and info once it fires, since another tab opening later does not show in
443
+ watch.
444
+
445
+ Shared: every client sees the same steps, and watch new's place is shared: when another client watches
446
+ the tab too, read it with watch <n>, since each one's watch new would hide steps from the other.`,
385
447
  moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
386
448
  more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
387
449
  after the tab navigates, even when the page navigates as soon as the request answers.
@@ -526,9 +588,9 @@ shows in screenshots and recordings. Selectors, snapshot and text don't see it,
526
588
  ],
527
589
  detail: `The REPL's clicks and typing don't move a pointer on screen, so a recording shows things happening
528
590
  with nothing pointing at them. With the cursor on, these commands move it smoothly to their element or
529
- point before they act: click, dblclick, hover, fill, type <selector>, press <selector>, select, check,
530
- uncheck, upload, mousemove and mouseclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
531
- records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedown, and upload
591
+ point before they act: click, dblclick, hover, drag (to each end), fill, type <selector>, press <selector>, select, check,
592
+ uncheck, upload, mousemove, mouseclick and mousedblclick. Each move takes 150 to 600ms by distance (300 to 1000ms while the tab
593
+ records), and the command waits for it. Clicks (click, dblclick, check, uncheck, mouseclick, mousedblclick, mousedown, and upload
532
594
  when it clicks a button) show a ring where they land.
533
595
 
534
596
  Only the drawing moves: no mouse events are sent along the way, so the page gets the same input as with
@@ -607,13 +669,15 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
607
669
  summary: 'show, set or unset the viewport size',
608
670
  args: [
609
671
  ['<W>x<H>', 'sets the selected tab\'s page to that size, until viewport off, modes off or the REPL exits. The window is not resized.'],
610
- ['off', 'back to the window\'s size.'],
611
- ['(none)', 'shows the size, and whether it was set or is the window\'s.'],
672
+ ['off', 'back to the window\'s page area.'],
673
+ ['(none)', 'shows the size, and whether it was set or is the window\'s page area.'],
612
674
  ],
613
675
  detail: `emulate mobile sets a phone's size instead, and each refuses while the other is on.
614
676
 
615
- The window's size here is the size of the page in it. Chromium's --window-size flag sizes the whole
677
+ The window's page area is the page's size without a viewport set. Chromium's --window-size flag sizes the whole
616
678
  window, frame included, so the page is smaller: --window-size=1280,900 gives 1280x757 in a headless one.
679
+ --launch starts its Chromium at --window-size=1280,800 (a page of 1280x657 headless), unless the flags
680
+ after -- give one; a profile it was given and has used before may keep its own last size.
617
681
  For a page of an exact size, use viewport <W>x<H>.`,
618
682
  },
619
683
  emulate: {
@@ -832,9 +896,10 @@ in front of it usually holds the request open, so the page spins instead of fail
832
896
  summary: 'evaluate JavaScript in the selected tab and print the result',
833
897
  args: [
834
898
  ['<JavaScript>', 'the rest of the line. Promises are awaited, await works at the top level, and a function (() => document.title) is called.'],
835
- ['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5.'],
899
+ ['<function> <ref>', 'calls it with that element: eval "el => el.textContent" e5. Quoted, its \\n is a newline and its \\" a quote: write \\\\n for a JS string\'s \\n.'],
836
900
  ],
837
901
  options: [
902
+ ['--file=<path>', 'the JavaScript is the file\'s text: anything the line takes, over many lines and with // comments. With a ref or a selector after the path, it is a function called with that element (the first match). Over send, a relative path is from your shell\'s current folder.'],
838
903
  ['--all', 'the whole result, not capped.'],
839
904
  ],
840
905
  detail: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
@@ -990,9 +1055,11 @@ Different on purpose, since the browser may be someone else's too:
990
1055
  if (Object.hasOwn(TOPICS, topicOrCommand)) {
991
1056
  const topic = TOPICS[topicOrCommand];
992
1057
  const text = [topic.intro, '', ...commandLines(topic.commands)].join('\n');
993
- // A topic can share its name with a command (network); both are shown.
994
- return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${renderCommand(topicOrCommand)}` : text;
1058
+ // A topic can share its name with a command (network): the topic, and where the command's help is.
1059
+ return Object.hasOwn(COMMANDS, topicOrCommand) ? `${text}\n\n${topicOrCommand} is also a command: help ${topicOrCommand} --command` : text;
995
1060
  }
1061
+ const asCommand = /^(\S+)\s+--command$/.exec(topicOrCommand);
1062
+ if (asCommand && Object.hasOwn(COMMANDS, asCommand[1])) return renderCommand(asCommand[1]);
996
1063
  if (Object.hasOwn(COMMANDS, topicOrCommand)) return renderCommand(topicOrCommand);
997
1064
  // Not a topic: the mouse commands are in interact, with the others that act on the page.
998
1065
  if (topicOrCommand === 'mouse') {
@@ -1020,4 +1087,4 @@ function notFound(topic) {
1020
1087
  return `No help for ${topic}. Topics: ${Object.keys(TOPICS).join(', ')}`;
1021
1088
  }
1022
1089
 
1023
- module.exports = { render, notFound, TOPICS, COMMANDS };
1090
+ module.exports = { render, notFound, TOPICS, COMMANDS, TASKS, MODES };
package/lib/inspect.js CHANGED
@@ -484,15 +484,32 @@ const commands = {
484
484
 
485
485
  async eval(args, all) {
486
486
  if (!args) throw new Error('Usage: eval <js-expression>');
487
- // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
488
- const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
489
- if (onRef && isFunctionText(unquote(onRef[1]))) {
490
- const selector = toSelector(onRef[2]);
487
+ let ref = null;
488
+ // eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
489
+ // it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
490
+ const fromFile = /^--file(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
491
+ if (/^--file(?:[=\s]|$)/.test(args.trim())) {
492
+ if (!fromFile) throw new Error('Usage: eval --file=<path> [<ref or selector>]: the JavaScript is in the file');
493
+ const file = path.resolve(unquote(fromFile[1]));
494
+ const target = fromFile[2]?.trim() || null;
495
+ try { args = fs.readFileSync(file, 'utf8').trim(); } catch (error) {
496
+ // eval --file=my dir/x.js reads "my" as the file and "dir/x.js" as the element.
497
+ throw new Error(`eval --file: cannot read ${file} (${error.code || error.message})${target ? '; quote a path with spaces' : ''}`);
498
+ }
499
+ if (!args) throw new Error(`eval --file: ${file} is empty`);
500
+ ref = target && unquote(target);
501
+ } else {
502
+ // eval <function> <ref> calls the function with that element, as playwright-cli's eval does.
503
+ const onRef = /^([\s\S]+?)\s+((?:f\d+)?e\d+)$/.exec(args.trim());
504
+ if (onRef && isFunctionText(unquote(onRef[1]))) [args, ref] = [unquote(onRef[1]), onRef[2]];
505
+ }
506
+ if (ref) {
507
+ const selector = toSelector(ref);
491
508
  const el = await onElement(selector, () => state.page.locator(selector).first().elementHandle({ timeout: 5000 }));
492
509
  try {
493
510
  // Made from its text by the debugger, as eval is, so a page's CSP cannot
494
511
  // refuse it, and in the element's own frame, which may be an iframe.
495
- const fn = await evaluated((await el.ownerFrame()).evaluateHandle(unquote(onRef[1])));
512
+ const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
496
513
  try {
497
514
  printOutput(await el.evaluate((e, f) => f(e), fn), all);
498
515
  } finally {
package/lib/launch.js CHANGED
@@ -139,7 +139,9 @@ async function launch({ headed = false, extraArgs = [] }) {
139
139
  const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
140
140
  process.on('exit', onExit);
141
141
  const onSpawn = proc => { group = proc.pid; };
142
- const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
142
+ // A laptop's window, rather than Chromium's headless 800x600 or so, unless the flags after -- size it.
143
+ const sized = extraArgs.some(a => a.startsWith('--window-size=')) ? [] : ['--window-size=1280,800'];
144
+ const ours = [...(headed ? [] : ['--headless=new']), ...sized, '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
143
145
  '--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
144
146
  // A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
145
147
  // Local State is written at its root on the first run, whichever --profile-directory is used.
package/lib/record.js CHANGED
@@ -391,7 +391,7 @@ function videoTime(rec) {
391
391
  // What a step is, for its line: the command and what it acts on, never what it types or chooses, the
392
392
  // code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
393
393
  // video goes, and the video shows a password as dots.
394
- const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedown', 'mouseup', 'mousewheel']);
394
+ const WHOLE = new Set(['click', 'dblclick', 'hover', 'drag', 'check', 'uncheck', 'mousemove', 'mouseclick', 'mousedblclick', 'mousedown', 'mouseup', 'mousewheel']);
395
395
  const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
396
396
  function stepLabel(cmd, args) {
397
397
  if (WHOLE.has(cmd)) {
package/lib/runner.js CHANGED
@@ -38,6 +38,8 @@ const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'profile', 'record', '
38
38
  // Commands that accept --all, right after the command or at the end, to lift the output limit.
39
39
  const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'profile', 'body', 'console', 'snapshot', 'watch'];
40
40
 
41
+ const SUBCOMMANDS = ['run', 'serve', 'attach', 'stop', 'send', 'where', 'skill'];
42
+
41
43
  let queue = Promise.resolve();
42
44
  // The server command running now, so a quit at the prompt can answer it.
43
45
  let inFlight = null;
@@ -100,6 +102,11 @@ async function executeCommand(text) {
100
102
  state.currentCommandAt = Date.now();
101
103
  const run = Object.hasOwn(commands, cmd) ? commands[cmd] : translated && Object.hasOwn(cliCommands, cmd) ? cliCommands[cmd] : null;
102
104
  if (!run) {
105
+ // An agent fresh from the skill tries send where; those are the command line's, not the REPL's.
106
+ if (SUBCOMMANDS.includes(cmd)) {
107
+ out.error(`Error: ${cmd} is a pw-repl subcommand, run at the shell: pw-repl ${cmd}${args ? ' ...' : ''}`);
108
+ return { status: 'error', cmd };
109
+ }
103
110
  out.log(`Unknown command: ${cmd}. Type 'help' for commands.`);
104
111
  return { status: 'error', cmd };
105
112
  }
@@ -117,6 +124,10 @@ async function executeCommand(text) {
117
124
  stepEnded(step, true);
118
125
  // Playwright colours its call log; the codes are noise anywhere but a terminal.
119
126
  e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
127
+ // A page's own error (eval's SyntaxError, a cdp method not found) without Playwright's method name
128
+ // or the stack of its injected script, which point at Playwright, not at the line that was sent.
129
+ e.message = e.message.split('\n').filter(line => !/^\s+at /.test(line)).join('\n')
130
+ .replace(/^(?:page|frame|elementHandle|jsHandle|cdpSession)\.(?:evaluate|evaluateHandle|send): /, '');
120
131
  out.error(`Error: ${e.message}`);
121
132
  const uncertain = !READ_ONLY.has(cmd) && isUncertain(e);
122
133
  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
@@ -88,6 +88,11 @@ async function viaTmux(session, command, timeoutMs) {
88
88
  return 2;
89
89
  }
90
90
 
91
+ // A word in double quotes, as the REPL reads them (unescapeDouble): every other character goes as it is.
92
+ function doubleQuote(word) {
93
+ return `"${word.replace(/[\\"]/g, '\\$&').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t')}"`;
94
+ }
95
+
91
96
  // A command's words as send was given them, made into the line the REPL reads: quoted again where the
92
97
  // shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
93
98
  // folder, since the REPL's may be another.
@@ -100,7 +105,7 @@ function commandLine(words) {
100
105
  // So does playwright-cli's storage key (localstorage-set "my key" v).
101
106
  // And record's file, which may have spaces in it, and wait's text, whose last word would otherwise be
102
107
  // taken as the seconds when it is a number (wait text 'Showing 1').
103
- const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
108
+ const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || words[0] === 'drag' || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || words[0] === 'wait' || /^video-(?:start|stop)$/.test(words[0]));
104
109
  // An empty word (fill #name "") is the empty value.
105
110
  // upload's files are read by the REPL, whose folder may not be this one.
106
111
  // So are the files upload reads and screenshot --filename writes.
@@ -117,15 +122,21 @@ function commandLine(words) {
117
122
  // watch save and profile save write their files.
118
123
  if (words[0] === 'profile' && words[1] === 'save' && words.length > 2) resolved = ['profile', 'save', resolve(words.slice(2).join(' '))];
119
124
  if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
125
+ // So does the file eval --file reads.
126
+ if (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) {
127
+ // As one word, --file=<path>, which is quoted below when the path has spaces.
128
+ const [file, rest] = words[1] === '--file' ? [words[2], words.slice(3)] : [words[1].slice(7), words.slice(2)];
129
+ if (file) resolved = ['eval', `--file=${resolve(file)}`, ...rest];
130
+ }
120
131
  if (words[0] === 'screenshot') {
121
132
  resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
122
133
  }
123
- const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
134
+ const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? doubleQuote(w) : w)) : resolved;
124
135
  // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
125
136
  // Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
126
137
  for (const [n, w] of quoted.entries()) {
127
138
  const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
128
- if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
139
+ if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? doubleQuote(option[2]) : `'${option[2]}'`}`;
129
140
  }
130
141
  return quoted.join(' ').trim();
131
142
  }
@@ -155,7 +166,8 @@ async function sendFile(options) {
155
166
  console.log(`> ${line}`);
156
167
  // Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
157
168
  const words = shellWords(line);
158
- const command = PATHS.test(words[0]) ? commandLine(words) : line;
169
+ // eval's own line keeps its quotes; only eval --file names a file.
170
+ const command = PATHS.test(words[0]) || (words[0] === 'eval' && /^--file(?:=|$)/.test(words[1] || '')) ? commandLine(words) : line;
159
171
  const found = options.noVars ? null : variablesFor(command);
160
172
  if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
161
173
  const status = await send({ ...options, file: null, command, vars: found?.vars || null });
package/lib/start.js CHANGED
@@ -5,6 +5,7 @@ const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./st
5
5
  const out = require('./output');
6
6
  const { watchPage, complete } = require('./commands');
7
7
  const { listTabs, openTab, markStartTab } = require('./tabs');
8
+ const { tabState } = require('./tabstate');
8
9
  const runner = require('./runner');
9
10
 
10
11
  // A browser --launch starts has its own address instead.
@@ -88,10 +89,14 @@ async function start(options) {
88
89
 
89
90
  if (START_URL) {
90
91
  state.startingStep = 'opening the start URL';
91
- state.page = await openTab();
92
+ // A Chromium the REPL launched has only its first blank tab, which nobody else is using: the start
93
+ // URL goes there, rather than into a second tab behind a blank one.
94
+ const first = state.launched && pages.length === 1 && pages[0].url() === 'about:blank' ? pages[0] : null;
95
+ if (first) Object.assign(tabState(first), { opened: true, owner: state.client });
96
+ state.page = first || await openTab();
92
97
  markStartTab(state.page);
93
98
  await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
94
- out.log(`Opened ${state.page.url()} in a new tab`);
99
+ out.log(`Opened ${state.page.url()}${first ? '' : ' in a new tab'}`);
95
100
  }
96
101
 
97
102
  out.log('');
package/lib/syntax.js CHANGED
@@ -11,25 +11,31 @@ function toSelector(word) {
11
11
  return REF.test(word) ? `aria-ref=${word}` : word;
12
12
  }
13
13
 
14
- // Quotes around the whole of a value are removed; \" inside double quotes is a quote.
14
+ // Inside double quotes, \n, \t and \r are a newline, tab and carriage return, so a value send quotes
15
+ // keeps them on one line; any other \X is X (\" a quote, \\ a backslash).
16
+ function unescapeDouble(text) {
17
+ return text.replace(/\\(.)/g, (_, c) => (c === 'n' ? '\n' : c === 't' ? '\t' : c === 'r' ? '\r' : c));
18
+ }
19
+
20
+ // Quotes around the whole of a value are removed.
15
21
  function unquote(text) {
16
22
  const match = /^"((?:[^"\\]|\\.)*)"$|^'([^']*)'$/.exec(text);
17
23
  if (!match) return text;
18
- return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
24
+ return match[1] !== undefined ? unescapeDouble(match[1]) : match[2];
19
25
  }
20
26
 
21
27
  // The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
22
28
  // (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
23
29
  function shellWords(text) {
24
30
  return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
25
- .map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double.replace(/\\(.)/g, '$1') : single)));
31
+ .map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? unescapeDouble(double) : single)));
26
32
  }
27
33
 
28
34
  // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
29
35
  function splitSelector(args) {
30
36
  const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
31
37
  if (!match) return null;
32
- const word = match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2] !== undefined ? match[2] : match[3];
38
+ const word = match[1] !== undefined ? unescapeDouble(match[1]) : match[2] !== undefined ? match[2] : match[3];
33
39
  return { word, selector: toSelector(word), rest: match[4] };
34
40
  }
35
41
 
@@ -83,4 +89,4 @@ function expandVariables(line, values) {
83
89
  });
84
90
  }
85
91
 
86
- module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
92
+ module.exports = { SELECTOR_FIRST, REF, toSelector, unescapeDouble, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",