pw-repl 0.4.1 → 0.5.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/AGENTS.md +7 -3
- package/README.md +90 -101
- package/bin/pw-repl.js +6 -5
- package/lib/background.js +41 -8
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +372 -41
- package/lib/help.js +30 -18
- package/lib/launch.js +97 -27
- package/lib/output.js +1 -1
- package/lib/runner.js +6 -3
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +15 -5
- package/lib/state.js +4 -1
- package/package.json +1 -1
- package/skill/SKILL.md +54 -20
package/lib/help.js
CHANGED
|
@@ -44,11 +44,11 @@ const TOPICS = {
|
|
|
44
44
|
commands: ['tab', 'goto', 'back', 'forward', 'reload', 'info'],
|
|
45
45
|
},
|
|
46
46
|
interact: {
|
|
47
|
-
intro: 'Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.\nCommands use the first match; see help
|
|
48
|
-
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload'],
|
|
47
|
+
intro: 'Selectors are Playwright selectors (CSS, text=..., role=...) or snapshot refs such as e5.\nCommands use the first match (click, dblclick and hover the first they can use: help click); see help\nfill for selectors with spaces. mousemove and the other mouse commands act at a point instead.',
|
|
48
|
+
commands: ['click', 'dblclick', 'hover', 'fill', 'type', 'press', 'select', 'check', 'uncheck', 'upload', 'mousemove', 'mousedown', 'mouseup', 'mousewheel'],
|
|
49
49
|
},
|
|
50
50
|
inspect: {
|
|
51
|
-
intro: 'Output is capped;
|
|
51
|
+
intro: 'Output is capped; --all right after the command, or at the end, shows everything (text --all body).',
|
|
52
52
|
commands: ['snapshot', 'watch', 'text', 'html', 'attrs', 'listeners', 'count', 'visible', 'links', 'inputs', 'screenshot', 'viewport', 'emulate', 'wait', 'sleep'],
|
|
53
53
|
},
|
|
54
54
|
network: {
|
|
@@ -91,7 +91,7 @@ const COMMANDS = {
|
|
|
91
91
|
goto: {
|
|
92
92
|
usage: 'goto <url>',
|
|
93
93
|
summary: 'navigate the selected tab',
|
|
94
|
-
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.',
|
|
94
|
+
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.\n\nA page that does not load says why, as wait load does: a failed request, after which the tab shows\nChrome\'s error page (info names the URL that failed), or an answer with no page (a 204), which\nleaves the tab where it was. reload, back and forward say it the same way.',
|
|
95
95
|
},
|
|
96
96
|
back: {
|
|
97
97
|
usage: 'back',
|
|
@@ -103,11 +103,23 @@ const COMMANDS = {
|
|
|
103
103
|
info: { usage: 'info', summary: 'selected tab URL, title and viewport' },
|
|
104
104
|
click: {
|
|
105
105
|
usage: 'click <selector> [button]',
|
|
106
|
-
summary: 'click the first match',
|
|
107
|
-
detail: 'After a ref or a quoted selector, a button (left, right, middle) may follow: click e5 right.\n--modifiers=<key>[,<key>] holds keys down meanwhile (Alt, Control, ControlOrMeta, Meta, Shift).',
|
|
106
|
+
summary: 'click the first match that can be clicked',
|
|
107
|
+
detail: 'After a ref or a quoted selector, a button (left, right, middle) may follow: click e5 right.\n--modifiers=<key>[,<key>] holds keys down meanwhile (Alt, Control, ControlOrMeta, Meta, Shift).\n\nWhen a selector matches several elements, hidden ones and ones covered by something else (a dialog\'s\noverlay) are passed over, and it says which match it used: Clicked: text=Buy (match 3 of 4; ...). If\nnone can be clicked within 5s, it says why for each and does nothing. A ref, or a single match, is\nclicked as it is, waiting up to 5s for it to be ready.\n\nTimed out after it began acting, it says what stopped it (e.g. <div class="overlay"> intercepts\npointer events) and that the outcome is unknown; Playwright\'s full call log goes to the REPL\'s pane\nor log.',
|
|
108
|
+
},
|
|
109
|
+
dblclick: { usage: 'dblclick <selector> [button]', summary: 'double-click, choosing the match as click does' },
|
|
110
|
+
hover: { usage: 'hover <selector>', summary: 'hover, choosing the match as click does' },
|
|
111
|
+
mousemove: {
|
|
112
|
+
usage: 'mousemove <x> <y>',
|
|
113
|
+
summary: 'move the mouse to a point in the viewport',
|
|
114
|
+
detail: 'x and y are CSS pixels from the top left of the tab\'s viewport (not the page), as mouse events\nhave them. A snapshot has no coordinates; eval of getBoundingClientRect gives an element\'s centre:\neval (r => [r.x + r.width / 2, r.y + r.height / 2])(document.querySelector("#map")\n .getBoundingClientRect())\n\nA click at a point: mousemove <x> <y>, mousedown, mouseup. A drag: mousedown, then mousemove to\nanother point, then mouseup.',
|
|
115
|
+
},
|
|
116
|
+
mousedown: { usage: 'mousedown [button]', summary: 'press a mouse button where the mouse is (left, right or middle)' },
|
|
117
|
+
mouseup: { usage: 'mouseup [button]', summary: 'release a mouse button where the mouse is' },
|
|
118
|
+
mousewheel: {
|
|
119
|
+
usage: 'mousewheel <dx> <dy>',
|
|
120
|
+
summary: 'turn the wheel where the mouse is',
|
|
121
|
+
detail: 'Wheel events at the mouse\'s point (mousemove puts it there), in CSS pixels: positive dy scrolls\ndown. A map zooms around that point: mousemove 400 300, then mousewheel 0 -300 zooms in. It returns\nonce the events are sent, not once the page has scrolled or redrawn.',
|
|
108
122
|
},
|
|
109
|
-
dblclick: { usage: 'dblclick <selector> [button]', summary: 'double-click the first match; button and --modifiers as for click' },
|
|
110
|
-
hover: { usage: 'hover <selector>', summary: 'hover the first match' },
|
|
111
123
|
fill: {
|
|
112
124
|
usage: 'fill <selector> <value> [--submit]',
|
|
113
125
|
summary: 'clear an input and fill it',
|
|
@@ -130,12 +142,12 @@ const COMMANDS = {
|
|
|
130
142
|
snapshot: {
|
|
131
143
|
usage: 'snapshot [--full] [--grep <text> | <eN> | selector]',
|
|
132
144
|
summary: 'outline by role and name, with [ref=eN] labels',
|
|
133
|
-
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. --regex <pattern> is --grep by a\nregular expression: snapshot --regex "Total: \\d+".\n\nsnapshot e3 outlines one element. A ref (e3, or f1e3
|
|
145
|
+
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. --regex <pattern> is --grep by a\nregular expression: snapshot --regex "Total: \\d+".\n\nAn element with no role or name of its own (a card that is only a div) is left out, with its ref;\nsnapshot --full keeps it.\n\nsnapshot e3 outlines one element, and the refs of the last snapshot keep working. A ref (e3, or f1e3\nin newer Playwright) works as a selector in any command: click e3. Refs can change when the page\nchanges (e102 may become f4e98), so take a new snapshot after it does. snapshot <selector> (not a\nref) gives new refs, and those from before stop working.\n\nOutput over 60 lines ends with a line count.',
|
|
134
146
|
},
|
|
135
147
|
watch: {
|
|
136
148
|
usage: 'watch on [--changes] [--live] | off | [n|new]',
|
|
137
149
|
summary: 'record what happens in the tab; show the last n steps',
|
|
138
|
-
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath, each with its status as in requests\n(body <#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded\nas type.\n\nFrom its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one\nafter the tab navigates, even when the page navigates as soon as the request answers.\n\nwatch on --next-tab [url-part] returns at once, then waits for the next tab someone opens (tab new,\na client\'s own, does not count), or the next whose URL contains url-part, and watches it from its\nfirst page and selects it for the client that asked: for a tab someone is about to open. It takes\n--changes and --live too; watch off stops waiting, and modes lists it.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations: what a\npage changes on its own after a step settles (data arriving later) shows under the next step.\n\nA watch belongs to the tab, not to a client: every client sees the same steps, watch new\'s place is\nshared, and anyone\'s watch off stops it.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
150
|
+
detail: 'Off until watch on; a watch on after watch off starts a new recording. Records clicks, typing (once\nit pauses), Enter and Escape, form changes, submits and navigations, each described by role and name\nlike snapshot, with up to 5 of the requests it caused underneath, each with its status as in requests\n(body <#> for one), leaving out scripts and what requests hides. The REPL\'s own fill is recorded\nas type. A double-click shows as two clicks; hovering and moving the mouse are not recorded.\n\nFrom its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one\nafter the tab navigates, even when the page navigates as soon as the request answers.\n\nwatch on --next-tab [url-part] returns at once, then waits for the next tab someone opens (tab new,\na client\'s own, does not count), or the next whose URL contains url-part, and watches it from its\nfirst page and selects it for the client that asked: for a tab someone is about to open. It takes\n--changes and --live too; watch off stops waiting, and modes lists it.\n\nwatch on its own says whether it is on and shows the last 20 steps; watch <n> shows the last n.\nwatch new shows only the steps it has not shown yet, and requests that have since arrived for the\nlast one.\n\n--live also prints each step in the REPL window (never in the server\'s answer to a command) once it\nsettles, with its requests and changes, marked [watch], or [watch <url>] for a tab that is not the\nselected one. A step cut short by the next one, or by watch off, prints at once; changes it made\nthen show under the next step.\n\n--changes also shows what each step changed on the page once it settles, in up to 5 lines: + added,\n- removed, ~ changed, each element once with the first names inside it. Not for navigations: what a\npage changes on its own after a step settles (data arriving later) shows under the next step.\n\nA watch belongs to the tab, not to a client: every client sees the same steps, watch new\'s place is\nshared, and anyone\'s watch off stops it.\n\nTyped values are not recorded, and password fields not at all; --changes leaves out field values,\nbut what the page itself shows (e.g. "Hello <name>") is shown. Nothing is replayed.',
|
|
139
151
|
},
|
|
140
152
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
141
153
|
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
@@ -143,7 +155,7 @@ const COMMANDS = {
|
|
|
143
155
|
listeners: {
|
|
144
156
|
usage: 'listeners <selector>|document|window',
|
|
145
157
|
summary: 'the page\'s event listeners on an element',
|
|
146
|
-
detail: '
|
|
158
|
+
detail: 'For the first match, one line each: the event, how it was added (capture, once, passive), the\nstart of the handler, where ↵ marks a line break, and its line in its script. --all shows each handler\nwhole, as written. Listeners added on a parent (e.g. document, for delegation) are not the element\'s\nown: check listeners document too. Those Playwright adds to window as it acts are left out, and it\nsays how many.',
|
|
147
159
|
},
|
|
148
160
|
count: { usage: 'count <selector>', summary: 'number of matches' },
|
|
149
161
|
visible: { usage: 'visible <selector>', summary: 'whether the first match is visible' },
|
|
@@ -152,7 +164,7 @@ const COMMANDS = {
|
|
|
152
164
|
screenshot: {
|
|
153
165
|
usage: 'screenshot [<ref>] [--full] [-d <secs>] [name]',
|
|
154
166
|
summary: 'save a PNG of the viewport, --full page or an element',
|
|
155
|
-
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR; without it, next to the REPL\'s\nsocket when it serves on one of its own, or else in /tmp. --filename=<file> saves it there instead (a\nJPEG if it ends in .jpg), relative to the folder pw-repl send runs in; it never replaces a file.\n\nscreenshot e5 saves only that element, from a snapshot ref. --full-page is --full.\n\nIt brings the tab to the front of its window first: Chrome draws only the tab in front. The image\nis at CSS pixel size, one image pixel per CSS pixel
|
|
167
|
+
detail: 'Saved as screenshot-<name or timestamp>.png in $PW_SCREENSHOT_DIR; without it, next to the REPL\'s\nsocket when it serves on one of its own, or else in /tmp. --filename=<file> saves it there instead (a\nJPEG if it ends in .jpg), relative to the folder pw-repl send runs in; it never replaces a file.\n\nscreenshot e5 saves only that element, from a snapshot ref. --full-page is --full.\n\nIt brings the tab to the front of its window first: Chrome draws only the tab in front. The image\nis at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on, it is what the\nphone shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit.\n\n--delay counts down out loud first (maximum 60s), so someone can hold a hover or open a menu.',
|
|
156
168
|
},
|
|
157
169
|
viewport: { usage: 'viewport [WxH]', summary: 'show or set the viewport size' },
|
|
158
170
|
emulate: {
|
|
@@ -163,13 +175,13 @@ const COMMANDS = {
|
|
|
163
175
|
wait: {
|
|
164
176
|
usage: 'wait [text|request] <what> [--gone] [secs]',
|
|
165
177
|
summary: 'wait for an element, text, a response or a load (10s)',
|
|
166
|
-
detail: 'wait <selector> waits for a matching element to be visible, and wait text <text> for text to be;\nwait request <url-part|glob> waits for a matching response, counting one that finished since the\nprevious command began (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.',
|
|
178
|
+
detail: 'wait <selector> waits for a matching element to be visible, and wait text <text> for text to be;\nwait request <url-part|glob> waits for a matching response, counting one that finished since the\nprevious command began, or is still under way (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.',
|
|
167
179
|
},
|
|
168
|
-
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time (maximum 3600000)' },
|
|
180
|
+
sleep: { usage: 'sleep <ms>', summary: 'wait a fixed time, in milliseconds (maximum 3600000)' },
|
|
169
181
|
requests: {
|
|
170
182
|
usage: 'requests [--all] [n] [url-filter]',
|
|
171
183
|
summary: 'the selected tab\'s last n requests (default 20)',
|
|
172
|
-
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept. Each line: #number, time
|
|
184
|
+
detail: 'Recording starts when the REPL connects; the last 200 per tab are kept, of every kind the page\nrequests (not the browser\'s own, such as the favicon). Each line: #number, time, method, status (HTTP\ncode, pending, no response, failed: <reason>, <code>, then failed: <reason> when its body was cut\noff, or <code> faked), duration, kind (document, fetch, xhr, script, image, ...), URL. A request that\nfinished a moment ago can still show pending.\n\nn is how many of the latest to show (default 20). When more match, a first line says so: (last 20\nof 112 kept; requests 112 shows them all). A page often makes more than 20 in one load. A line\n"--- the page loads <url>" marks where each page load starts, even when a filter hides that request.\n\nbody <#> shows what a request got back.\n\nImages, fonts, stylesheets, media and extension requests are hidden unless --all is given.\n\nurl-filter is a substring: requests 20 /api/ shows API calls only, e.g. when dev-server scripts\ncrowd the list. --regex <pattern>, at the end, matches URLs by a regular expression instead.',
|
|
173
185
|
},
|
|
174
186
|
body: {
|
|
175
187
|
usage: 'body [--all] <#>|<url-part>',
|
|
@@ -179,7 +191,7 @@ const COMMANDS = {
|
|
|
179
191
|
console: {
|
|
180
192
|
usage: 'console [--all] [n] [level|filter]',
|
|
181
193
|
summary: 'the last n console messages and page errors',
|
|
182
|
-
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. A\n"--- the page loaded <url>" line marks each load after the first message shown.\n\nA level (error, warning, info, debug) shows that level and those above it: console warning shows\nwarnings and errors, page errors among them. info, which leaves out debug, is what playwright-cli\nshows by default. Anything else is a filter, matching part of the type or the text.\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.',
|
|
194
|
+
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. A\n"--- the page loaded <url>" line marks each load after the first message shown.\n\nA level (error, warning, info, debug) shows that level and those above it: console warning shows\nwarnings and errors, page errors among them. info, which leaves out debug, is what playwright-cli\nshows by default. Anything else is a filter, matching part of the type or the text.\n\nn is how many of the latest to show (default 20); when more match, a first line says so.\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.',
|
|
183
195
|
},
|
|
184
196
|
capture: {
|
|
185
197
|
usage: 'capture on [requests|console] [secs] | off',
|
|
@@ -189,7 +201,7 @@ const COMMANDS = {
|
|
|
189
201
|
route: {
|
|
190
202
|
usage: 'route <glob> <how> | off <glob>|--all',
|
|
191
203
|
summary: 'fake, patch, delay or fail the selected tab\'s matching requests',
|
|
192
|
-
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON, or no body; it never\n reaches the network, so it still answers while the network is off.\n The body is the rest of the line. Another kind needs its type:\n route <glob> 200 --content-type=text/plain hello\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests; requests shows a fake
|
|
204
|
+
detail: 'route <glob> <status> <json> answer with this status (200-599) and JSON, or no body; it never\n reaches the network, so it still answers while the network is off.\n The body is the rest of the line. Another kind needs its type:\n route <glob> 200 --content-type=text/plain hello\nroute <glob> patch <json> let it through, then change its JSON response: a JSON Merge Patch, where\n objects merge, null removes a key and anything else replaces; a\n patch that is not an object (an array) replaces the whole body\nroute <glob> delay <secs> hold it for up to 120 seconds, then let it through\nroute <glob> abort fail it as if the connection broke\n\nEach matching request prints a line (Faked:, Patched:, Delayed:, Aborted:) in the REPL window, and in\nthe answer to a command running then, with its number as in requests (one the page makes after reload or\na click has answered shows only in the window: wait request <glob> waits for it); requests shows a fake\nas <status> faked and a patch as <status> patched. If a route fails it prints "Route failed" and aborts\nthe request.\n\nRoutes belong to the tab and last until route off <glob> (or route off --all) or the REPL exits.\nRouting the same glob again replaces it. route on its own lists the selected tab\'s routes. A glob may\nbe quoted.\n\nExample: route **/api/cart patch {"total": 0}',
|
|
193
205
|
},
|
|
194
206
|
network: {
|
|
195
207
|
usage: 'network [on|off|slow [<ms> [<kbps>]]]',
|
|
@@ -221,7 +233,7 @@ const COMMANDS = {
|
|
|
221
233
|
help: { usage: 'help [topic | command | --all]', summary: 'this help; --all prints every topic and command in full' },
|
|
222
234
|
quit: {
|
|
223
235
|
usage: 'quit',
|
|
224
|
-
summary: 'disconnect, leaving Chromium running',
|
|
236
|
+
summary: 'disconnect, leaving Chromium running (one --launch started stops too)',
|
|
225
237
|
detail: 'Runs immediately, even while another command is waiting. Only available at the prompt.',
|
|
226
238
|
},
|
|
227
239
|
};
|
package/lib/launch.js
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
// pw-repl run|serve --launch: a Chromium of the REPL's own, started for it and
|
|
2
2
|
// stopped with it, in a profile that is removed afterwards. It is the quick
|
|
3
3
|
// start; a browser set up any other way is reached with PW_CDP_URL instead.
|
|
4
|
+
// A --user-data-dir among the flags after -- is used instead, and kept.
|
|
4
5
|
const { spawn } = require('child_process');
|
|
5
6
|
const fs = require('fs');
|
|
6
7
|
const os = require('os');
|
|
7
8
|
const path = require('path');
|
|
8
9
|
|
|
9
10
|
const START_TIMEOUT = 20000;
|
|
10
|
-
|
|
11
|
+
// Stopping: a moment to connect and ask it to close, a few seconds to close, as long again after SIGTERM,
|
|
12
|
+
// and up to 3s to remove a profile its helpers are still writing to. STOP_WORST is what it can take.
|
|
13
|
+
const CLOSE_CONNECT_TIMEOUT = 2000;
|
|
14
|
+
const CLOSE_TIMEOUT = 3000;
|
|
15
|
+
const TERM_TIMEOUT = 3000;
|
|
16
|
+
const STOP_WORST = CLOSE_CONNECT_TIMEOUT + CLOSE_TIMEOUT + TERM_TIMEOUT + 3000;
|
|
11
17
|
const INSTALL = 'npx playwright-core install chromium';
|
|
12
18
|
const ON_PATH = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome', 'microsoft-edge'];
|
|
13
19
|
const MAC_APPS = [
|
|
@@ -48,25 +54,69 @@ function smallShm() {
|
|
|
48
54
|
try { const shm = fs.statfsSync('/dev/shm'); return shm.blocks * shm.bsize < 1024 ** 3; } catch { return false; }
|
|
49
55
|
}
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
57
|
+
const GROUP_GONE_TIMEOUT = 2000;
|
|
58
|
+
|
|
59
|
+
// A launched Chromium runs in its own process group, so this stops the helper processes it starts too.
|
|
60
|
+
function killGroup(pid) {
|
|
61
|
+
try { process.kill(-pid, 'SIGKILL'); } catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function groupGone(pid) {
|
|
65
|
+
const deadline = Date.now() + GROUP_GONE_TIMEOUT;
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
try { process.kill(-pid, 0); } catch { return; }
|
|
68
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Starts it and waits for the port it picked, which it prints (as Playwright reads it too): a
|
|
73
|
+
// DevToolsActivePort file in a profile of the caller's can be one an earlier run left behind.
|
|
74
|
+
// One that does not start is stopped, helpers and all, before this returns.
|
|
75
|
+
function start(exe, args, onSpawn) {
|
|
53
76
|
return new Promise(resolve => {
|
|
54
|
-
// Its own process group, so stopping it also stops the helper processes it starts.
|
|
55
77
|
const proc = spawn(exe, args, { stdio: ['ignore', 'ignore', 'pipe'], detached: true });
|
|
78
|
+
onSpawn(proc);
|
|
56
79
|
let stderr = '';
|
|
80
|
+
let partial = '';
|
|
57
81
|
let settled = false;
|
|
58
|
-
const done = result => {
|
|
59
|
-
|
|
82
|
+
const done = result => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true; clearTimeout(timer);
|
|
85
|
+
if (!result.error) return resolve(result);
|
|
86
|
+
killGroup(proc.pid);
|
|
87
|
+
// A helper still holding the profile's lock would make a retry in the same profile fail.
|
|
88
|
+
groupGone(proc.pid).then(() => resolve(result));
|
|
89
|
+
};
|
|
90
|
+
proc.stderr.on('data', d => {
|
|
91
|
+
if (stderr.length < 20000) stderr += d;
|
|
92
|
+
const text = partial + d;
|
|
93
|
+
const listening = /^DevTools listening on ws:\/\/[^/\s]+:(\d+)\//m.exec(text);
|
|
94
|
+
if (listening) done({ proc, port: listening[1] });
|
|
95
|
+
partial = text.slice(text.lastIndexOf('\n') + 1);
|
|
96
|
+
});
|
|
60
97
|
proc.on('error', error => done({ error: error.message, stderr }));
|
|
61
98
|
proc.on('exit', code => done({ error: `it exited with code ${code}`, stderr }));
|
|
62
|
-
const
|
|
63
|
-
const poll = setInterval(() => {
|
|
64
|
-
try { const port = fs.readFileSync(portFile, 'utf8').split('\n')[0].trim(); if (port) done({ proc, port }); } catch {}
|
|
65
|
-
}, 100);
|
|
66
|
-
const timer = setTimeout(() => { proc.kill('SIGKILL'); done({ error: `no debugging port within ${START_TIMEOUT / 1000}s`, stderr }); }, START_TIMEOUT);
|
|
99
|
+
const timer = setTimeout(() => done({ error: `no debugging port within ${START_TIMEOUT / 1000}s`, stderr }), START_TIMEOUT);
|
|
67
100
|
});
|
|
68
101
|
}
|
|
69
102
|
|
|
103
|
+
// Chromium takes only --user-data-dir=<dir>, and the last one given.
|
|
104
|
+
function userDataDir(args) {
|
|
105
|
+
const given = args.filter(a => a.startsWith('--user-data-dir=')).pop();
|
|
106
|
+
return given ? path.resolve(given.slice('--user-data-dir='.length)) : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function closeBrowser(url) {
|
|
110
|
+
let browser;
|
|
111
|
+
try {
|
|
112
|
+
browser = await require('playwright-core').chromium.connectOverCDP(url, { timeout: CLOSE_CONNECT_TIMEOUT });
|
|
113
|
+
const session = await browser.newBrowserCDPSession();
|
|
114
|
+
// It closes before it answers, or as it does.
|
|
115
|
+
await session.send('Browser.close').catch(() => {});
|
|
116
|
+
} catch {}
|
|
117
|
+
try { await browser?.close(); } catch {}
|
|
118
|
+
}
|
|
119
|
+
|
|
70
120
|
function quoted(args) {
|
|
71
121
|
return args.map(a => (/[\s"'$]/.test(a) ? JSON.stringify(a) : a)).join(' ');
|
|
72
122
|
}
|
|
@@ -76,40 +126,60 @@ async function launch({ headed = false, extraArgs = [] }) {
|
|
|
76
126
|
if (!exe) {
|
|
77
127
|
throw new Error(`No Chromium found to launch. Install Playwright's with:\n ${INSTALL}\nor set PW_CHROME to one, or start one yourself with --remote-debugging-port and set PW_CDP_URL.`);
|
|
78
128
|
}
|
|
79
|
-
const
|
|
80
|
-
const
|
|
129
|
+
const given = userDataDir(extraArgs);
|
|
130
|
+
const profile = given || fs.mkdtempSync(path.join(os.tmpdir(), 'pw-repl-chrome-'));
|
|
131
|
+
const removeProfile = () => { if (!given) fs.rmSync(profile, { recursive: true, force: true }); };
|
|
132
|
+
// Registered before Chromium starts, and kept for the REPL's life: a REPL that goes without shutting
|
|
133
|
+
// down, even while it waits for Chromium, still takes it (and a profile of its own) along.
|
|
134
|
+
let group = null;
|
|
135
|
+
const onExit = () => { if (group) killGroup(group); try { removeProfile(); } catch {} };
|
|
136
|
+
process.on('exit', onExit);
|
|
137
|
+
const onSpawn = proc => { group = proc.pid; };
|
|
138
|
+
const ours = [...(headed ? [] : ['--headless=new']), '--remote-debugging-port=0', ...(given ? [] : [`--user-data-dir=${profile}`]),
|
|
81
139
|
'--no-first-run', '--no-default-browser-check', ...(smallShm() ? ['--disable-dev-shm-usage'] : [])];
|
|
140
|
+
// A profile already used has tabs to restore; a blank tab of its own each time would pile up there.
|
|
141
|
+
// Local State is written at its root on the first run, whichever --profile-directory is used.
|
|
142
|
+
const blank = given && fs.existsSync(path.join(given, 'Local State')) ? [] : ['about:blank'];
|
|
82
143
|
// Flags given after -- come after these, so they can override them.
|
|
83
|
-
let args = [...ours, ...extraArgs,
|
|
84
|
-
let result = await start(exe, args,
|
|
144
|
+
let args = [...ours, ...extraArgs, ...blank];
|
|
145
|
+
let result = await start(exe, args, onSpawn);
|
|
85
146
|
let note = null;
|
|
86
147
|
// Tried with the sandbox first; a container often cannot give Chromium one.
|
|
87
148
|
if (result.error && /sandbox/i.test(result.stderr)) {
|
|
88
|
-
args = [...ours, '--no-sandbox', ...extraArgs,
|
|
89
|
-
result = await start(exe, args,
|
|
149
|
+
args = [...ours, '--no-sandbox', ...extraArgs, ...blank];
|
|
150
|
+
result = await start(exe, args, onSpawn);
|
|
90
151
|
note = 'Chromium could not use its sandbox here, so it runs with --no-sandbox.';
|
|
91
152
|
}
|
|
92
153
|
const command = quoted([exe, ...args]);
|
|
93
154
|
if (result.error) {
|
|
94
|
-
|
|
155
|
+
process.removeListener('exit', onExit);
|
|
156
|
+
removeProfile();
|
|
95
157
|
const last = result.stderr.trim().split('\n').slice(-5).join('\n');
|
|
96
158
|
throw new Error(`Chromium did not start (${result.error}):\n ${command}${last ? `\n${last}` : ''}`);
|
|
97
159
|
}
|
|
98
160
|
const { proc, port } = result;
|
|
99
|
-
const
|
|
161
|
+
const url = `http://127.0.0.1:${port}`;
|
|
162
|
+
const exited = new Promise(resolve => { if (proc.exitCode !== null || proc.signalCode !== null) resolve(); else proc.once('exit', resolve); });
|
|
163
|
+
const exitsWithin = ms => Promise.race([exited.then(() => true), new Promise(resolve => setTimeout(resolve, ms, false))]);
|
|
100
164
|
const stop = async () => {
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
165
|
+
if (!(await exitsWithin(0))) {
|
|
166
|
+
// Browser.close shuts it down in order: its helpers save the profile (what pages last wrote to
|
|
167
|
+
// localStorage) and it removes the profile's lock. SIGTERM to the browser process saves the
|
|
168
|
+
// profile too but leaves the lock; to the whole group, it loses the last writes.
|
|
169
|
+
// Not awaited: a browser that hangs may never answer; its exit is what counts.
|
|
170
|
+
void closeBrowser(url);
|
|
171
|
+
if (!(await exitsWithin(CLOSE_CONNECT_TIMEOUT + CLOSE_TIMEOUT))) {
|
|
172
|
+
try { process.kill(proc.pid, 'SIGTERM'); } catch {}
|
|
173
|
+
await exitsWithin(TERM_TIMEOUT);
|
|
174
|
+
}
|
|
104
175
|
}
|
|
176
|
+
killGroup(proc.pid);
|
|
105
177
|
// Its helper processes can still be writing to the profile for a moment.
|
|
106
178
|
for (let tries = 0; tries < 30; tries++) {
|
|
107
|
-
try {
|
|
179
|
+
try { removeProfile(); return; } catch { await new Promise(resolve => setTimeout(resolve, 100)); }
|
|
108
180
|
}
|
|
109
181
|
};
|
|
110
|
-
|
|
111
|
-
process.on('exit', () => signal('SIGKILL'));
|
|
112
|
-
return { url: `http://127.0.0.1:${port}`, command, note, headed, stop };
|
|
182
|
+
return { url, command, note, headed, stop };
|
|
113
183
|
}
|
|
114
184
|
|
|
115
|
-
module.exports = { findChrome, launch, INSTALL };
|
|
185
|
+
module.exports = { findChrome, launch, INSTALL, START_TIMEOUT, STOP_WORST };
|
package/lib/output.js
CHANGED
|
@@ -61,7 +61,7 @@ function printOutput(value, all = false) {
|
|
|
61
61
|
else text = JSON.stringify(value, null, 2);
|
|
62
62
|
if (text === undefined) text = 'undefined';
|
|
63
63
|
if (!all && text.length > OUTPUT_LIMIT) {
|
|
64
|
-
log(`${text.slice(0, OUTPUT_LIMIT)}\n[truncated;
|
|
64
|
+
log(`${text.slice(0, OUTPUT_LIMIT)}\n[truncated; add --all to the command to show all]`);
|
|
65
65
|
} else {
|
|
66
66
|
log(text);
|
|
67
67
|
}
|
package/lib/runner.js
CHANGED
|
@@ -30,7 +30,7 @@ const READ_ONLY = new Set(['info', 'listeners', 'text', 'html', 'attrs', 'count'
|
|
|
30
30
|
// Commands that work with no tab selected.
|
|
31
31
|
const NO_TAB_NEEDED = new Set(['tab', 'modes', 'capture', 'dialog', 'help', 'quit', 'tab-select', 'close']);
|
|
32
32
|
|
|
33
|
-
// Commands that accept
|
|
33
|
+
// Commands that accept --all, right after the command or at the end, to lift the output limit.
|
|
34
34
|
const INSPECTION = ['info', 'listeners', 'text', 'html', 'attrs', 'links', 'inputs', 'eval', 'cdp', 'cookies', 'storage', 'capture', 'body', 'console', 'snapshot', 'watch'];
|
|
35
35
|
|
|
36
36
|
let queue = Promise.resolve();
|
|
@@ -44,6 +44,7 @@ function parseInput(line) {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function isUncertain(error) {
|
|
47
|
+
if (typeof error.uncertain === 'boolean') return error.uncertain;
|
|
47
48
|
const message = error.message || '';
|
|
48
49
|
if (!/timed out|timeout|connection (?:lost|closed)/i.test(message)) return false;
|
|
49
50
|
// Playwright's call log shows whether the element was ever found; if not, nothing was done.
|
|
@@ -85,8 +86,10 @@ async function executeCommand(text) {
|
|
|
85
86
|
const spaceIdx = text.indexOf(' ');
|
|
86
87
|
const cmd = spaceIdx === -1 ? text : text.slice(0, spaceIdx);
|
|
87
88
|
let args = spaceIdx === -1 ? '' : text.slice(spaceIdx + 1);
|
|
88
|
-
|
|
89
|
-
if (all) args = args.slice(5).trimStart();
|
|
89
|
+
let all = false;
|
|
90
|
+
if (INSPECTION.includes(cmd) && /^--all(?:\s|$)/.test(args)) { all = true; args = args.slice(5).trimStart(); }
|
|
91
|
+
// At the end too, where the truncation note's reader tends to put it (console 200 --all).
|
|
92
|
+
else if (INSPECTION.includes(cmd) && /(?:^|\s)--all$/.test(args)) { all = true; args = args.slice(0, -5).trimEnd(); }
|
|
90
93
|
// wait request counts responses since the previous command began.
|
|
91
94
|
state.previousCommandAt = state.currentCommandAt;
|
|
92
95
|
state.currentCommandAt = Date.now();
|
package/lib/send.js
CHANGED
|
@@ -100,6 +100,7 @@ async function send(options) {
|
|
|
100
100
|
if (way.kind === 'tmux' && options.client) return fail('-c needs the command server (pw-repl serve); a REPL in tmux has no clients');
|
|
101
101
|
if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
|
|
102
102
|
const answer = await client.request(way.endpoint, command, timeoutMs, name);
|
|
103
|
+
if (answer.result?.starting) return fail(`${answer.result.output} (pw-repl where says when it serves)`);
|
|
103
104
|
if (answer.result) {
|
|
104
105
|
if (answer.result.output) console.log(answer.result.output);
|
|
105
106
|
if (answer.result.unconfirmed) { console.error('completion not confirmed'); return 2; }
|
|
@@ -115,6 +116,9 @@ async function send(options) {
|
|
|
115
116
|
|
|
116
117
|
// Reports the route a command would take, checking it the way a command would.
|
|
117
118
|
async function where(options) {
|
|
119
|
+
// First, whatever else is reachable: a saved skill is compared with it (pw-repl skill: Start it).
|
|
120
|
+
const skill = require('./skill').current();
|
|
121
|
+
console.log(`skill: ${skill.hash} (pw-repl ${skill.version})`);
|
|
118
122
|
const way = route(options);
|
|
119
123
|
const info = way.kind === 'server' ? await client.healthInfo(way.endpoint, 5000) : null;
|
|
120
124
|
// The browser a running REPL uses (one it launched has its own address); otherwise the one it would
|
|
@@ -126,6 +130,11 @@ async function where(options) {
|
|
|
126
130
|
console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
|
|
127
131
|
if (way.kind === 'server') {
|
|
128
132
|
const name = client.describe(way.endpoint);
|
|
133
|
+
if (info?.status === 'starting') {
|
|
134
|
+
// Not an error, but not serving yet: exit 64 as for no REPL, so a loop on where waits for it.
|
|
135
|
+
console.log(`server: ${name} (the REPL was started with pw-repl serve and is starting: ${info.step || 'starting'}; retry shortly)`);
|
|
136
|
+
return 64;
|
|
137
|
+
}
|
|
129
138
|
if (info) {
|
|
130
139
|
const background = require('./background').describeBackground(way.endpoint);
|
|
131
140
|
console.log(`server: ${name} (the REPL was started with pw-repl serve${background ? ' --background' : ''})`);
|
package/lib/server.js
CHANGED
|
@@ -31,7 +31,7 @@ function handle(req, res) {
|
|
|
31
31
|
// cross-origin POST, and cannot send a JSON content type without a
|
|
32
32
|
// preflight this server never answers.
|
|
33
33
|
if (req.headers.origin) return send(res, 403, { status: 'error', output: 'Requests from web pages are refused' });
|
|
34
|
-
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: 'ok', browser: state.cdpUrl, launched: state.launched });
|
|
34
|
+
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: state.serving ? 'ok' : 'starting', step: state.serving ? undefined : state.startingStep, browser: state.cdpUrl, launched: state.launched });
|
|
35
35
|
if (req.method !== 'POST' || req.url !== '/run') return send(res, 404, { status: 'error', output: 'Use POST /run or GET /health' });
|
|
36
36
|
if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return send(res, 415, { status: 'error', output: 'Content-Type must be application/json' });
|
|
37
37
|
let body = '';
|
|
@@ -50,6 +50,8 @@ function handle(req, res) {
|
|
|
50
50
|
if (client !== undefined && client !== null && !CLIENT_NAME.test(client)) {
|
|
51
51
|
return send(res, 400, { status: 'error', output: 'A client name is 1-40 letters, digits, dots, dashes and underscores' });
|
|
52
52
|
}
|
|
53
|
+
// Bound before the browser is reached, so where and send find it while it starts.
|
|
54
|
+
if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
|
|
53
55
|
const result = await runner.submit(command.trim(), client || null);
|
|
54
56
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
55
57
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
@@ -71,6 +73,8 @@ async function clearStaleSocket(socketPath) {
|
|
|
71
73
|
fs.unlinkSync(socketPath);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
// Listens before the REPL connects, answering "starting" until the function it returns is called,
|
|
77
|
+
// so a REPL that is still connecting is not taken for one without a server.
|
|
74
78
|
async function serve(endpointArg) {
|
|
75
79
|
const endpoint = parseEndpoint(endpointArg);
|
|
76
80
|
if (endpoint.socket) await clearStaleSocket(endpoint.socket);
|
|
@@ -90,7 +94,10 @@ async function serve(endpointArg) {
|
|
|
90
94
|
const removeSocket = () => { if (endpoint.socket) try { fs.unlinkSync(endpoint.socket); } catch {} };
|
|
91
95
|
onShutdown(() => { server.close(); removeSocket(); });
|
|
92
96
|
process.on('exit', removeSocket);
|
|
93
|
-
|
|
97
|
+
return () => {
|
|
98
|
+
state.serving = true;
|
|
99
|
+
out.log(`Serving commands on ${describe(endpoint)}`);
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
module.exports = { serve };
|
package/lib/skill.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// The agent skill, stamped as it is printed with the version it came from and a hash of its text,
|
|
2
|
+
// so a saved copy can be told apart from the skill of the pw-repl being run.
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const FILE = path.join(__dirname, '..', 'skill', 'SKILL.md');
|
|
8
|
+
const STAMP = '<!-- pw-repl skill stamp -->';
|
|
9
|
+
|
|
10
|
+
// Only the skill's own text counts: not the stamp line, nor the Custom rules a person adds to a
|
|
11
|
+
// saved copy. Line endings are made the same first, so a Windows checkout gets the same hash.
|
|
12
|
+
function hashOf(text) {
|
|
13
|
+
const own = text.replace(/\r\n?/g, '\n').split('\n## Custom rules\n')[0]
|
|
14
|
+
.split('\n').filter(line => line !== STAMP && !/^This skill is from pw-repl /.test(line)).join('\n').trimEnd();
|
|
15
|
+
return crypto.createHash('sha256').update(own).digest('hex').slice(0, 8);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function current() {
|
|
19
|
+
const text = fs.readFileSync(FILE, 'utf8');
|
|
20
|
+
return { text, hash: hashOf(text), version: require('../package.json').version };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// What `pw-repl skill` prints.
|
|
24
|
+
function stamped() {
|
|
25
|
+
const { text, hash, version } = current();
|
|
26
|
+
return text.replace(STAMP, `This skill is from pw-repl ${version} (skill ${hash}).`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { STAMP, hashOf, current, stamped };
|
package/lib/start.js
CHANGED
|
@@ -3,7 +3,7 @@ const { chromium } = require('playwright-core');
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { state, withTimeout, shutdown, onBeforeExit, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const { listTabs, openTab, watchPage, complete } = require('./commands');
|
|
6
|
+
const { listTabs, openTab, startTabs, watchPage, complete } = require('./commands');
|
|
7
7
|
const runner = require('./runner');
|
|
8
8
|
|
|
9
9
|
// A browser --launch starts has its own address instead.
|
|
@@ -28,7 +28,10 @@ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve
|
|
|
28
28
|
|
|
29
29
|
async function start(options) {
|
|
30
30
|
const START_URL = options.startUrl || process.env.PW_START_URL || null;
|
|
31
|
+
if (options.pidFile) require('./background').markStopInLog();
|
|
32
|
+
const serving = options.serve ? await require('./server').serve(options.endpoint) : null;
|
|
31
33
|
if (options.launch) {
|
|
34
|
+
state.startingStep = 'launching a Chromium of its own';
|
|
32
35
|
const launched = await require('./launch').launch({ headed: options.headed, extraArgs: options.chromeArgs });
|
|
33
36
|
onBeforeExit(launched.stop);
|
|
34
37
|
CDP_URL = launched.url;
|
|
@@ -40,6 +43,7 @@ async function start(options) {
|
|
|
40
43
|
state.cdpUrl = CDP_URL;
|
|
41
44
|
state.launched = !!options.launch;
|
|
42
45
|
out.log(`Connecting to ${CDP_URL}...`);
|
|
46
|
+
state.startingStep = 'connecting to the browser';
|
|
43
47
|
try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
|
|
44
48
|
catch (error) { throw new Error(connectHelp(error)); }
|
|
45
49
|
if (state.stopping) {
|
|
@@ -71,7 +75,9 @@ async function start(options) {
|
|
|
71
75
|
}
|
|
72
76
|
|
|
73
77
|
if (START_URL) {
|
|
78
|
+
state.startingStep = 'opening the start URL';
|
|
74
79
|
state.page = await openTab();
|
|
80
|
+
startTabs.add(state.page);
|
|
75
81
|
await state.page.goto(START_URL, { waitUntil: 'networkidle', timeout: 15000 });
|
|
76
82
|
out.log(`Opened ${state.page.url()} in a new tab`);
|
|
77
83
|
}
|
|
@@ -80,7 +86,7 @@ async function start(options) {
|
|
|
80
86
|
await listTabs();
|
|
81
87
|
out.log('');
|
|
82
88
|
if (!state.page) out.log('No tab is selected: tab new [url] opens one of your own, tab <index|url-part> selects one.');
|
|
83
|
-
if (
|
|
89
|
+
if (serving) serving();
|
|
84
90
|
// In the background there is no prompt: commands come only through the server.
|
|
85
91
|
if (options.pidFile) {
|
|
86
92
|
require('./background').claimPidFile(options.pidFile);
|
|
@@ -150,7 +156,11 @@ function stop() {
|
|
|
150
156
|
shutdown().then(beforeExit).then(() => process.exit(process.exitCode || 0));
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
// SIGHUP too: closing the terminal must still remove the server's socket.
|
|
154
|
-
|
|
159
|
+
// SIGHUP too: closing the terminal must still remove the server's socket. SIGINT is Ctrl-C before the
|
|
160
|
+
// prompt reads it (while --launch starts Chromium, or while connecting): without this, node exits at
|
|
161
|
+
// once and leaves a launched Chromium running.
|
|
162
|
+
for (const signal of ['SIGTERM', 'SIGHUP', 'SIGINT']) process.on(signal, stop);
|
|
155
163
|
|
|
156
|
-
|
|
164
|
+
// Stopped while it starts (Ctrl-C), what was under way fails as the browser goes: stop() says nothing
|
|
165
|
+
// of that, and exits.
|
|
166
|
+
module.exports = { start: options => start(options).catch(e => { if (state.stopping) return; out.error(e.message); shutdown().then(beforeExit).finally(() => process.exit(1)); }) };
|
package/lib/state.js
CHANGED
|
@@ -14,6 +14,9 @@ const state = {
|
|
|
14
14
|
// The browser the REPL is connected to, and whether --launch started it.
|
|
15
15
|
cdpUrl: null,
|
|
16
16
|
launched: false,
|
|
17
|
+
// The command server takes commands; before, it answers that the REPL is starting, and what it is doing.
|
|
18
|
+
serving: false,
|
|
19
|
+
startingStep: 'starting',
|
|
17
20
|
stopping: false,
|
|
18
21
|
connectionLost: false,
|
|
19
22
|
shutdownFailed: false,
|
|
@@ -83,4 +86,4 @@ async function shutdown() {
|
|
|
83
86
|
return shutdownPromise;
|
|
84
87
|
}
|
|
85
88
|
|
|
86
|
-
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown };
|
|
89
|
+
module.exports = { state, clientRecord, asClient, withTimeout, onShutdown, onBeforeExit, beforeExit, shutdown, SHUTDOWN_TIMEOUT };
|