pw-repl 0.12.8 → 0.14.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/lib/help.js CHANGED
@@ -41,7 +41,7 @@ const TASKS = [
41
41
  ['record requests and console together', 'capture on, then capture off'],
42
42
  ['find what makes the page slow', 'profile on, then profile off; profile reload for a load'],
43
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>'],
44
+ ['change or slow an API response', 'route <glob> patch <json>, route <glob> delay <time>'],
45
45
  ['cut or slow the whole network', 'network off, network slow, then network on'],
46
46
  ['record a video of the page', 'record on, then record off (help video for a take)'],
47
47
  ['show a pointer where the agent acts', 'cursor on, then cursor off'],
@@ -62,6 +62,8 @@ ${TASKS.map(([task, commands]) => ` ${task.padEnd(38)} ${commands}`).join('\n'
62
62
  Modes (${MODES}) stay on until turned off; the prompt shows the selected tab's: (watch routes:1) pw>.
63
63
  Each of them on its own, and modes, shows its state and what you can run next.
64
64
 
65
+ Times are in milliseconds, as in Playwright, or in seconds with an s: wait .done 30000, or wait .done 30s.
66
+
65
67
  Topics (help <topic>):
66
68
  tabs open, select, close, navigate network requests, bodies, console, fakes, network off
67
69
  interact click, fill, type, press, select devtools eval, CDP commands, cookie and storage names
@@ -120,7 +122,7 @@ box to point at something, and toast <text> a line of text saying what is happen
120
122
  the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
121
123
  text ...), not a heading the page always has, and not wait load: a single-page app loads nothing.
122
124
 
123
- record on --steps also saves when and where each step ran, in the video's seconds and pixels, for
125
+ record on --steps also saves when and where each step ran, in seconds into the video and its pixels, for
124
126
  editing it afterwards.
125
127
 
126
128
  The user's own screen, with their real pointer, takes a screen recorder they run (OBS, ffmpeg -f
@@ -135,7 +137,7 @@ clean browser of its own (here, its video-start is record on).`,
135
137
  devtools: {
136
138
  intro: `Cookie and storage listings omit values. eval, cdp, html, screenshots, recordings and URLs can
137
139
  still show sensitive data (cdp Network.getCookies shows cookie values).`,
138
- commands: ['eval', 'cdp', 'cookies', 'storage'],
140
+ commands: ['eval', 'cdp', 'clipboard', 'cookies', 'storage'],
139
141
  },
140
142
  session: {
141
143
  intro: `Server: pw-repl serve also takes commands on /tmp/pw-repl.sock; they show here as [server] lines.
@@ -214,6 +216,7 @@ afresh.`,
214
216
  ['--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
217
  ['--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.'],
216
218
  ['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
219
+ ['--force', 'skips Playwright\'s actionability checks (enabled, stable, not covered), as Playwright\'s force. For an element marked aria-disabled, which Playwright counts as disabled, that a page keeps clickable (a greyed menu item that says why). Several matches are still chosen from as below; a covered ref or single match is clicked where it is, so what covers it gets the click.'],
217
220
  ],
218
221
  detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
219
222
  and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
@@ -221,8 +224,10 @@ one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none
221
224
  says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
222
225
  it to be ready.
223
226
 
224
- Timed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts pointer
225
- events) and that the outcome is unknown; Playwright's full call log goes to the REPL's pane or log.`,
227
+ Timed out, it says what stopped it, and Playwright's full call log goes to the REPL's pane or log. Stopped by
228
+ one of Playwright's checks before the click is sent, it did nothing: the element is not visible, enabled
229
+ or stable, or <div class="overlay"> intercepts pointer events (something covers it). Stopped after it was
230
+ sent, as when the page stops answering, the outcome is unknown.`,
226
231
  },
227
232
  dblclick: {
228
233
  usage: 'dblclick <selector> [button]',
@@ -372,7 +377,7 @@ all.`,
372
377
  ['--grep <text>', 'only the lines containing text (role, name or a flag such as [disabled], any case), each with the named elements around it.'],
373
378
  ['--regex <pattern>', '--grep by a regular expression: snapshot --regex "Total: \\d+".'],
374
379
  ],
375
- detail: `Refs: a ref (e3, or f1e3 in newer Playwright) works as a selector in any command: click e3. It belongs
380
+ detail: `Refs: a ref (e3; f1e3, f2e3 once the page has loaded again, when the old refs stop working) works as a selector in any command: click e3. It belongs
376
381
  to the tab whose snapshot gave it, and can change when the page changes (e102 may become f4e98): take a
377
382
  new snapshot after it does.
378
383
 
@@ -527,15 +532,15 @@ circle). That changes the page, so put it back afterwards, as it was if it had a
527
532
  eval document.querySelector("#rate-3").style.outline = ""`,
528
533
  },
529
534
  screenshot: {
530
- usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
535
+ usage: 'screenshot [<ref>] [--full] [-d <time>] [name]',
531
536
  summary: 'save a PNG of the viewport, --full page or an element',
532
537
  args: [
533
538
  ['[<ref>]', 'only that element, from a snapshot ref: screenshot e5.'],
534
- ['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp.'],
539
+ ['[name]', 'saved as screenshot-<name>.png; letters, digits, underscores and hyphens. Default: a timestamp. A word with a / or ending in .png, .jpg or .jpeg is a file instead, as --filename: x.png is the file x.png, not screenshot-x.png. At the prompt, a path with a space goes in --filename="<file>"; through pw-repl send, either works.'],
535
540
  ],
536
541
  options: [
537
542
  ['--full', 'the whole page, not only the viewport; --full-page too.'],
538
- ['-d, --delay <secs>', 'counts down out loud first, up to 60, so someone can hold a hover or open a menu.'],
543
+ ['-d, --delay <time>', 'counts down out loud first, in seconds, up to 60s, so someone can hold a hover or open a menu.'],
539
544
  ['--filename=<file>', 'saves it there instead (a JPEG if it ends in .jpg). Relative to the REPL\'s folder; through pw-repl send, the sender\'s. It never replaces a file.'],
540
545
  ],
541
546
  detail: `Where: $PW_SCREENSHOT_DIR (a folder that exists); without it, next to the REPL's socket when it serves on one of its own, or
