pw-repl 0.9.0 → 0.11.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/inspect.js CHANGED
@@ -12,7 +12,7 @@ const { keptSession } = require('./cdp');
12
12
  const { CDP_REFUSED } = require('./dialogs');
13
13
  const { onElement, soleSelector } = require('./elements');
14
14
  const { waitForLoad, waitForRequest } = require('./requestlog');
15
- const { deviceMetrics, viewportMetrics } = require('./emulation');
15
+ const { deviceMetrics, viewportMetrics, reapplyMetrics } = require('./emulation');
16
16
 
17
17
  const SCREENSHOT_DELAY_MAX = 60;
18
18
 
@@ -51,6 +51,50 @@ async function deviceShot(device, { ref, full, type }) {
51
51
  return Buffer.from(data, 'base64');
52
52
  }
53
53
 
54
+ // An element bigger than the viewport is taken with the page laid out again at its whole size, where
55
+ // the page's scrollbar is gone and what was beside it moves over by its width, after the element was
56
+ // measured: the shot came out shifted. Laid out without the scrollbar for the shot, by a stylesheet
57
+ // adopted for it (under any CSP), the element is measured where it is taken. Someone looking at the
58
+ // page sees its scrollbar go for a moment.
59
+ const NO_SCROLLBAR = 'html:not([data-pw-repl])';
60
+ async function elementShot(locator, type) {
61
+ const fits = await locator.evaluate(el => { const r = el.getBoundingClientRect(); return r.width <= innerWidth && r.height <= innerHeight; }, null, { timeout: 5000 });
62
+ if (fits) return locator.screenshot({ type, timeout: 5000 });
63
+ const sheet = hide => state.page.evaluate(([rule, on]) => {
64
+ const sheets = [...document.adoptedStyleSheets].filter(s => ![...s.cssRules].some(r => r.selectorText === rule));
65
+ if (on) {
66
+ const s = new CSSStyleSheet();
67
+ s.replaceSync(`${rule} { scrollbar-width: none !important; }`);
68
+ sheets.push(s);
69
+ }
70
+ document.adoptedStyleSheets = sheets;
71
+ }, [NO_SCROLLBAR, hide]);
72
+ await sheet(true);
73
+ try {
74
+ return await locator.screenshot({ type, timeout: 5000 });
75
+ } finally {
76
+ await sheet(false).catch(() => {});
77
+ }
78
+ }
79
+
80
+ // A password field's value shows in a snapshot as typed, and in html and attrs as its value attribute,
81
+ // which some pages (React's) keep in step with it. It is left out, as watch leaves it out, so a password
82
+ // filled in with {{ PW_PASSWORD }} stays out of the pane and the log too. These are the fields
83
+ // watch counts as secret, but one only shown as text for now (Show password) unless its autocomplete
84
+ // says so. A field that cannot be checked any more is taken to be one.
85
+ const SECRET_FIELD = ['input[type=password]', ...['current-password', 'new-password', 'one-time-code', 'cc-number', 'cc-csc']
86
+ .map(kind => `[autocomplete~="${kind}"]`)].join(',');
87
+ async function hideSecrets(page, text) {
88
+ const lines = text.split('\n');
89
+ for (const [i, line] of lines.entries()) {
90
+ const field = /^(\s*- (?:textbox|searchbox|spinbutton|combobox)\b.*\[ref=(\w+)\][^:]*): .+$/.exec(line);
91
+ if (!field) continue;
92
+ const secret = await page.locator(`aria-ref=${field[2]}`).evaluate((el, selector) => el.matches(selector), SECRET_FIELD, { timeout: 1000 }).catch(() => true);
93
+ if (secret) lines[i] = `${field[1]}: (a password: not shown)`;
94
+ }
95
+ return lines.join('\n');
96
+ }
97
+
54
98
  // Unnamed generic nodes are layout wrappers (mostly divs): they add depth and
55
99
  // nothing to read. Drop them, lift their children, and drop cursor hints.
