pw-repl 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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: state.serving ? 'ok' : 'starting', step: state.serving ? undefined : state.startingStep, 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, pid: process.pid });
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 = '';
@@ -43,16 +43,20 @@ function handle(req, res) {
43
43
  req.on('end', async () => {
44
44
  let command;
45
45
  let client;
46
- try { ({ command, client } = JSON.parse(body)); } catch {}
46
+ let vars;
47
+ try { ({ command, client, vars } = JSON.parse(body)); } catch {}
47
48
  if (typeof command !== 'string' || !command.trim() || /[\r\n]/.test(command)) {
48
49
  return send(res, 400, { status: 'error', output: 'Body must be {"command": "<one line>"}, with "client": "<name>" if it has one' });
49
50
  }
50
51
  if (client !== undefined && client !== null && !CLIENT_NAME.test(client)) {
51
52
  return send(res, 400, { status: 'error', output: 'A client name is 1-40 letters, digits, dots, dashes and underscores' });
52
53
  }
54
+ if (vars !== undefined && (!vars || typeof vars !== 'object' || Object.values(vars).some(v => typeof v !== 'string' || /[\r\n]/.test(v)))) {
55
+ return send(res, 400, { status: 'error', output: '"vars" must be {"<NAME>": "<one line>", ...}' });
56
+ }
53
57
  // Bound before the browser is reached, so where and send find it while it starts.
54
58
  if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
55
- const result = await runner.submit(command.trim(), client || null);
59
+ const result = await runner.submit(command.trim(), client || null, vars || null);
56
60
  if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
57
61
  // unconfirmed: the command may or may not have done what it was sent to do.
58
62
  send(res, 200, { status: result.status, output: result.output, unconfirmed: true });
@@ -69,7 +73,11 @@ async function clearStaleSocket(socketPath) {
69
73
  probe.on('connect', () => { probe.destroy(); resolve(true); });
70
74
  probe.on('error', () => resolve(false));
71
75
  });
72
- if (live) throw new Error(`Another REPL is already serving on ${socketPath}`);
76
+ if (live) {
77
+ const why = await require('./background').alreadyServing(parseEndpoint(socketPath));
78
+ const message = why || `another REPL is already serving on ${socketPath}`;
79
+ throw new Error(message[0].toUpperCase() + message.slice(1));
80
+ }
73
81
  fs.unlinkSync(socketPath);
74
82
  }
75
83
 
package/lib/skill.js CHANGED
@@ -7,7 +7,7 @@ const path = require('path');
7
7
  const FILE = path.join(__dirname, '..', 'skill', 'SKILL.md');
8
8
  const STAMP = '<!-- pw-repl skill stamp -->';
9
9
 
10
- // Only the skill's own text counts: not the stamp line, nor the Custom rules a person adds to a
10
+ // Only the skill's own text counts: not the stamp line, nor the Custom rules the user adds to a
11
11
  // saved copy. Line endings are made the same first, so a Windows checkout gets the same hash.
12
12
  function hashOf(text) {
13
13
  const own = text.replace(/\r\n?/g, '\n').split('\n## Custom rules\n')[0]
package/lib/start.js CHANGED
@@ -29,6 +29,9 @@ Or let pw-repl start a private one for this REPL: pw-repl run --launch (or serve
29
29
 
30
30
  async function start(options) {
31
31
  const START_URL = options.startUrl || process.env.PW_START_URL || null;
32
+ // First, so a terminal or a log says which pw-repl ran; where names the skill to compare a saved one with.
33
+ const skill = require('./skill').current();
34
+ out.log(`pw-repl ${skill.version} (skill ${skill.hash})`);
32
35
  if (options.pidFile) require('./background').markStopInLog();
33
36
  const serving = options.serve ? await require('./server').serve(options.endpoint) : null;
34
37
  if (options.launch) {
@@ -45,7 +48,7 @@ async function start(options) {
45
48
  state.launched = !!options.launch;
46
49
  out.log(`Connecting to ${CDP_URL}...`);
47
50
  state.startingStep = 'connecting to the browser';
48
- try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT }); }
51
+ try { state.browser = await chromium.connectOverCDP(CDP_URL, { timeout: CONNECT_TIMEOUT, noDefaults: true }); }
49
52
  catch (error) { throw new Error(connectHelp(error)); }
50
53
  if (state.stopping) {
51
54
  try { await withTimeout(state.browser.close(), 'Chromium shutdown'); }
package/lib/syntax.js CHANGED
@@ -54,4 +54,33 @@ function keyName(key) {
54
54
  return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
55
55
  }
56
56
 
57
- module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName };
57
+ // {{ PW_NAME }} in a command send sends: a variable from the sender's environment. The REPL fills it in
58
+ // as it runs the line, so its pane and log show the line as written. Only PW_ names: {{ name }} is common
59
+ // in a page's own text (a template's), which send must neither fail on nor fill with one of the
60
+ // sender's own secrets (GITHUB_TOKEN). In double quotes the value is
61
+ // escaped as they read it (a JS string's too); anywhere else it goes in as it is. Single quotes escape
62
+ // nothing, so a value with one cannot go in them: refused, naming the variable, since a page or eval error
63
+ // that quoted the broken line would show the value.
64
+ const VARIABLE = /\{\{\s*(PW_\w+)\s*\}\}/g;
65
+
66
+ function variablesIn(line) {
67
+ return [...new Set([...line.matchAll(VARIABLE)].map(m => m[1]))];
68
+ }
69
+
70
+ // values: { NAME: value }. Throws on a variable it has no value for.
71
+ function expandVariables(line, values) {
72
+ const value = name => {
73
+ if (typeof values[name] !== 'string') throw new Error(`{{ ${name} }} has no value: send passes it along from ${name} in its environment`);
74
+ return values[name];
75
+ };
76
+ return line.replace(new RegExp(`"(?:[^"\\\\]|\\\\.)*"|'[^']*'|${VARIABLE.source}`, 'g'), (whole, name) => {
77
+ if (name) return value(name);
78
+ if (whole[0] === '"') return whole.replace(VARIABLE, (_, inner) => value(inner).replace(/[\\"]/g, '\\$&'));
79
+ return whole.replace(VARIABLE, (_, inner) => {
80
+ if (value(inner).includes("'")) throw new Error(`{{ ${inner} }} holds a ', which single quotes cannot hold: put it in double quotes`);
81
+ return value(inner);
82
+ });
83
+ });
84
+ }
85
+
86
+ module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName, variablesIn, expandVariables };
package/lib/tabs.js CHANGED
@@ -42,8 +42,9 @@ function markStartTab(p) {
42
42
 
43
43
  // A tab of the REPL's own: closing it can go back to the tab before. It opens
44
44
  // in the background, so it does not take the front of the window from the
45
- // person using the browser; Playwright's newPage would bring it to the front.
45
+ // user using the browser; Playwright's newPage would bring it to the front.
46
46
  const OPEN_TIMEOUT = 10000;
47
+ const FRONT_READ_TIMEOUT = 500;
47
48
 
48
49
  // True while tab new opens a tab: watch on --next-tab waits for one someone
49
50
  // else opens, not one a client of the REPL opens for itself.
@@ -74,7 +75,7 @@ async function openOwnTab() {
74
75
  if (!opened) opened = await ctx.newPage();
75
76
  tabState(opened).opened = true;
76
77
  tabState(opened).owner = state.client;
77
- keepDrawing(opened);
78
+ await keepDrawing(opened);
78
79
  return opened;
79
80
  }
80
81
 
@@ -104,11 +105,24 @@ async function pageForTarget(ctx, targetId) {
104
105
  }
105
106
 
106
107
  // Every tab, URL first, with * on the selected one. The numbers are what tab <index> uses.
108
+ // Whether a tab is the one in front in its window, as the tab itself says: 'in front', 'in front,
109
+ // focused' (its window has the keyboard, and not in the address bar), or null. Not for a tab a client
110
+ // uses, which is told it is in front so that it draws (lib/cdp.js); nor for one that does not answer
111
+ // at once (a dialog open, a frozen tab). Each window has one in front, an incognito one too.
112
+ async function inFront(p) {
113
+ if (tabState(p).focusEmulated) return null;
114
+ const read = p.evaluate(() => [document.visibilityState, document.hasFocus()]).catch(() => null);
115
+ const seen = await Promise.race([read, new Promise(resolve => setTimeout(resolve, FRONT_READ_TIMEOUT, null))]);
116
+ if (!seen || seen[0] !== 'visible') return null;
117
+ return seen[1] ? 'in front, focused' : 'in front';
118
+ }
119
+
107
120
  async function listTabs() {
108
121
  const all = state.browser.contexts().flatMap(c => c.pages());
109
122
  if (state.page && !all.includes(state.page)) state.page = null;
110
123
  state.tabListing = all.slice();
111
124
  if (!all.length) { out.log('No tabs. Use tab new.'); return; }
125
+ const fronts = await Promise.all(all.map(inFront));
112
126
  for (const [i, p] of all.entries()) {
113
127
  let title;
114
128
  try { title = await p.title(); } catch { title = '[title unavailable]'; }
@@ -116,7 +130,7 @@ async function listTabs() {
116
130
  // URL first on its own line: it is what distinguishes otherwise
117
131
  // identically titled tabs, and titles wrap less badly when indented.
118
132
  if (i) out.log('');
119
- const notes = ['start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
133
+ const notes = [fronts[i], 'start' in tabState(p) && 'the start URL', tabState(p).owner && `opened by ${tabState(p).owner}`].filter(Boolean);
120
134
  out.log(`${marker} [${i}] ${p.url()}${notes.length ? ` (${notes.join(', ')})` : ''}`);
121
135
  out.log(` ${JSON.stringify(title)}`);
122
136
  }
@@ -142,11 +156,13 @@ const commands = {
142
156
  // Matching on the URL, not an index, so a stale listing cannot point at someone else's tab.
143
157
  // Remembers where the REPL was, so closing a tab can go back there, but
144
158
  // only to a tab this REPL opened: the previous one may be someone else's.
145
- const select = p => {
159
+ // Its focus is on before anything acts in it: a click in a tab that is not yet told it is focused
160
+ // can leave it drawing a frame a second (lib/cdp.js).
161
+ const select = async p => {
146
162
  if (p !== state.page) state.previousPage = state.page;
147
163
  state.page = p;
148
164
  ensureDialogHandler(p);
149
- keepDrawing(p);
165
+ await keepDrawing(p);
150
166
  };
151
167
  const byUrl = part => {
152
168
  const matches = all.filter(p => p.url().includes(part));
@@ -157,7 +173,7 @@ const commands = {
157
173
  if (/^\d+$/.test(subcommand)) {
158
174
  if (parts.length) throw new Error(usage);
159
175
  const target = state.tabListing[Number(subcommand)];
160
- if (target && all.includes(target)) { select(target); return commands.info(); }
176
+ if (target && all.includes(target)) { await select(target); return commands.info(); }
161
177
  // Not read as part of a URL instead: that could land in someone else's tab without a word.
162
178
  if (target) throw new Error(`Tab [${subcommand}] has closed since the latest listing. Run tab to list them again.`);
163
179
  // A number that is not a tab in the listing may be part of a URL (a port).
@@ -169,7 +185,7 @@ const commands = {
169
185
  }
170
186
  }
171
187
  if (subcommand === 'new') {
172
- select(await openTab());
188
+ await select(await openTab());
173
189
  const url = parts.join(' ');
174
190
  if (url) {
175
191
  try { await commands.goto(url); }
@@ -196,7 +212,7 @@ const commands = {
196
212
  if (recording) out.log(await endedByTabClose(recording));
197
213
  return listTabs();
198
214
  }
199
- select(byUrl(trimmed));
215
+ await select(byUrl(trimmed));
200
216
  return commands.info();
201
217
  },
202
218
 
package/lib/toast.js ADDED
@@ -0,0 +1,166 @@
1
+ // A line of text over the page, to narrate a video or a screenshot: it stays until toast off, or for
2
+ // --duration. One per tab; another replaces it.
3
+ const { state, onShutdown } = require('./state');
4
+ const out = require('./output');
5
+ const { tabState } = require('./tabstate');
6
+ const { unquote } = require('./syntax');
7
+ const { takeOptions } = require('./cli-names');
8
+ const { inOverlay, keepAcrossPages, forget } = require('./overlay');
9
+ const { recordingOf, beforeRecordingEnds } = require('./record');
10
+
11
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
12
+
13
+ // While the tab records, a toast stays up at least this long (its read time) before another, toast off
14
+ // or record off may take it down: about the pace captions are read at.
15
+ const READ_MS_PER_WORD = 300;
16
+ const READ_MS_MIN = 1500;
17
+ const LONG_READ_MS = 15000;
18
+ const DEFAULTS = { position: 'bottom', size: 24, align: 'center', color: '#fff', background: 'rgba(20, 20, 20, .85)', opacity: 1 };
19
+
20
+ // Runs in the page, as the overlay's toast layer (lib/overlay.js). A new toast waits for the old one to
21
+ // fade out, then fades in; both are waited on, but not past their time in a tab that draws no frames.
22
+ function pageToast(overlay, { text, style, action, fade }) {
23
+ const { set } = overlay;
24
+ const old = overlay.toast;
25
+ const fadeTo = (el, from, to) => Promise.race([
26
+ el.animate([{ opacity: from }, { opacity: to }], { duration: 200, fill: 'forwards' }).finished,
27
+ new Promise(resolve => setTimeout(resolve, 500)),
28
+ ]).catch(() => {});
29
+ if (action === 'remove') {
30
+ if (!old) return;
31
+ delete overlay.toast;
32
+ return (fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve()).then(() => {
33
+ old.remove();
34
+ if (!overlay.toast) overlay.drop('toast');
35
+ });
36
+ }
37
+ const box = document.createElement('div');
38
+ overlay.toast = box;
39
+ box.textContent = text;
40
+ const edge = { top: { top: '6%' }, center: { top: '50%' }, bottom: { bottom: '6%' } }[style.position];
41
+ set(box, {
42
+ position: 'absolute', left: '50%', ...edge, transform: `translate(-50%, ${style.position === 'center' ? '-50%' : '0'})`,
43
+ 'max-width': '80%', width: 'max-content', 'box-sizing': 'border-box', padding: `${Math.round(style.size * 0.5)}px ${Math.round(style.size * 0.9)}px`,
44
+ 'border-radius': `${Math.round(style.size * 0.45)}px`, background: style.background, color: style.color, opacity: String(style.opacity),
45
+ font: `500 ${style.size}px/1.35 system-ui, -apple-system, 'Segoe UI', sans-serif`, 'text-align': style.align,
46
+ 'white-space': 'pre-wrap', 'overflow-wrap': 'anywhere', 'box-shadow': '0 4px 18px rgba(0, 0, 0, .3)',
47
+ });
48
+ const out = old && fade ? fadeTo(old, getComputedStyle(old).opacity, 0) : Promise.resolve();
49
+ return out.then(() => {
50
+ old?.remove();
51
+ if (overlay.toast !== box) return;
52
+ overlay.layer('toast').append(box);
53
+ if (fade) return fadeTo(box, 0, style.opacity);
54
+ });
55
+ }
56
+
57
+ function toastOf(p) {
58
+ return tabState(p).toast || null;
59
+ }
60
+
61
+ // Waits out what is left of the toast's read time, while its tab records; says so, when it waited.
62
+ async function waitToRead(p) {
63
+ const toast = toastOf(p);
64
+ if (!toast || !toast.readMs || !recordingOf(p)) return;
65
+ const left = toast.shownAt + toast.readMs - Date.now();
66
+ if (left <= 0) return;
67
+ await sleep(left);
68
+ out.log(`Waited ${(left / 1000).toFixed(1)}s for the toast's read time (the tab is recording)`);
69
+ }
70
+
71
+ async function toastOff(p, fade = true) {
72
+ const toast = toastOf(p);
73
+ if (!toast) return false;
74
+ clearTimeout(toast.timer);
75
+ delete tabState(p).toast;
76
+ forget(p, 'toast');
77
+ await inOverlay(p, pageToast, { action: 'remove', fade }, false).catch(() => {});
78
+ return true;
79
+ }
80
+
81
+ beforeRecordingEnds(p => (p && !p.isClosed() ? waitToRead(p) : null));
82
+
83
+ // 3s, 1.5s, 800ms, or a number of milliseconds.
84
+ function parseDuration(value) {
85
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value || '');
86
+ if (!match) return null;
87
+ return Math.round(Number(match[1]) * (match[2] === 's' ? 1000 : 1));
88
+ }
89
+
90
+ onShutdown(async () => {
91
+ if (!state.browser || state.connectionLost) return;
92
+ const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => !p.isClosed());
93
+ await Promise.all(pages.map(p => toastOff(p, false).catch(() => {})));
94
+ });
95
+
96
+ const commands = {
97
+ async toast(args) {
98
+ const p = state.page;
99
+ const line = (args || '').trim();
100
+ const toast = toastOf(p);
101
+ if (!line) {
102
+ out.log(toast
103
+ ? `A toast is up in the selected tab${toast.owner ? ` (by ${toast.owner})` : ''}: ${JSON.stringify(toast.text)}${toast.until ? `, for ${Math.max(0, Math.round((toast.until - Date.now()) / 100) / 10)}s more` : ''}; toast off hides it`
104
+ : 'No toast is up in the selected tab; toast <text> shows one');
105
+ return;
106
+ }
107
+ if (line === 'off') {
108
+ await waitToRead(p);
109
+ out.log(await toastOff(p) ? 'The toast is gone' : 'No toast was up in the selected tab');
110
+ return;
111
+ }
112
+ const usage = 'Usage: toast <text> [--duration=<time>] [--read-time=<time>] [--position=top|center|bottom] [--size=<px>] [--align=left|center|right] [--color=<css>] [--background=<css>] [--opacity=<0-1>] | toast off';
113
+ const { found, rest } = takeOptions(line, { duration: 'value', 'read-time': 'value', position: 'value', size: 'value', align: 'value', color: 'value', background: 'value', opacity: 'value' });
114
+ const text = unquote(rest.trim());
115
+ if (!text) throw new Error(usage);
116
+ const style = { ...DEFAULTS };
117
+ let duration = null;
118
+ let readTime = null;
119
+ for (const { name, value } of found) {
120
+ if (value === undefined || value === '') throw new Error(`--${name} needs a value: ${usage}`);
121
+ if (name === 'duration') {
122
+ duration = parseDuration(value);
123
+ if (!duration) throw new Error(`--duration is a time, e.g. --duration=3s or --duration=1500 (ms): ${usage}`);
124
+ } else if (name === 'read-time') {
125
+ readTime = parseDuration(value);
126
+ if (readTime === null) throw new Error(`--read-time is a time, e.g. --read-time=4s, or 0 for none: ${usage}`);
127
+ } else if (name === 'position') {
128
+ if (!['top', 'center', 'bottom'].includes(value)) throw new Error(`--position is top, center or bottom: ${usage}`);
129
+ style.position = value;
130
+ } else if (name === 'align') {
131
+ if (!['left', 'center', 'right'].includes(value)) throw new Error(`--align is left, center or right: ${usage}`);
132
+ style.align = value;
133
+ } else if (name === 'size') {
134
+ const size = Number(String(value).replace(/px$/, ''));
135
+ if (!(size >= 8 && size <= 200)) throw new Error(`--size is the text's size in pixels, 8 to 200: ${usage}`);
136
+ style.size = size;
137
+ } else if (name === 'opacity') {
138
+ const opacity = Number(value);
139
+ if (!(opacity > 0 && opacity <= 1)) throw new Error(`--opacity is above 0, up to 1: ${usage}`);
140
+ style.opacity = opacity;
141
+ } else {
142
+ style[name] = value;
143
+ }
144
+ }
145
+ if (duration && readTime !== null) throw new Error(`--duration and --read-time do not go together: a toast with a --duration ends by itself, and nothing waits for it`);
146
+ // Replaced, not stacked: the old one fades out, then the new one fades in.
147
+ if (toast) {
148
+ await waitToRead(p);
149
+ clearTimeout(toast.timer);
150
+ }
151
+ const words = text.split(/\s+/).filter(Boolean).length;
152
+ const readMs = duration ? 0 : readTime ?? Math.max(READ_MS_MIN, words * READ_MS_PER_WORD);
153
+ const shown = tabState(p).toast = { owner: state.client, text, style, until: duration ? Date.now() + duration : null, readMs, shownAt: Date.now() };
154
+ await inOverlay(p, pageToast, { text, style, action: 'draw', fade: true });
155
+ shown.shownAt = Date.now();
156
+ keepAcrossPages(p, 'toast', async () => { if (toastOf(p) === shown) await inOverlay(p, pageToast, { text, style, action: 'draw', fade: false }); });
157
+ if (duration) shown.timer = setTimeout(() => { if (toastOf(p) === shown && !p.isClosed()) toastOff(p).catch(() => {}); }, duration);
158
+ if (duration) { out.log(`Showing the toast for ${duration / 1000}s`); return; }
159
+ const why = readTime !== null ? '--read-time' : `${words} word${words === 1 ? '' : 's'}; ${READ_MS_PER_WORD}ms a word, at least ${READ_MS_MIN / 1000}s`;
160
+ // Past send's default 20s, the command that waits would come back unconfirmed while it still waits.
161
+ const long = readMs > LONG_READ_MS ? ` While recording, that one can wait up to ${readMs / 1000}s: send it with -t ${Math.ceil(readMs / 1000) + 10}.` : '';
162
+ out.log(`Showing the toast until toast off. ${readMs ? `Read time: ${readMs / 1000}s (${why}), which a recording waits out before the next toast, toast off or record off.${long}` : 'No read time (--read-time=0).'}`);
163
+ },
164
+ };
165
+
166
+ module.exports = { commands, toastOf, toastOff };