@@ -603,19 +608,19 @@ the same spot, and shows in screenshots and recordings. Selectors, snapshot and
603
608
  takes no clicks, and it points at elements inside frames too.`,
604
609
  },
605
610
  record: {
606
- usage: 'record [on [file] [seconds] [options] | off [file]]',
611
+ usage: 'record [on [file] [time] [options] | off [file]]',
607
612
  summary: 'record the selected tab\'s page to a video',
608
613
  args: [
609
614
  ['on | off', 'on starts recording the selected tab; off stops it, saves the file and says its path, length, size and bytes. Neither: says whether it is recording, and how your last recording ended.'],
610
615
  ['[file]', 'clip.webm or clip.mp4; its ending picks the format. Relative to the REPL\'s folder; through pw-repl send, the sender\'s. Default: recording-<timestamp>.webm where screenshot saves. On record off: saves it under that name instead.'],
611
- ['[seconds]', 'stops by itself after them, 1 to 3600. Default: 3600.'],
616
+ ['[time]', 'stops by itself after it, 1s or more. Default: 1 hour (3600s).'],
612
617
  ],
613
618
  optionsFor: 'record on',
614
619
  options: [
615
620
  ['--steps', 'also save clip.steps.txt next to clip.webm: when and where each command ran (Steps, below). Default: off.'],
616
621
  ['--pause=<ms>', 'a pause after each action, and each highlight drawn, so the viewer sees it. Default: 750; 0 none.'],
617
- ['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward the seconds.'],
618
- ['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its seconds.'],
622
+ ['--lead=<ms>', 'still page recorded before record on returns. Default: 1000; counts toward its time.'],
623
+ ['--tail=<ms>', 'still page recorded after record off, before it stops. Default: 1000; none when it stops at its time.'],
619
624
  ['--filename=<file>', 'the file, as playwright-cli writes it.'],
620
625
  ],
621
626
  detail: `What it records: the page only, from inside the browser, at the viewport's size in CSS pixels (set
@@ -656,7 +661,7 @@ height loses a pixel), scrollbars included; a phone's under emulate mobile is it
656
661
  is 412 wide, less sharp than the phone draws it.
657
662
 
658
663
  How it ended: record on its own, or record off when nothing is recording, says how your last recording
659
- ended (record off, its seconds, its tab closing or the REPL stopping) with what record off would have
664
+ ended (record off, its time, its tab closing or the REPL stopping) with what record off would have
660
665
  said. tab close says what became of a recording it ended. A REPL stopped with pw-repl stop cannot be
661
666
  asked: stop prints what became of each recording it ended, and of one that ended by itself that record
662
667
  has not shown since.