56
100
  function compactSnapshot(text) {
@@ -134,16 +178,23 @@ const commands = {
134
178
 
135
179
  async html(args, all) {
136
180
  const selector = soleSelector(args, 'html <selector>');
137
- printOutput(await onElement(selector, () => state.page.$eval(selector, el => el.outerHTML)), all);
181
+ // A copy in a document of its own, where the page's code (a custom element's) does not run.
182
+ printOutput(await onElement(selector, () => state.page.$eval(selector, (el, secret) => {
183
+ const copy = document.implementation.createHTMLDocument('').importNode(el, true);
184
+ for (const field of [copy, ...copy.querySelectorAll('*')]) {
185
+ if (field.matches(secret) && field.hasAttribute('value')) field.setAttribute('value', '(a password: not shown)');
186
+ }
187
+ return copy.outerHTML;
188
+ }, SECRET_FIELD)), all);
138
189
  },
139
190
 
140
191
  async attrs(args, all) {
141
192
  const selector = soleSelector(args, 'attrs <selector>');
142
- const result = await onElement(selector, () => state.page.$eval(selector, el => {
193
+ const result = await onElement(selector, () => state.page.$eval(selector, (el, secret) => {
143
194
  const out = {};
144
- for (const attr of el.attributes) out[attr.name] = attr.value;
195
+ for (const attr of el.attributes) out[attr.name] = attr.name === 'value' && el.matches(secret) ? '(a password: not shown)' : attr.value;
145
196
  return out;
146
- }));
197
+ }, SECRET_FIELD));
147
198
  printOutput(result, all);
148
199
  },
149
200
 
@@ -271,6 +322,8 @@ const commands = {
271
322
  // Chrome does not draw a tab that is not in front, and tabs open in the
272
323
  // background, so the tab is brought to the front for the shot.
273
324
  await state.page.bringToFront();
325
+ // A tab brought to the front before can be drawn at the window's size since its last navigation.
326
+ await reapplyMetrics(state.page);
274
327
  let image;
275
328
  const device = tabState(state.page).emulation?.device;
276
329
  try {
@@ -278,7 +331,7 @@ const commands = {
278
331
  image = await deviceShot(device, { ref, full, type });
279
332
  } else if (ref) {
280
333
  const selector = toSelector(ref);
281
- image = await onElement(selector, () => state.page.locator(selector).screenshot({ type, timeout: 5000 }));
334
+ image = await onElement(selector, () => elementShot(state.page.locator(selector), type));
282
335
  } else {
283
336
  image = await state.page.screenshot({ fullPage: full, type });
284
337
  }
@@ -330,6 +383,7 @@ const commands = {
330
383
  } else {
331
384
  raw = await state.page.ariaSnapshot({ mode: 'ai', timeout: 5000 });
332
385
  }
386
+ raw = await hideSecrets(state.page, raw);
333
387
  const text = full ? raw : compactSnapshot(raw);
334
388
  if (needle !== null) {
335
389
  const hits = grepSnapshot(text, needle);
@@ -424,6 +478,11 @@ const commands = {
424
478
  const value = await withTimeout(state.page.evaluate(expression), 'Page evaluation', COMMAND_TIMEOUT);
425
479
  // An empty string is shown as one, not as no output at all.
426
480
  printOutput(value === '' ? '""' : value, all);
481
+ // Quotes around the whole of it, as a shell's quoting sent on, make it a string, not code to run:
482
+ // one plain literal only, not "a" + b or a template with ${...} in it, which ran.
483
+ if (typeof value === 'string' && value && /^(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`)$/s.test(args.trim())) {
484
+ out.log('(That is a string: the JavaScript was in quotes, which eval keeps. Without them, it runs: eval document.title)');
485
+ }
427
486
  },
428
487
 
429
488
  async cdp(args, all) {
package/lib/modes.js CHANGED
@@ -9,8 +9,9 @@ const { emulatedKinds, setEmulation, setViewport } = require('./emulation');
9
9
  const { recordingOf, finishRecording } = require('./record');
10
10
  const { currentCapture, endCapture } = require('./capture');
11
11
  const { nextTabWanted, waitingText, stopWaitingForNextTab, stopWatching } = require('./watch');
12
- const { hideHighlights } = require('./elements');
12
+ const { hideHighlights, setLabels } = require('./elements');
13
13
  const { cursorOf, cursorOff } = require('./cursor');
14
+ const { toastOf, toastOff } = require('./toast');
14
15
 
15
16
  // The modes turned on in a tab, in the order the prompt shows them.
16
17
  function activeModes(p) {
@@ -26,7 +27,9 @@ function activeModes(p) {
26
27
  if (routes) modes.push(`routes:${routes}`);
27
28
  const highlights = tabState(p).highlights?.size;
28
29
  if (highlights) modes.push(`highlight:${highlights}`);
30
+ if (tabState(p).labelsOn) modes.push('labels');
29
31
  if (cursorOf(p)) modes.push('cursor');
32
+ if (toastOf(p)) modes.push('toast');
30
33
  if (currentCapture()?.page === p) modes.push('capture');
31
34
  if (recordingOf(p)) modes.push('record');
32
35
  return modes;
@@ -42,7 +45,7 @@ function listModes() {
42
45
  if (!on.length) {
43
46
  out.log('No modes are on in any tab.');
44
47
  out.log(hints([
45
- ['watch on', 'record what the person at the browser does'],
48
+ ['watch on', 'record what the user at the browser does'],
46
49
  ['capture on', 'record requests and console messages together'],
47
50
  ['route <url-glob> <status> <json-body>', 'fake a response'],
48
51
  ['network off', 'cut the tab\'s network'],
@@ -76,8 +79,12 @@ function modeOwners(p) {
76
79
  if (viewport) owners.push([viewport.owner ?? null, 'viewport']);
77
80
  for (const [glob, route] of tabState(p).routes || []) owners.push([route.owner ?? null, `route ${glob}`]);
78
81
  for (const [selector, h] of tabState(p).highlights || []) owners.push([h.owner ?? null, `highlight ${selector}`]);
82
+ const labels = tabState(p).labelsOn;
83
+ if (labels) owners.push([labels.owner ?? null, 'labels']);
79
84
  const cursor = cursorOf(p);
80
85
  if (cursor) owners.push([cursor.owner ?? null, 'cursor']);
86
+ const toast = toastOf(p);
87
+ if (toast) owners.push([toast.owner ?? null, 'toast']);
81
88
  const capture = currentCapture();
82
89
  if (capture?.page === p) owners.push([capture.owner ?? null, 'capture']);
83
90
  const recording = recordingOf(p);
@@ -114,7 +121,9 @@ async function allModesOff(mine = false) {
114
121
  if ('network' in tabState(p) && ours(tabState(p).network.owner)) { await setNetwork(p, null); done.push('network on'); }
115
122
  const hidden = await hideHighlights(p, mine);
116
123
  if (hidden) done.push(`${hidden} highlight${hidden === 1 ? '' : 's'} hidden`);
124
+ if (tabState(p).labelsOn && ours(tabState(p).labelsOn.owner)) { await setLabels(p, false); done.push('labels off'); }
117
125
  if (cursorOf(p) && ours(cursorOf(p).owner) && await cursorOff(p)) done.push('cursor off');
126
+ if (toastOf(p) && ours(toastOf(p).owner) && await toastOff(p)) done.push('toast off');
118
127
  // Before the page's size is reset, which the recording would otherwise end with.
119
128
  const recording = recordingOf(p);
120
129
  if (recording && ours(recording.owner)) done.push(`record off (${(await finishRecording(recording)).replace(/\n/g, '; ')})`);
package/lib/output.js CHANGED
@@ -58,7 +58,7 @@ function notice(text, page = null) {
58
58
  else log(text);
59
59
  }
60
60
 
61
- // Like notice, but never part of a command's answer: for what the person at
61
+ // Like notice, but never part of a command's answer: for what the user at
62
62
  // the browser does, which is not the output of the command running now.
63
63
  function aside(text) {
64
64
  if (!sink && idlePrinter && idlePrinter.idle()) idlePrinter.print(String(text));
package/lib/overlay.js ADDED
@@ -0,0 +1,79 @@
1
+ // What the REPL draws over a page (the cursor, a toast), each as a layer of one element of its own.
2
+ const { tabState } = require('./tabstate');
3
+
4
+ // Runs in the page. An element under <html>, not <body>, which frameworks replace, with a closed shadow
5
+ // root, so the page's selectors, the snapshot and text do not find what is in it. It takes no pointer
6
+ // events, so what is under it is clicked, and it is built with the DOM and inline styles, which a page's
7
+ // Trusted Types or style-src rules do not block. It is shown as a popover, in the top layer, above the
8
+ // page's own dialogs, as Playwright's highlight is. Kept on the document under a symbol, so each call
9
+ // finds it again, and one the page removed is drawn again. Each layer is a box over the whole viewport,
10
+ // in a fixed order (the cursor above a toast), whatever order they were drawn in; the element goes with
11
+ // its last layer.
12
+ function pageOverlay(create) {
13
+ const KEY = Symbol.for('pw-repl-overlay');
14
+ const ORDER = ['toast', 'cursor'];
15
+ // Important on the element in the page, whose own rules could reach it; inside, the page's cannot, and
16
+ // an important transform would win over an animation.
17
+ const set = (el, styles, important = '') => { for (const [name, value] of Object.entries(styles)) el.style.setProperty(name, value, important); };
18
+ let overlay = document[KEY];
19
+ if (!overlay || !overlay.host.isConnected) {
20
+ if (!create) return null;
21
+ const host = document.createElement('pw-repl-overlay');
22
+ host.setAttribute('aria-hidden', 'true');
23
+ host.setAttribute('popover', 'manual');
24
+ set(host, {
25
+ display: 'block', position: 'fixed', inset: '0', width: '100%', height: '100%', 'max-width': 'none',
26
+ 'max-height': 'none', margin: '0', padding: '0', border: 'none', background: 'transparent',
27
+ overflow: 'visible', 'pointer-events': 'none', 'z-index': '2147483647',
28
+ }, 'important');
29
+ const root = host.attachShadow({ mode: 'closed' });
30
+ document.documentElement.append(host);
31
+ const layers = {};
32
+ overlay = document[KEY] = {
33
+ host,
34
+ set,
35
+ // A layer's box, made the first time it is asked for.
36
+ layer(name) {
37
+ if (layers[name]) return layers[name];
38
+ const box = document.createElement('div');
39
+ set(box, { position: 'absolute', inset: '0', overflow: 'visible' });
40
+ const after = ORDER.slice(ORDER.indexOf(name) + 1).map(n => layers[n]).find(Boolean);
41
+ root.insertBefore(box, after || null);
42
+ return (layers[name] = box);
43
+ },
44
+ has: name => !!layers[name],
45
+ drop(name) {
46
+ layers[name]?.remove();
47
+ delete layers[name];
48
+ if (Object.keys(layers).length) return;
49
+ host.remove();
50
+ delete document[KEY];
51
+ },
52
+ };
53
+ }
54
+ // Shown again, so it is above a dialog the page opened since.
55
+ try { overlay.host.hidePopover(); overlay.host.showPopover(); } catch {}
56
+ return overlay;
57
+ }
58
+
59
+ // Runs fn(overlay, args) in the page, fn a function of its own that uses nothing from here. A string,
60
+ // so the two functions go to the page as one call; evaluated over CDP, it is not the page's own eval,
61
+ // which a page's script-src may forbid. With create false, nothing is drawn if the overlay is not there.
62
+ function inOverlay(p, fn, args = null, create = true) {
63
+ return p.evaluate(`(() => { const overlay = (${pageOverlay})(${create}); return overlay ? (${fn})(overlay, ${JSON.stringify(args)}) : undefined; })()`);
64
+ }
65
+
66
+ // A new document drops the overlay: each layer kept on the tab is drawn again by its redraw.
67
+ function keepAcrossPages(p, name, redraw) {
68
+ if (!tabState(p).overlayKept) {
69
+ tabState(p).overlayKept = new Map();
70
+ p.on('domcontentloaded', () => { for (const again of tabState(p).overlayKept.values()) again().catch(() => {}); });
71
+ }
72
+ tabState(p).overlayKept.set(name, redraw);
73
+ }
74
+
75
+ function forget(p, name) {
76
+ tabState(p).overlayKept?.delete(name);
77
+ }
78
+
79
+ module.exports = { inOverlay, keepAcrossPages, forget };
package/lib/record.js CHANGED
@@ -8,8 +8,7 @@ const out = require('./output');
8
8
  const { tabState } = require('./tabstate');
9
9
  const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
10
10
  const { shellWords, splitSelector } = require('./syntax');
11
- const { keptSession } = require('./cdp');
12
- const { deviceMetrics, viewportMetrics, settled } = require('./emulation');
11
+ const { reapplyMetrics } = require('./emulation');
13
12
 
14
13
  const FPS = 25;
15
14
  const MAX_SECONDS = 3600;
@@ -134,13 +133,7 @@ function recordingOf(p) {
134
133
 
135
134
  async function restoreMetrics(p, rec) {
136
135
  if (rec?.finishing) return;
137
- const tab = tabState(p);
138
- if (!tab.viewport && !tab.emulation?.device) return;
139
- const kept = await keptSession(p);
140
- if (rec?.finishing) return;
141
- if (tab.viewport) await viewportMetrics(kept, tab.viewport);
142
- else if (tab.emulation?.device) await deviceMetrics(kept, tab.emulation.device);
143
- await settled(p);
136
+ await reapplyMetrics(p);
144
137
  }
145
138
 
146
139
  // The tabs recording now, for the modes they count as.
@@ -408,6 +401,8 @@ function stepLabel(cmd, args) {
408
401
  const parsed = /^mouse/.test(cmd) ? null : splitSelector(words);
409
402
  return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
410
403
  }
404
+ // A toast's text is what it said in the video, for whoever edits it.
405
+ if (cmd === 'toast') return `toast ${args.trim()}`.trim();
411
406
  if (!TARGET_FIRST.has(cmd)) return cmd;
412
407
  const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
413
408
  const parsed = splitSelector(rest);
@@ -455,6 +450,13 @@ function cursorStep(p, what, point, ms) {
455
450
  rec.steps.push({ rec, t0, t1: t0 + ms / 1000, command: what.replace(/-?\d+(?:\.\d+)?/g, n => String(Math.round(Number(n) * rec.scale))), box: null, point: { x: scaled, y: Math.round(point.y * rec.scale) }, offSize: rec.resizedAt !== null, failed: false });
456
451
  }
457
452
 
453
+ // Run by record off before it ends a recording, each with the recorded tab: for what the video must
454
+ // still show first. Registered by the modules that know, which record cannot require.
455
+ const beforeEnding = [];
456
+ function beforeRecordingEnds(wait) {
457
+ beforeEnding.push(wait);
458
+ }
459
+
458
460
  // The pause after an action on a recording tab, for its result to be seen (record on --pause).
459
461
  const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
460
462
  async function pauseAfter(p, cmd) {
@@ -623,7 +625,9 @@ const commands = {
623
625
  // Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
624
626
  const other = (rec.owner ?? null) !== (state.client ?? null);
625
627
  const clientName = c => c ?? 'the unnamed client';
626
- // The tail: still page after the last action. Only record off has one; the seconds limit is a hard stop.
628
+ // What must be seen before the end (a toast's read time), then the tail: still page after the last
629
+ // action. Only record off has them; the seconds limit is a hard stop.
630
+ if (!rec.finishing) for (const wait of beforeEnding) await wait(state.page);
627
631
  if (!rec.finishing) await sleep(rec.pacing.tail);
628
632
  const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
629
633
  if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
@@ -688,4 +692,4 @@ const commands = {
688
692
  },
689
693
  };
690
694
 
691
- module.exports = { commands, recordingOf, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
695
+ module.exports = { commands, recordingOf, beforeRecordingEnds, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
package/lib/runner.js CHANGED
@@ -9,6 +9,7 @@ const { activeModes } = require('./modes');
9
9
  const { NO_TAB } = require('./util');
10
10
  const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
11
11
  const cliNames = require('./cli-names');
12
+ const { expandVariables } = require('./syntax');
12
13
 
13
14
  // playwright-cli names already explained, per client: each client is told once.
14
15
  const explained = new Set();
@@ -186,8 +187,14 @@ function enqueue(line) {
186
187
  // Server commands share the prompt's queue and are echoed to the pane, so
187
188
  // someone watching sees everything an agent does.
188
189
  // client names the sender: its commands show as [server:<client>] and use its own selected tab.
189
- function submit(text, client = null) {
190
- if (DIALOG.test(text)) return answerDialog(text, client);
190
+ // vars: values for the {{ PW_NAME }} variables in text (from send), filled in to run it; text is shown
191
+ // as written, so a password in one stays out of the pane, the log and the prompt's history.
192
+ function submit(text, client = null, vars = null) {
193
+ let run = text;
194
+ if (vars) {
195
+ try { run = expandVariables(text, vars); } catch (error) { return Promise.resolve({ status: 'error', output: `Error: ${error.message}` }); }
196
+ }
197
+ if (DIALOG.test(run)) return answerDialog(text, client, run);
191
198
  if (CDP_ANSWER.test(text)) {
192
199
  console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
193
200
  return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
@@ -195,11 +202,11 @@ function submit(text, client = null) {
195
202
  return new Promise(resolve => {
196
203
  queue = queue.then(async () => {
197
204
  if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
198
- // Printed above whatever the person at the prompt is typing, which is
205
+ // Printed above whatever the user at the prompt is typing, which is
199
206
  // redrawn afterwards rather than broken up.
200
207
  if (process.stdout.isTTY) { readline.clearLine(process.stdout, 0); readline.cursorTo(process.stdout, 0); }
201
208
  out.log(`[server${client ? `:${client}` : ''}] ${text}`);
202
- // So the person at the prompt can bring an agent's command back with up-arrow.
209
+ // So the user at the prompt can bring an agent's command back with up-arrow.
203
210
  if (state.rl?.history && state.rl.history[0] !== text) {
204
211
  state.rl.history.unshift(text);
205
212
  state.rl.history.length = Math.min(state.rl.history.length, 1000);
@@ -211,7 +218,7 @@ function submit(text, client = null) {
211
218
  let result;
212
219
  const flight = { text, resolve, answered: false };
213
220
  inFlight = flight;
214
- const output = await out.collect(async () => { result = await execute(text, client); });
221
+ const output = await out.collect(async () => { result = await execute(run, client); });
215
222
  if (inFlight === flight) inFlight = null;
216
223
  if (!flight.answered) resolve({ status: result.status, output, uncertain: result.uncertain });
217
224
  }
@@ -222,12 +229,12 @@ function submit(text, client = null) {
222
229
 
223
230
  // Outside the queue, and so outside out.collect, whose single sink belongs to
224
231
  // the command that is waiting on the dialog.
225
- async function answerDialog(text, client) {
232
+ async function answerDialog(text, client, run = text) {
226
233
  console.log(`[server${client ? `:${client}` : ''}] ${text}`);
227
- const translated = cliNames.translate(text);
228
- const note = translated && explain(text, translated, client);
234
+ const translated = cliNames.translate(run);
235
+ const note = translated && explain(run, translated, client);
229
236
  let result;
230
- try { result = { status: 'ok', output: await dialogCommand((translated?.text || text).slice(6), clientRecord(client).page) }; }
237
+ try { result = { status: 'ok', output: await dialogCommand((translated?.text || run).slice(6), clientRecord(client).page) }; }
231
238
  catch (error) { result = { status: 'error', output: `Error: ${error.message}` }; }
232
239
  if (note) result.output = `${note}\n${result.output}`;
233
240
  console.log(result.output);
package/lib/send.js CHANGED
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const crypto = require('crypto');
7
7
  const { execFileSync } = require('child_process');
8
8
  const client = require('./client');
9
- const { shellWords } = require('./syntax');
9
+ const { shellWords, variablesIn } = require('./syntax');
10
10
 
11
11
  // The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
12
12
  const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
@@ -113,6 +113,8 @@ function commandLine(words) {
113
113
  return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
114
114
  });
115
115
  }
116
+ // watch save writes its file.
117
+ if (words[0] === 'watch' && words[1] === 'save' && words.length > 2) resolved = ['watch', 'save', resolve(words.slice(2).join(' '))];
116
118
  if (words[0] === 'screenshot') {
117
119
  resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
118
120
  }
@@ -128,7 +130,18 @@ function commandLine(words) {
128
130
 
129
131
  // A file of commands, one per line, run in turn until one fails. A recording it started and did not
130
132
  // stop is still on: the next take would fail to start, so it says so.
131
- const PATHS = /^(?:upload|record|video-start|video-stop|screenshot)$/;
133
+ const PATHS = /^(?:upload|record|video-start|video-stop|screenshot|watch)$/;
134
+ // The values of the {{ PW_NAME }} variables in a command, from send's environment: { vars }, null with
135
+ // none, or { error } for one that is not set or has a line break.
136
+ function variablesFor(command) {
137
+ const names = variablesIn(command);
138
+ const unset = names.filter(name => process.env[name] === undefined);
139
+ const broken = names.filter(name => /[\r\n]/.test(process.env[name] || ''));
140
+ if (unset.length) return { error: `${unset.join(', ')} ${unset.length === 1 ? 'is' : 'are'} not set; set ${unset.length === 1 ? 'it' : 'them'} for send (${unset[0]}=... pw-repl send ...), or --no-vars sends {{ ${unset[0]} }} as it is` };
141
+ if (broken.length) return { error: `${broken.join(', ')} ${broken.length === 1 ? 'has' : 'have'} a line break, which a line cannot hold` };
142
+ return names.length ? { vars: Object.fromEntries(names.map(name => [name, process.env[name]])) } : null;
143
+ }
144
+
132
145
  async function sendFile(options) {
133
146
  let text;
134
147
  try { text = fs.readFileSync(options.file, 'utf8'); } catch (error) { return fail(`cannot read ${options.file} (${error.code || error.message})`); }
@@ -141,7 +154,9 @@ async function sendFile(options) {
141
154
  // Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
142
155
  const words = shellWords(line);
143
156
  const command = PATHS.test(words[0]) ? commandLine(words) : line;
144
- const status = await send({ ...options, file: null, command });
157
+ const found = options.noVars ? null : variablesFor(command);
158
+ if (found?.error) { console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}): ${found.error}`); return 1; }
159
+ const status = await send({ ...options, file: null, command, vars: found?.vars || null });
145
160
  if (status !== 0) {
146
161
  console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}), exit status ${status}`);
147
162
  if (recording) console.error('pw-repl: record off was not reached, so the recording is still on; pw-repl send record off ends it');
@@ -159,14 +174,22 @@ async function send(options) {
159
174
  if (!command) return fail('no command given');
160
175
  // A newline would reach the REPL as a second, unmarked command.
161
176
  if (/[\r\n]/.test(command)) return fail('the command must be a single line');
177
+ // A command of its own, not a file's line, which sendFile has read its variables for already.
178
+ if (options.vars === undefined && !options.noVars) {
179
+ const found = variablesFor(command);
180
+ if (found?.error) return fail(found.error);
181
+ options = { ...options, vars: found?.vars || null };
182
+ }
162
183
  const timeoutMs = options.timeout * 1000;
163
184
  const way = route(options);
164
185
  const name = options.client || process.env.PW_CLIENT || null;
165
186
  if (name && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,39}$/.test(name)) return fail('a client name is 1-40 letters, digits, dots, dashes and underscores');
166
187
  // The pane has one prompt, so a command typed there has no client of its own.
167
188
  if (way.kind === 'tmux' && options.client) return fail('-c needs the command server (pw-repl serve); a REPL in tmux has no clients');
189
+ // Typed into the pane, a variable's value would be on screen and in its scrollback.
190
+ if (way.kind === 'tmux' && options.vars) return fail(`{{ ${Object.keys(options.vars)[0]} }} needs the command server (pw-repl serve), which fills it in without showing it; --no-vars sends it as it is`);
168
191
  if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
169
- const answer = await client.request(way.endpoint, command, timeoutMs, name);
192
+ const answer = await client.request(way.endpoint, command, timeoutMs, name, options.vars || null);
170
193
  if (answer.result?.starting) return fail(`${answer.result.output} (pw-repl where says when it serves)`);
171
194
  if (answer.result) {
172
195
  if (answer.result.output) console.log(answer.result.output);
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 --file fills it in 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 };