@@ -702,14 +707,14 @@ mouse, and sends no touch events. Dark, light and timezone apply at once, but wh
702
707
  drew with them (times, colors set by its script) changes only when it reloads.`,
703
708
  },
704
709
  wait: {
705
- usage: 'wait [text|request] <what> [--gone] [secs]',
710
+ usage: 'wait [text|request] <what> [--gone] [time]',
706
711
  summary: 'wait for an element, text, a response or a load (10s)',
707
712
  args: [
708
713
  ['<selector>', 'for a matching element to be visible.'],
709
- ['text <text>', 'for text to be visible: any part of an element\'s text, any case. Quote text that ends in a number, or the number is taken as the seconds: wait text "Showing 1".'],
714
+ ['text <text>', 'for text to be visible: any part of an element\'s text, any case. Quote text that ends in a number, or the number is taken as the time: wait text "Showing 1".'],
710
715
  ['request <part>', 'for a matching response (a URL part or a glob), counting one that finished since the previous command began, or is still under way: click, then wait request, does not miss it.'],
711
716
  ['load', 'for the page to finish loading (its load event), counting a navigation that began since the previous command began: click a link, then wait load.'],
712
- ['[secs]', 'how long, up to 120. Default: 10.'],
717
+ ['[time]', 'how long, with no limit (give send time too: send -t). Default: 10s.'],
713
718
  ],
714
719
  options: [
715
720
  ['--gone', 'until nothing matching is visible (removed or hidden): wait .spinner --gone. Done at once if nothing matches yet, so wait for it to appear first if it may not have.'],
@@ -718,8 +723,8 @@ drew with them (times, colors set by its script) changes only when it reloads.`,
718
723
  something on the new view instead. A wait that times out is an error; the REPL carries on.`,
719
724
  },
720
725
  sleep: {
721
- usage: 'sleep <ms>',
722
- summary: 'wait a fixed time, in milliseconds (maximum 3600000)',
726
+ usage: 'sleep <time>',
727
+ summary: 'wait a fixed time: sleep 1500, or sleep 1.5s',
723
728
  detail: `While recording, each action is already followed by a pause (help record): a sleep adds to it.`,
724
729
  },
725
730
  requests: {
@@ -776,11 +781,11 @@ A page brought back from the back/forward cache (back, forward) reports its earl
776
781
  the time it comes back, though nothing loaded again.`,
777
782
  },
778
783
  profile: {
779
- usage: 'profile on [secs] | off | reload | save <file>',
784
+ usage: 'profile on [time] | off | reload | save <file>',
780
785
  summary: 'what slows the tab: long tasks, layouts, input, frames',
781
786
  args: [
782
- ['on', 'starts a performance trace of the selected tab, until profile off (at most 120 seconds).'],
783
- ['[secs]', 'records that long (1 to 120), then prints. Other commands wait until it ends. Over 20, give send time too: pw-repl send -t 70 profile on 60.'],
787
+ ['on', 'starts a performance trace of the selected tab, until profile off (at most 120s; give it a time for longer).'],
788
+ ['[time]', 'records that long (1s or more), then prints. Other commands wait until it ends. Over 20s, give send time too: pw-repl send -t 70s profile on 60s.'],
784
789
  ['off', 'stops it and prints the summary.'],
785
790
  ['reload', 'reloads the tab and profiles its load, until its network is idle (at most 10s after load) and half a second more. For work that starts later (a timer): profile on, reload, sleep, profile off.'],
786
791
  ['save <file>', 'writes the last profile\'s trace, the tab\'s events only, as Chrome trace JSON for DevTools\' Performance panel (Load profile) or ui.perfetto.dev. It refuses a file that exists.'],
@@ -813,7 +818,7 @@ may time out, but the profile records it all; profile off once the page answers
813
818
 
814
819
  Size: a busy page fills Chrome's trace buffer in under a minute (one that restyles many elements as it
815
820
  draws, sooner), which stops the profile there, and the summary says so. Stopping a long one takes a
816
- while, as Chrome writes out its trace: give send time, pw-repl send -t 60 profile off. If send gives up
821
+ while, as Chrome writes out its trace: give send time, pw-repl send -t 60s profile off. If send gives up
817
822
  first, the profile still ends, and profile shows it.
818
823
 
819
824
  One profile runs at a time, in a browser: Chrome traces one at a time, the whole browser's, so another
@@ -844,12 +849,12 @@ the trace even when its page did nothing. The trace is kept in a temporary folde
844
849
  profile, or until the REPL stops.`,
845
850
  },
846
851
  capture: {
847
- usage: 'capture on [requests|console] [secs] | off',
852
+ usage: 'capture on [requests|console] [time] | off',
848
853
  summary: 'record requests and console together, in time order',
849
854
  args: [
850
855
  ['on', 'records both until capture off, which prints them.'],
851
856
  ['requests | console', 'records only one.'],
852
- ['[secs]', 'records that long (1 to 3600), then prints. Other commands wait until it ends.'],
857
+ ['[time]', 'records that long (1s or more), then prints. Other commands wait until it ends.'],
853
858
  ['(none)', 'says whether one is running, or shows the last one.'],
854
859
  ],
855
860
  detail: `A timed capture suits recording what someone does in the browser; around your own commands, use
@@ -862,7 +867,7 @@ and console it keeps more than the last 200, and lists both together.`,
862
867
  args: [
863
868
  ['<glob> <status> <json>', 'answers with this status (200-599) and JSON (the rest of the line), or no body. It never reaches the network, so it still answers while the network is off.'],
864
869
  ['<glob> patch <json>', 'lets it through, then changes its JSON response: a JSON Merge Patch, where objects merge, null removes a key and anything else replaces; one that is not an object (an array) replaces the whole body.'],
865
- ['<glob> delay <secs>', 'holds it for up to 120 seconds, then lets it through.'],
870
+ ['<glob> delay <time>', 'holds it that long, then lets it through.'],
866
871
  ['<glob> abort', 'fails it as if the connection broke.'],
867
872
  ['off <glob> | --all', 'removes one route, or all.'],
868
873
  ['(none)', 'lists the selected tab\'s routes.'],
@@ -888,11 +893,11 @@ replaces it. A glob may be quoted. Example: route **/api/cart patch {"total": 0}
888
893
  ['on', 'restores it.'],
889
894
  ],
890
895
  detail: `Per tab; lasts until network on or the REPL exits. Routes still answer while it is off or slow. To slow
891
- one API, use route <glob> delay <secs>. Stopping a service is not the same as network off: a dev proxy
896
+ one API, use route <glob> delay <time>. Stopping a service is not the same as network off: a dev proxy
892
897
  in front of it usually holds the request open, so the page spins instead of failing.`,
893
898
  },
894
899
  eval: {
895
- usage: 'eval [--all] <JavaScript>',
900
+ usage: 'eval [--all] [--timeout=<time>] <JavaScript>',
896
901
  summary: 'evaluate JavaScript in the selected tab and print the result',
897
902
  args: [
898
903
  ['<JavaScript>', 'the rest of the line. Promises are awaited, await works at the top level, and a function (() => document.title) is called.'],
@@ -900,14 +905,20 @@ in front of it usually holds the request open, so the page spins instead of fail
900
905
  ],
901
906
  options: [
902
907
  ['--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.'],
908
+ ['--timeout=<time>', 'how long the page has to answer, before --file. Default: 15s. Past 20s, give send time too, or it gives up first and cancels the eval: pw-repl send -t 70s eval --timeout=60s ...'],
903
909
  ['--all', 'the whole result, not capped.'],
904
910
  ],
905
911
  detail: `eval fetch(...) sends a new request, which can change state on the server; it cannot read one that
906
- already happened (requests lists those).`,
912
+ already happened (requests lists those).
913
+
914
+ Long work (--timeout): every other command waits while an eval runs, the other clients' too. For work of
915
+ minutes, or in a browser others are using, start it without awaiting it, keep its result on window, and
916
+ read it back: eval "window.__done = null; run().then(r => window.__done = r); 'started'", then eval
917
+ window.__done until it is set.`,
907
918
  },
908
919
  cdp: {
909
920
  usage: 'cdp [--all] <method> <JSON object>',
910
- summary: 'send one CDP command through a temporary session',
921
+ summary: 'send one CDP command to the selected tab',
911
922
  args: [
912
923
  ['<method>', 'a CDP method: Network.getCookies.'],
913
924
  ['<JSON object>', 'its parameters: {} for none.'],
@@ -915,8 +926,35 @@ already happened (requests lists those).`,
915
926
  options: [
916
927
  ['--all', 'the whole result, not capped.'],
917
928
  ],
918
- detail: `The session is detached afterwards, so subscriptions and settings do not persist. Browser.close and
919
- Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog answers one.`,
929
+ detail: `A command goes through a temporary session, detached afterwards, so subscriptions and settings do not
930
+ persist (input aside, below). Browser.close and Target.closeTarget are refused, and so is Page.handleJavaScriptDialog: dialog
931
+ answers one.
932
+
933
+ Input (Input.dispatchTouchEvent, dispatchMouseEvent, the synthesize gestures): through a session kept for
934
+ the tab, so a sequence sent one call at a time stays one sequence (Chrome drops it when the session that
935
+ began it detaches). End one before other commands (touchEnd, or touchCancel to drop it): left open, the
936
+ next click lands mid-touch. Input.setIgnoreInputEvents and setInterceptDrags go through a temporary session,
937
+ as they would otherwise stay on and break later clicks.
938
+
939
+ Touch (under emulate mobile, for pointerType touch): touchStart and touchEnd sent this way make a tap, with
940
+ click, and two in a row a dblclick; held, it is still a tap, with no contextmenu. Chrome takes a tap sent
941
+ soon after another gesture as part of it, with no click: up to a few seconds after a drag (sleep 3s). The synthesize gestures
942
+ (Input.synthesizeTapGesture, synthesizePinchGesture, synthesizeScrollGesture) send pointer and touch events
943
+ but no click, dblclick or contextmenu.`,
944
+ },
945
+ clipboard: {
946
+ usage: 'clipboard [--all] [--shared-clipboard]',
947
+ summary: 'print the text on the clipboard, as a Copy button left it',
948
+ options: [
949
+ ['--shared-clipboard', 'reads it in a browser with a window too, whose clipboard is the computer\'s (below).'],
950
+ ['--all', 'the whole text, not capped.'],
951
+ ],
952
+ detail: `It reads through the selected tab, which must be a secure page (https, or http on localhost), granting
953
+ it clipboard-read only while it reads. Check a Copy button: click it, then clipboard.
954
+
955
+ A headless browser, as --launch starts, has a clipboard of its own, holding only what its pages copied.
956
+ One with a window reads the computer's: whatever its user last copied, a password too. There it refuses
957
+ without --shared-clipboard; ask the user before giving it.`,
920
958
  },
921
959
  cookies: {
922
960
  usage: 'cookies [--all]',
@@ -973,7 +1011,11 @@ at the browser (e.g. headless), it is the only way to answer one.`,
973
1011
  quit: {
974
1012
  usage: 'quit',
975
1013
  summary: 'disconnect, leaving Chromium running (one --launch started stops too)',
976
- detail: `Runs immediately, even while another command is waiting. Only available at the prompt.`,
1014
+ detail: `Runs immediately, even while another command is waiting. Only available at the prompt.
1015
+
1016
+ As it stops (quit, or pw-repl stop), it says, for each client, how many of the files it wrote are still
1017
+ there, how large, and in which folders: Files left by cart-bug: 3 files, 48 MB (2 screenshots, 1 video)
1018
+ in /tmp/. pw-repl send -c <name> close lists a client's own in full.`,
977
1019
  },
978
1020
  };
979
1021
 
package/lib/inspect.js CHANGED
@@ -5,16 +5,17 @@ const { state, withTimeout } = require('./state');
5
5
  const out = require('./output');
6
6
  const { printOutput } = out;
7
7
  const { devices } = require('playwright-core');
8
- const { toSelector, REF, unquote } = require('./syntax');
9
- const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE } = require('./util');
8
+ const { toSelector, REF, SCREENSHOT_FILE, unquote } = require('./syntax');
9
+ const { regexOf, COMMAND_TIMEOUT, BUSY_PAGE, TIME, parseDuration, durationText } = require('./util');
10
10
  const { tabState } = require('./tabstate');
11
- const { keptSession } = require('./cdp');
11
+ const { noteWritten } = require('./written');
12
+ const { keptSession, keepDrawing } = require('./cdp');
12
13
  const { CDP_REFUSED } = require('./dialogs');
13
14
  const { onElement, soleSelector } = require('./elements');
14
15
  const { waitForLoad, waitForRequest } = require('./requestlog');
15
16
  const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
16
17
 
17
- const SCREENSHOT_DELAY_MAX = 60;
18
+ const SCREENSHOT_DELAY_MAX = 60000;
18
19
  // What inputs lists: native form controls, and elements that are one by their ARIA role.
19
20
  const INPUTS = ['input', 'select', 'textarea', ...['checkbox', 'radio', 'switch', 'combobox', 'listbox', 'textbox', 'searchbox', 'slider', 'spinbutton'].map(role => `[role=${role}]`)].join(', ');
20
21
 
@@ -111,15 +112,14 @@ function compactSnapshot(text) {
111
112
  return lines.join('\n');
112
113
  }
113
114
 
114
- const WAIT_DEFAULT = 10;
115
- const WAIT_MAX = 120;
115
+ const WAIT_DEFAULT = 10000;
116
116
 
117
117
  // Gone once no match is visible, whether it was removed or hidden.
118
118
  async function waitGone(locator, what, timeout) {
119
119
  try {
120
120
  await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
121
121
  } catch (error) {
122
- if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
122
+ if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${durationText(timeout)}: ${what}`);
123
123
  throw error;
124
124
  }
125
125
  out.log(`Gone: ${what}`);
@@ -127,9 +127,9 @@ async function waitGone(locator, what, timeout) {
127
127
 
128
128
  const SNAPSHOT_HINT_LINES = 60;
129
129
 
130
- function evaluated(promise) {
131
- return withTimeout(promise, 'Page evaluation', COMMAND_TIMEOUT).catch(error => {
132
- if (error.message === 'Page evaluation timed out') error.message += ` after ${COMMAND_TIMEOUT / 1000}s; ${BUSY_PAGE}`;
130
+ function evaluated(promise, timeout = COMMAND_TIMEOUT) {
131
+ return withTimeout(promise, 'Page evaluation', timeout).catch(error => {
132
+ if (error.message === 'Page evaluation timed out') error.message += ` after ${durationText(timeout)}: eval --timeout=<time> waits longer (and send -t with it), but ${BUSY_PAGE}`;
133
133
  throw error;
134
134
  });
135
135
  }
@@ -316,7 +316,7 @@ const commands = {
316
316
  },
317
317
 
318
318
  async screenshot(args) {
319
- const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <seconds>] [name | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens.';
319
+ const usage = 'Usage: screenshot [<ref>] [--full] [--delay|-d <time>] [name | <file> | --filename=<file>]\nNames may contain letters, digits, underscores, and hyphens; a word with a / or ending in .png, .jpg or .jpeg is a file.';
320
320
  // The file first: a path may be quoted, with spaces in it.
321
321
  let file = null;
322
322
  const rest = (args || '').replace(/(?:^|\s)--filename(?:=|\s+)("(?:[^"\\]|\\.)*"|'[^']*'|\S+)/, (_, value) => { file = unquote(value); return ' '; });
@@ -334,9 +334,14 @@ const commands = {
334
334
  if (token === '--delay' || token === '-d') {
335
335
  const value = tokens[++i];
336
336
  // Capped low on purpose: a timed shot is human-in-the-loop, and a typo
337
- // like `-d 300` would otherwise park the serialized command queue.
338
- if (!value || !/^\d+$/.test(value) || Number(value) > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <seconds> (maximum ${SCREENSHOT_DELAY_MAX})`);
339
- delay = Number(value);
337
+ // like `-d 300s` would otherwise park the serialized command queue.
338
+ delay = parseDuration(value);
339
+ if (delay === null || delay > SCREENSHOT_DELAY_MAX) throw new Error(`Usage: screenshot --delay <time> (ms, or 5s; at most ${durationText(SCREENSHOT_DELAY_MAX)})`);
340
+ continue;
341
+ }
342
+ if (SCREENSHOT_FILE.test(token)) {
343
+ if (file) throw new Error(usage);
344
+ file = token;
340
345
  continue;
341
346
  }
342
347
  names.push(token);
@@ -345,9 +350,11 @@ const commands = {
345
350
  const name = names[0] || null;
346
351
  // Counted down out loud so a watcher can time a hover or menu state, and so
347
352
  // the wait is distinguishable from a hung command in a tmux capture.
348
- for (let remaining = delay; remaining > 0; remaining--) {
349
- out.log(`${remaining}...`);
350
- await state.page.waitForTimeout(1000);
353
+ for (let left = delay; left > 0;) {
354
+ out.log(`${Math.ceil(left / 1000)}...`);
355
+ const step = left % 1000 || 1000;
356
+ await state.page.waitForTimeout(step);
357
+ left -= step;
351
358
  }
352
359
  // Named after the countdown so a default filename timestamps the capture.
353
360
  // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
@@ -388,6 +395,7 @@ const commands = {
388
395
  if (error.code === 'EEXIST') throw new Error(`Screenshot already exists: ${filepath}`);
389
396
  throw error;
390
397
  }
398
+ noteWritten(filepath, 'screenshot');
391
399
  out.log(`Saved: ${filepath}`);
392
400
  },
393
401
 
@@ -433,22 +441,22 @@ const commands = {
433
441
  },
434
442
 
435
443
  async wait(args) {
436
- const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
444
+ const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with an optional [time] (ms, or 30s); --gone for a selector or text';
437
445
  let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
438
446
  const gone = tokens.includes('--gone');
439
447
  tokens = tokens.filter(t => t !== '--gone');
440
- let seconds = WAIT_DEFAULT;
441
- // A number last is the seconds, unless it is all there is to wait for: wait text 990.
448
+ // A time last is how long, unless it is all there is to wait for: wait text 990.
442
449
  const onlyWhat = (tokens[0] === 'text' || tokens[0] === 'request') && tokens.length === 2;
443
- const tookSeconds = tokens.length > 1 && !onlyWhat && /^\d+$/.test(tokens[tokens.length - 1]);
444
- if (tookSeconds) seconds = Number(tokens.pop());
445
- if (!tokens.length) throw new Error(`${usage} (1-${WAIT_MAX})`);
446
- if (seconds < 1 || seconds > WAIT_MAX) {
447
- // Most likely text that ends in a number, not seconds at all.
448
- const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${seconds}"` : '';
449
- throw new Error(`wait takes 1-${WAIT_MAX} seconds (got ${seconds})${text}`);
450
+ const tookTime = tokens.length > 1 && !onlyWhat && TIME.test(tokens[tokens.length - 1]);
451
+ const given = tookTime ? tokens.pop() : null;
452
+ const timeout = given ? parseDuration(given) : WAIT_DEFAULT;
453
+ if (!tokens.length) throw new Error(usage);
454
+ if (!timeout) {
455
+ // Most likely text that ends in a number, not a time at all.
456
+ const text = tokens[0] === 'text' && !/^["']/.test(tokens[1]) ? `; for text that ends in a number, quote it: wait text "${tokens.slice(1).join(' ')} ${given}"` : '';
457
+ throw new Error(`wait takes a time over 0 (got ${given})${text}`);
450
458
  }
451
- const timeout = seconds * 1000;
459
+ const within = durationText(timeout);
452
460
  // Said plainly, not as Playwright's call log.
453
461
  const plainly = message => error => { throw /Timeout \d+ms exceeded/.test(error.message) ? new Error(message) : error; };
454
462
  const kind = tokens[0];
@@ -464,26 +472,36 @@ const commands = {
464
472
  }
465
473
  if (gone) return waitGone(state.page.getByText(what), what, timeout);
466
474
  // Text that ends in a number gives it up as the seconds, unless quoted: said when it times out.
467
- const quote = tookSeconds && !/^["']/.test(tokens[1]) ? ` (${seconds} was taken as the seconds: wait text "${what} ${seconds}" waits for that text)` : '';
468
- await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${seconds}s${quote}`));
475
+ const quote = tookTime && !/^["']/.test(tokens[1]) ? ` (${given} was taken as the time: wait text "${what} ${given}" waits for that text)` : '';
476
+ await state.page.getByText(what).first().waitFor({ state: 'visible', timeout }).catch(plainly(`No visible text matches ${what} within ${within}${quote}`));
469
477
  out.log(`Visible: ${what}`);
470
478
  return;
471
479
  }
472
480
  const selector = toSelector(unquote(tokens.join(' ')));
473
481
  if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
474
- await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${seconds}s`));
482
+ await state.page.waitForSelector(selector, { state: 'visible', timeout }).catch(plainly(`No visible element matches ${tokens.join(' ')} within ${within}`));
475
483
  out.log(`Visible: ${tokens.join(' ')}`);
476
484
  },
477
485
 
478
486
  async sleep(args) {
479
- if (!args || !/^\d+$/.test(args) || Number(args) > 3600000) throw new Error('Usage: sleep <ms> (maximum 3600000)');
480
- await state.page.waitForTimeout(Number(args));
487
+ const ms = parseDuration(args);
488
+ if (ms === null) throw new Error('Usage: sleep <time> (ms, or 3s)');
489
+ await state.page.waitForTimeout(ms);
481
490
  // sleep 3 is easily meant as seconds.
482
- out.log(Number(args) < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}000 is ${args}s)` : `Slept ${args}ms`);
491
+ out.log(/^\d+$/.test(args) && ms < 100 ? `Slept ${args}ms (sleep takes milliseconds: sleep ${args}s for ${args}s)` : `Slept ${durationText(ms)}`);
483
492
  },
484
493
 
485
494
  async eval(args, all) {
486
495
  if (!args) throw new Error('Usage: eval <js-expression>');
496
+ // eval --timeout=<time> [--file=...] <JavaScript>: how long the page has to answer.
497
+ let timeout = COMMAND_TIMEOUT;
498
+ const timed = /^--timeout(?:=|\s+)(\S+)(?:\s+([\s\S]*))?$/.exec(args.trim());
499
+ if (/^--timeout(?:[=\s]|$)/.test(args.trim())) {
500
+ timeout = parseDuration(timed?.[1]);
501
+ if (!timeout) throw new Error('Usage: eval --timeout=<time> <JavaScript>, a time in ms or with s: eval --timeout=60s ...');
502
+ args = timed[2] || '';
503
+ if (!args.trim()) throw new Error('Usage: eval --timeout=<time> <JavaScript>');
504
+ }
487
505
  let ref = null;
488
506
  // eval --file=<path> [<ref or selector>]: the file's text is what the line would be, over as many lines as
489
507
  // it likes. The rest of the line can only be the element, so any selector goes there, not only a ref.
@@ -509,9 +527,9 @@ const commands = {
509
527
  try {
510
528
  // Made from its text by the debugger, as eval is, so a page's CSP cannot
511
529
  // refuse it, and in the element's own frame, which may be an iframe.
512
- const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args));
530
+ const fn = await evaluated((await el.ownerFrame()).evaluateHandle(args), timeout);
513
531
  try {
514
- printOutput(await el.evaluate((e, f) => f(e), fn), all);
532
+ printOutput(await evaluated(el.evaluate((e, f) => f(e), fn), timeout), all);
515
533
  } finally {
516
534
  await fn.dispose().catch(() => {});
517
535
  }
@@ -531,13 +549,13 @@ const commands = {
531
549
  const call = `const value = (${args}\n); return typeof value === 'function' ? value() : value;`;
532
550
  if (awaits && !single && compiles(AsyncFunction, args)) {
533
551
  const session = await keptSession(state.page);
534
- const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }));
552
+ const { result, exceptionDetails } = await evaluated(session.send('Runtime.evaluate', { expression: args, replMode: true, awaitPromise: true, returnByValue: true }), timeout);
535
553
  if (exceptionDetails) throw new Error(exceptionDetails.exception?.description || exceptionDetails.text);
536
554
  printOutput(result.value !== undefined ? result.value : result.description ?? 'undefined', all);
537
555
  return;
538
556
  }
539
557
  const expression = single ? `(${awaits ? 'async ' : ''}() => { ${call} })()` : args;
540
- const value = await evaluated(state.page.evaluate(expression));
558
+ const value = await evaluated(state.page.evaluate(expression), timeout);
541
559
  // An empty string is shown as one, not as no output at all.
542
560
  printOutput(value === '' ? '""' : value, all);
543
561
  // Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
@@ -547,6 +565,38 @@ const commands = {
547
565
  }
548
566
  },
549
567
 
568
+ // Read through the selected tab, granted clipboard-read for that moment only. A browser with a
569
+ // window reads the system's clipboard, the user's, so only with --shared-clipboard.
570
+ async clipboard(args, all) {
571
+ const words = (args || '').trim().split(/\s+/).filter(Boolean);
572
+ if (words.some(w => w !== '--shared-clipboard')) throw new Error('Usage: clipboard [--shared-clipboard]');
573
+ const browser = await state.browser.newBrowserCDPSession();
574
+ try {
575
+ // Headless says so in its user agent (its product name says Chrome either way).
576
+ const { userAgent } = await browser.send('Browser.getVersion');
577
+ if (!/\bHeadlessChrome\//.test(userAgent) && !words.length) {
578
+ throw new Error('This browser has a window, so its clipboard is the computer\'s: whatever its user last copied, a password too. clipboard --shared-clipboard reads it anyway; ask them first. A headless browser (--launch, without --headed) has a clipboard of its own.');
579
+ }
580
+ const p = state.page;
581
+ const origin = await evaluated(p.evaluate(() => (isSecureContext && location.origin !== 'null' ? location.origin : null)));
582
+ if (!origin) throw new Error(`The selected tab (${p.url()}) is not a secure page, and only one can read the clipboard: https, or http on localhost`);
583
+ const permission = { name: 'clipboard-read' };
584
+ const before = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
585
+ // readText also needs the page focused: a tab the REPL uses is told it is (help tab).
586
+ await keepDrawing(p);
587
+ await browser.send('Browser.setPermission', { permission, setting: 'granted', origin });
588
+ try {
589
+ const granted = await p.evaluate(() => navigator.permissions.query({ name: 'clipboard-read' }).then(s => s.state));
590
+ if (granted !== 'granted') throw new Error('The browser did not let the selected tab read the clipboard: a tab in a private window, or in a browser context of its own, does not take the grant');
591
+ printOutput(await evaluated(p.evaluate(() => navigator.clipboard.readText())), all);
592
+ } finally {
593
+ await browser.send('Browser.setPermission', { permission, setting: before, origin }).catch(() => {});
594
+ }
595
+ } finally {
596
+ await browser.detach().catch(() => {});
597
+ }
598
+ },
599
+
550
600
  async cdp(args, all) {
551
601
  const match = /^(\S+)\s+([\s\S]+)$/.exec(args || '');
552
602
  if (!match) throw new Error('Usage: cdp <method> <JSON object>');
@@ -554,6 +604,13 @@ const commands = {
554
604
  if (match[1] === 'Page.handleJavaScriptDialog') throw new Error(CDP_REFUSED);
555
605
  const params = JSON.parse(match[2]);
556
606
  if (!params || Array.isArray(params) || typeof params !== 'object') throw new Error('Parameters must be a JSON object');
607
+ // Input sent through the tab's kept session: Chrome drops a touch or mouse sequence when the session
608
+ // that began it detaches. Not Input's two switches, which would stay on and break later clicks.
609
+ if (/^Input\./.test(match[1]) && !['Input.setIgnoreInputEvents', 'Input.setInterceptDrags'].includes(match[1])) {
610
+ const kept = await keptSession(state.page);
611
+ printOutput(await withTimeout(kept.send(match[1], params), 'CDP operation', COMMAND_TIMEOUT), all);
612
+ return;
613
+ }
557
614
  const operation = async () => {
558
615
  const cdp = await state.page.context().newCDPSession(state.page);
559
616
  try {
package/lib/output.js CHANGED
@@ -4,14 +4,21 @@ const OUTPUT_LIMIT = 12000;
4
4
 
5
5
  let sink = null;
6
6
 
7
+ // A cancelled command still running prints to the pane only, never into the next command's answer.
8
+ let cancelled = () => false;
9
+
10
+ function onCancelled(fn) {
11
+ cancelled = fn;
12
+ }
13
+
7
14
  function log(text) {
8
15
  console.log(text);
9
- if (sink) sink.push(String(text));
16
+ if (sink && !cancelled()) sink.push(String(text));
10
17
  }
11
18
 
12
19
  function error(text) {
13
20
  console.error(text);
14
- if (sink) sink.push(String(text));
21
+ if (sink && !cancelled()) sink.push(String(text));
15
22
  }
16
23
 
17
24
  // Commands run one at a time, so a single sink is enough. Lines printed by
@@ -86,4 +93,4 @@ function printOutput(value, all = false, footer = '') {
86
93
  log(footer ? `${text}\n${footer}` : text);
87
94
  }
88
95
 
89
- module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, printOutput };
96
+ module.exports = { OUTPUT_LIMIT, log, error, collect, take, notice, aside, onIdlePrint, onSelectedTab, onCancelled, printOutput };