pw-repl 0.7.0 → 0.8.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/record.js CHANGED
@@ -6,7 +6,8 @@ const path = require('path');
6
6
  const { state, onShutdown } = require('./state');
7
7
  const out = require('./output');
8
8
  const { tabState } = require('./tabstate');
9
- const { clock, NO_TAB } = require('./util');
9
+ const { clock, NO_TAB, STOPPED_RECORDING, ENDED_UNSEEN } = require('./util');
10
+ const { shellWords, splitSelector } = require('./syntax');
10
11
 
11
12
  const FPS = 25;
12
13
  const MAX_SECONDS = 3600;
@@ -27,10 +28,11 @@ const FORMATS = {
27
28
  mp4: { encoder: 'libx264', args: ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-threads', '1', '-g', String(FPS * 2), '-movflags', '+frag_keyframe+empty_moov+default_base_moof'] },
28
29
  };
29
30
 
30
- // PW_FFMPEG, then Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
31
+ const FORMAT_NAMES = { webm: 'WebM (VP8)', mp4: 'MP4 (H.264)' };
32
+
33
+ // Playwright's own (wherever PLAYWRIGHT_BROWSERS_PATH puts it), then one on the PATH.
31
34
  function ffmpegCandidates() {
32
35
  const found = [];
33
- if (process.env.PW_FFMPEG) found.push(process.env.PW_FFMPEG);
34
36
  const dir = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(os.homedir(), '.cache', 'ms-playwright');
35
37
  let names = [];
36
38
  try { names = fs.readdirSync(dir).filter(n => /^ffmpeg-\d+$/.test(n)).sort().reverse(); } catch {}
@@ -49,20 +51,43 @@ function ffmpegCandidates() {
49
51
 
50
52
  const encoderLists = new Map();
51
53
 
52
- function hasEncoder(exe, encoder) {
53
- if (!encoderLists.has(exe)) {
54
- let list = '';
55
- try { list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }); } catch {}
54
+ // What an ffmpeg's -encoders lists, or why it could not say. Only a list is kept: a probe that
55
+ // failed, as one slowed past its timeout on a busy machine can, is tried again the next time.
56
+ function encoderList(exe) {
57
+ if (encoderLists.has(exe)) return { list: encoderLists.get(exe) };
58
+ try {
59
+ const list = execFileSync(exe, ['-hide_banner', '-encoders'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
56
60
  encoderLists.set(exe, list);
61
+ return { list };
62
+ } catch (error) {
63
+ if (error.code === 'ENOENT') return { error: 'not found' };
64
+ if (error.code === 'EACCES') return { error: 'not executable' };
65
+ if (error.code === 'ETIMEDOUT') return { error: '-encoders did not answer within 5s' };
66
+ return { error: `-encoders failed (${error.status !== null && error.status !== undefined ? `exit ${error.status}` : error.signal || error.message})` };
57
67
  }
58
- return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(encoderLists.get(exe));
59
68
  }
60
69
 
61
- // The first ffmpeg that can write the format.
70
+ function hasEncoder(list, encoder) {
71
+ return new RegExp(`^\\s*V\\S*\\s+${encoder}\\s`, 'm').test(list || '');
72
+ }
73
+
74
+ // PW_FFMPEG when it is set, and never another in its place; otherwise the first ffmpeg that can
75
+ // write the format.
62
76
  function findFfmpeg(format) {
77
+ const { encoder } = FORMATS[format];
78
+ const named = process.env.PW_FFMPEG;
79
+ if (named) {
80
+ const { list, error } = encoderList(named);
81
+ if (error) throw new Error(`PW_FFMPEG=${named} cannot be used: ${error}`);
82
+ if (!hasEncoder(list, encoder)) throw new Error(`PW_FFMPEG=${named} cannot write ${FORMAT_NAMES[format]}: its -encoders lists no ${encoder}`);
83
+ return named;
84
+ }
63
85
  const candidates = ffmpegCandidates();
64
- if (!candidates.length) throw new Error(`record needs ffmpeg, and none was found: ${INSTALL}; PW_FFMPEG names one`);
65
- const exe = candidates.find(c => hasEncoder(c, FORMATS[format].encoder));
86
+ if (!candidates.length) {
87
+ const how = format === 'mp4' ? 'an .mp4 needs ffmpeg from your system\'s packages, with H.264 (Playwright\'s writes only WebM)' : INSTALL;
88
+ throw new Error(`record needs ffmpeg, and none was found: ${how}; PW_FFMPEG names one`);
89
+ }
90
+ const exe = candidates.find(c => hasEncoder(encoderList(c).list, encoder));
66
91
  if (exe) return exe;
67
92
  if (format === 'mp4') throw new Error(`No ffmpeg found can write MP4 (H.264): Playwright's writes only WebM. Install ffmpeg from your system's packages, or name one with PW_FFMPEG; or record to a .webm file`);
68
93
  throw new Error(`No ffmpeg found can write WebM (VP8): ${INSTALL}; PW_FFMPEG names one`);
@@ -117,15 +142,19 @@ async function startRecording(p, file, seconds, format) {
117
142
  const rec = {
118
143
  page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
119
144
  size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
120
- ffmpegError: '',
145
+ ffmpegError: '', starting: true, scale: 1, steps: [], step: null, stepsFile: null,
121
146
  };
122
147
  // The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
123
148
  // its device pixels), and Chrome scales each frame to fit it. Not the first frame's: just after a
124
- // reload or a change of emulation, that can still be the window's.
149
+ // reload or a change of emulation, that can still be the window's. Its scrollbars too, as the
150
+ // frames have them (innerWidth has them; the viewport's clientWidth does not).
125
151
  const even = n => Math.max(2, Math.round(n) - (Math.round(n) % 2));
126
152
  try {
127
153
  const { cssVisualViewport: vv } = await session.send('Page.getLayoutMetrics');
128
- rec.size = { width: even(vv.clientWidth * (vv.scale || 1)), height: even(vv.clientHeight * (vv.scale || 1)) };
154
+ const { result } = await session.send('Runtime.evaluate', { expression: '[innerWidth, innerHeight]', returnByValue: true });
155
+ const [width, height] = result.value;
156
+ rec.scale = vv.scale || 1;
157
+ rec.size = { width: even(width * rec.scale), height: even(height * rec.scale) };
129
158
  } catch (error) {
130
159
  session.detach().catch(() => {});
131
160
  throw error;
@@ -134,7 +163,7 @@ async function startRecording(p, file, seconds, format) {
134
163
  let firstFrame;
135
164
  const gotFirstFrame = new Promise(resolve => { firstFrame = resolve; });
136
165
  const tick = () => {
137
- if (!rec.latest || !rec.ff || rec.finishing) return;
166
+ if (!rec.latest || !rec.ff || rec.ffExited || rec.finishing) return;
138
167
  // The video keeps the wall clock: Chrome sends a frame only when the page redraws,
139
168
  // so the latest one is repeated while the page is still.
140
169
  const due = Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS);
@@ -178,8 +207,19 @@ async function startRecording(p, file, seconds, format) {
178
207
  rec.limit = setTimeout(() => {
179
208
  finishRecording(rec, `It reached its ${seconds}s and stopped`).then(text => out.notice(text, p));
180
209
  }, seconds * 1000);
181
- rec.onClose = () => finishRecording(rec, 'Its tab closed, which ended it').then(text => out.notice(text, p));
210
+ rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
182
211
  p.once('close', rec.onClose);
212
+ // A new page, loaded or routed to by a single-page app, is a step of its own: a zoom that follows the
213
+ // cursor ends there. Its URL is left out, as a step's values are.
214
+ rec.onNavigated = frame => {
215
+ if (frame !== p.mainFrame() || rec.finishing) return;
216
+ const t = videoTime(rec);
217
+ // An app that sets its URL twice in a row (push, then replace) is one new page.
218
+ const last = rec.steps[rec.steps.length - 1];
219
+ if (last?.command === '(page)' && t - last.t0 < 0.5) return;
220
+ rec.steps.push({ rec, t0: t, t1: t, command: '(page)', box: null, point: null, offSize: false, failed: false });
221
+ };
222
+ p.on('framenavigated', rec.onNavigated);
183
223
  return rec;
184
224
  }
185
225
 
@@ -195,10 +235,24 @@ function spawnFfmpeg(rec) {
195
235
  // A write after ffmpeg has gone is reported when the recording finishes, not thrown.
196
236
  ff.stdin.on('error', () => {});
197
237
  ff.on('error', error => { rec.ffmpegError += error.message; });
198
- rec.exited = new Promise(resolve => ff.once('close', code => resolve(code)));
238
+ rec.exited = new Promise(resolve => ff.once('close', (code, signal) => {
239
+ rec.ffExited = true;
240
+ resolve(code ?? signal);
241
+ // ffmpeg gone before record off ends the recording now, so no one goes on thinking it records;
242
+ // record on, still starting it, says so itself.
243
+ if (!rec.finishing) {
244
+ rec.exitedEarly = true;
245
+ finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
246
+ }
247
+ }));
199
248
  return ff;
200
249
  }
201
250
 
251
+ // An exit code, or the signal that ended ffmpeg.
252
+ function exitText(code) {
253
+ return typeof code === 'string' ? `was killed by ${code}` : `exited with ${code}`;
254
+ }
255
+
202
256
  // Stops the screencast, lets ffmpeg finish the file, and says what was saved. Once per recording.
203
257
  function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
204
258
  if (rec.finishing) return rec.finishing;
@@ -207,10 +261,13 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
207
261
  rec.finishing = (async () => {
208
262
  const result = await finishFile(rec, shuttingDown);
209
263
  const text = typeof result === 'string' ? result : result.text;
210
- // A recording that saved nothing makes record off fail, rather than report it as done.
211
- rec.failed = typeof result !== 'string';
264
+ // A recording that saved nothing, or that ffmpeg cut short, makes record off fail rather than
265
+ // report it as done.
266
+ rec.failed = typeof result !== 'string' || !!rec.exitedEarly;
212
267
  const said = why ? `${why}. ${text}` : text;
213
- lastEnded.set(owner, { text: said, at: stoppedAt });
268
+ // A failure is kept unreported until record off (or record on) has failed with it. One that
269
+ // ended by itself is unseen until record shows it, and the REPL stopping logs it for stop.
270
+ lastEnded.set(owner, { text: said, at: stoppedAt, failed: rec.failed, reported: false, seen: !why || shuttingDown });
214
271
  return said;
215
272
  })();
216
273
  const pending = saving.get(owner) || new Set();
@@ -222,9 +279,10 @@ function finishRecording(rec, why = null, { shuttingDown = false } = {}) {
222
279
  async function finishFile(rec, shuttingDown) {
223
280
  clearTimeout(rec.limit);
224
281
  if (rec.onClose) rec.page.off('close', rec.onClose);
282
+ if (rec.onNavigated) rec.page.off('framenavigated', rec.onNavigated);
225
283
  if (tabState(rec.page).recording === rec) delete tabState(rec.page).recording;
226
284
  // The last frame runs to now, and a recording has one frame at least.
227
- if (rec.ff && rec.latest) {
285
+ if (rec.ff && rec.latest && !rec.ffExited) {
228
286
  const due = Math.max(1, Math.floor(((Date.now() - rec.firstFrameAt) / 1000) * FPS));
229
287
  for (; rec.written < due; rec.written++) rec.ff.stdin.write(rec.latest);
230
288
  }
@@ -256,20 +314,128 @@ async function finishFile(rec, shuttingDown) {
256
314
  const saved = fs.existsSync(rec.file) ? fs.statSync(rec.file).size : 0;
257
315
  if (!saved) {
258
316
  fs.rmSync(rec.file, { force: true });
259
- const why = cut ? 'it stopped making progress and was stopped' : `it exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
317
+ const why = cut ? 'it stopped making progress and was stopped' : `it ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ', saying nothing'}`;
260
318
  return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
261
319
  }
262
320
  const notes = [];
263
321
  if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
264
322
  if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
265
- if (cut) notes.push(`ffmpeg ${shuttingDown ? `did not finish within ${SHUTDOWN_FINISH_TIMEOUT / 1000}s as the REPL stopped` : 'stopped making progress'} and was stopped, so the file is likely cut short of the ${seconds.toFixed(1)}s recorded`);
266
- else if (code !== 0) notes.push(`ffmpeg exited with ${code}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
267
- return `Saved: ${rec.file} (${seconds.toFixed(1)}s, ${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}`;
323
+ // An ffmpeg stopped before it finished leaves only what it had written, which it writes in
324
+ // blocks: seconds of video short of the frames it was sent, and no length in the file's header.
325
+ const short = `so the file holds only what it had written, short of the ${seconds.toFixed(1)}s recorded, and may not show its length or seek`;
326
+ if (cut) notes.push(`ffmpeg ${shuttingDown ? `did not finish within ${SHUTDOWN_FINISH_TIMEOUT / 1000}s as the REPL stopped` : 'stopped making progress'} and was stopped, ${short}`);
327
+ else if (rec.exitedEarly) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? ` (${rec.ffmpegError.trim()})` : ''}, ${short}`);
328
+ else if (code !== 0) notes.push(`ffmpeg ${exitText(code)}${rec.ffmpegError ? `: ${rec.ffmpegError.trim()}` : ''}`);
329
+ const length = cut || rec.exitedEarly ? '' : `${seconds.toFixed(1)}s, `;
330
+ const steps = writeSteps(rec, seconds);
331
+ const stepsLine = !steps ? '' : steps.error ? `\nIts steps were not saved: ${steps.error}` : `\nSteps: ${steps.file} (${steps.count}; when each command ran, and where)`;
332
+ return `Saved: ${rec.file} (${length}${rec.size.width}x${rec.size.height}, ${sizeText(saved)})${notes.length ? `\n${notes.join('; ')}` : ''}${stepsLine}`;
333
+ }
334
+
335
+ // The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
336
+ // seconds, and the element or point it acted on in the video's pixels, saved next to the video for
337
+ // whoever edits it (a zoom on a step, say). Only that: watch is the log of what happened.
338
+
339
+ // The video's clock: frames are written against the wall clock since the first, less those dropped.
340
+ function videoTime(rec) {
341
+ return rec.firstFrameAt === null ? 0 : Math.max(0, (Date.now() - rec.firstFrameAt) / 1000 - rec.dropped / FPS);
342
+ }
343
+
344
+ // What a step is, for its line: the command and what it acts on, never what it types or chooses, the
345
+ // code it runs or the URL it goes to, which can hold a password or a token. A steps file goes where the
346
+ // video goes, and the video shows a password as dots.
347
+ const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
348
+ const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
349
+ function stepLabel(cmd, args) {
350
+ if (WHOLE.has(cmd)) return `${cmd} ${args.replace(/\s+--modifiers=\S+/, '')}`.trim();
351
+ if (!TARGET_FIRST.has(cmd)) return cmd;
352
+ const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
353
+ const parsed = splitSelector(rest);
354
+ // type and press with one word act on the focused element: the word is the text or the key.
355
+ return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
356
+ }
357
+
358
+ function stepStarted(p, command) {
359
+ const rec = recordingOf(p);
360
+ if (!rec || rec.finishing) return null;
361
+ // After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
362
+ rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
363
+ rec.steps.push(rec.step);
364
+ return rec.step;
365
+ }
366
+
367
+ function stepEnded(step, failed) {
368
+ if (!step) return;
369
+ step.t1 = videoTime(step.rec);
370
+ step.failed = failed;
371
+ if (step.rec.step === step) step.rec.step = null;
268
372
  }
269
373
 
270
- // Ends every recording, as the REPL stops, and says in the pane and the log what each saved.
374
+ // The element (box) or point the running step acts on, in CSS pixels.
375
+ function stepTarget(p, box, point) {
376
+ const rec = recordingOf(p);
377
+ if (!rec?.step) return;
378
+ const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
379
+ rec.step.box = scaled(box);
380
+ rec.step.point = scaled(point);
381
+ }
382
+
383
+ // Where the cursor went (cursor on), and when: a line of its own, not a command's step.
384
+ function cursorStep(p, what, point, ms) {
385
+ const rec = recordingOf(p);
386
+ if (!rec || rec.finishing) return;
387
+ const t0 = videoTime(rec);
388
+ const scaled = Math.round(point.x * rec.scale);
389
+ 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 });
390
+ }
391
+
392
+ function stepsPath(file) {
393
+ return file.replace(/\.[^./]+$/, '') + '.steps.txt';
394
+ }
395
+
396
+ // One line a step: t0 t1, then x y w h of its element (a point is x y 0 0; none, or not in the video's
397
+ // pixels, is - - - -), then the command. Plain words, for a shell script to read.
398
+ function writeSteps(rec, seconds) {
399
+ if (!rec.steps.length) return null;
400
+ const file = stepsPath(rec.file);
401
+ const lines = [
402
+ `# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
403
+ '# t0 t1: when the command began and ended, in seconds into the video',
404
+ '# x y w h: its element in the video\'s pixels (a point: w and h are 0; none: -)',
405
+ '# (cursor from X Y): the cursor (cursor on) glided from X Y to x y between t0 and t1',
406
+ '# (page): the tab went to a new page, loaded or routed to by a single-page app',
407
+ ...(rec.resizedAt !== null ? [`# the page changed size at ${rec.resizedAt.toFixed(2)}s: boxes after it are left out`] : []),
408
+ ];
409
+ for (const s of rec.steps) {
410
+ const where = s.offSize ? null : s.box || (s.point && { x: s.point.x, y: s.point.y, width: 0, height: 0 });
411
+ const xywh = where ? `${where.x} ${where.y} ${where.width} ${where.height}` : '- - - -';
412
+ lines.push(`${s.t0.toFixed(2)} ${(s.t1 ?? videoTime(rec)).toFixed(2)} ${xywh} ${s.command}${s.failed ? ' # failed' : ''}`);
413
+ }
414
+ try {
415
+ fs.writeFileSync(file, `${lines.join('\n')}\n`, { flag: 'wx', mode: 0o600 });
416
+ } catch (error) {
417
+ return { file, error: error.code === 'EEXIST' ? `${file} already exists` : error.message };
418
+ }
419
+ rec.stepsFile = file;
420
+ return { file, count: rec.steps.length };
421
+ }
422
+
423
+ const TAB_CLOSED = 'Its tab closed, which ended it';
424
+
425
+ // What became of a recording that tab close ended by closing its tab, for it to say.
426
+ async function endedByTabClose(rec) {
427
+ const text = await finishRecording(rec, TAB_CLOSED);
428
+ const last = lastEnded.get(rec.owner ?? null);
429
+ if (last?.text === text) last.seen = true;
430
+ return text;
431
+ }
432
+
433
+ // Ends every recording, as the REPL stops, and says in the pane and the log what each saved, and
434
+ // what became of any that ended by itself that record has not shown since.
271
435
  async function finishAll() {
272
- await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), 'The REPL stopped, which ended a recording', { shuttingDown: true }).then(text => out.log(text), () => {})));
436
+ for (const last of lastEnded.values()) if (!last.seen) out.log(`${ENDED_UNSEEN}: ${last.text.replace(/\n/g, '; ')}`);
437
+ // One line each, for stop to find in the log.
438
+ await Promise.all(recordingPages().map(p => finishRecording(recordingOf(p), STOPPED_RECORDING, { shuttingDown: true }).then(text => out.log(text.replace(/\n/g, '; ')), () => {})));
273
439
  }
274
440
 
275
441
  onShutdown(finishAll);
@@ -278,6 +444,7 @@ function statusText(p) {
278
444
  const rec = recordingOf(p);
279
445
  if (!rec) {
280
446
  const last = lastEnded.get(state.client ?? null);
447
+ if (last) last.seen = true;
281
448
  return `The selected tab is not being recorded; record on starts it.${last ? `\nYour last recording, ended at ${clock(last.at)}: ${last.text}` : ''}`;
282
449
  }
283
450
  const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
@@ -295,11 +462,14 @@ function fileWords(words, usage) {
295
462
  const word = words[i];
296
463
  if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
297
464
  else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
465
+ else if (/^--(?:mp4|webm)$/.test(word)) throw new Error(`record takes no ${word}: the file's ending picks the format, as in record on clip.${word.slice(2)}`);
466
+ // playwright-cli's video-start --size: here the video is the page's size.
467
+ else if (/^--size(?:=|$)/.test(word)) throw new Error(`record takes no --size: the video is the page's size; set it first with viewport <width>x<height>`);
298
468
  else if (word.startsWith('-')) throw new Error(`record takes no ${word}. ${usage}`);
299
469
  else if (/^\d+$/.test(word)) rest.push(word);
300
470
  else files.push(word);
301
471
  }
302
- if (files.length > 1) throw new Error(usage);
472
+ if (files.length > 1) throw new Error(`record takes one file, and ${files.map(f => `"${f}"`).join(' and ')} are two: quote a file name with spaces in it. ${usage}`);
303
473
  return { file: files.length ? path.resolve(files[0]) : null, rest };
304
474
  }
305
475
 
@@ -309,6 +479,7 @@ function checkFile(file, format) {
309
479
  if (format && ext !== format) throw new Error(`This recording is ${format === 'webm' ? 'WebM' : 'MP4'}: name it .${format}, not ${path.basename(file)}`);
310
480
  if (fs.existsSync(file)) throw new Error(`Recording already exists: ${file}`);
311
481
  if (!fs.existsSync(path.dirname(file))) throw new Error(`No folder ${path.dirname(file)} to save ${path.basename(file)} in`);
482
+ try { fs.accessSync(path.dirname(file), fs.constants.W_OK); } catch { throw new Error(`Cannot write in ${path.dirname(file)} to save ${path.basename(file)} there`); }
312
483
  return ext;
313
484
  }
314
485
 
@@ -326,7 +497,8 @@ function moveRecording(from, to) {
326
497
 
327
498
  const commands = {
328
499
  async record(args) {
329
- const words = (args || '').trim().split(/\s+/).filter(Boolean);
500
+ // A file may be quoted, with spaces in it.
501
+ const words = shellWords(args);
330
502
  const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
331
503
  if (!words.length) {
332
504
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
@@ -334,17 +506,23 @@ const commands = {
334
506
  return;
335
507
  }
336
508
  const [what, ...rest] = words;
337
- // record on its own works with no tab selected, as after the recorded tab closed.
338
- if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
339
509
  if (what !== 'on' && what !== 'off') throw new Error(usage);
510
+ // record on its own, and record off, work with no tab selected, as after the recorded tab closed.
511
+ const noTab = !state.page || state.page.isClosed();
512
+ if (noTab && what === 'on') throw new Error(NO_TAB);
340
513
  // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
341
514
  const { file: named, rest: numbers } = fileWords(rest, usage);
342
515
  if (what === 'off') {
343
516
  if (numbers.length) throw new Error(usage);
344
- const rec = recordingOf(state.page);
517
+ const rec = noTab ? null : recordingOf(state.page);
345
518
  if (!rec) {
346
519
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
347
- out.log(statusText(state.page));
520
+ // A recording that ended by itself and saved nothing fails record off, once, as it would
521
+ // have had record off ended it: its exit status is what an agent checks.
522
+ const last = lastEnded.get(state.client ?? null);
523
+ const text = statusText(state.page);
524
+ if (last?.failed && !last.reported) { last.reported = true; throw new Error(text); }
525
+ out.log(text);
348
526
  return;
349
527
  }
350
528
  // Its own name again (named at both ends, as playwright-cli habits have it) is no rename.
@@ -353,13 +531,29 @@ const commands = {
353
531
  if (rename) {
354
532
  try { checkFile(rename, rec.format); } catch (error) { throw new Error(`${error.message}; still recording to ${rec.file}`); }
355
533
  }
356
- const text = await finishRecording(rec);
357
- if (rec.failed) throw new Error(text);
534
+ // Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
535
+ const other = (rec.owner ?? null) !== (state.client ?? null);
536
+ const clientName = c => c ?? 'the unnamed client';
537
+ const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
538
+ if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
358
539
  if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
359
- moveRecording(rec.file, rename);
540
+ try {
541
+ moveRecording(rec.file, rename);
542
+ } catch (error) {
543
+ throw new Error(`${text}\nIt could not be renamed to ${rename} (${error.message}), so it stays where it was saved`);
544
+ }
545
+ // Its steps go with it, unless a file has the new name already.
546
+ let renamed = t => t.split(rec.file).join(rename);
547
+ if (rec.stepsFile && !fs.existsSync(stepsPath(rename))) {
548
+ try {
549
+ moveRecording(rec.stepsFile, stepsPath(rename));
550
+ const before = renamed;
551
+ renamed = t => before(t.split(rec.stepsFile).join(stepsPath(rename)));
552
+ } catch {}
553
+ }
360
554
  const last = lastEnded.get(rec.owner ?? null);
361
- if (last) last.text = last.text.split(rec.file).join(rename);
362
- out.log(text.split(rec.file).join(rename));
555
+ if (last) last.text = renamed(last.text);
556
+ out.log(renamed(text));
363
557
  return;
364
558
  }
365
559
  if (numbers.length > 1) throw new Error(usage);
@@ -378,15 +572,25 @@ const commands = {
378
572
  if (error.code === 'EEXIST') throw new Error(`Recording already exists: ${file}`);
379
573
  throw error;
380
574
  }
575
+ let rec;
381
576
  try {
382
- await startRecording(state.page, file, seconds, format);
577
+ rec = await startRecording(state.page, file, seconds, format);
383
578
  } catch (error) {
384
579
  fs.rmSync(file, { force: true });
385
580
  throw error;
386
581
  }
582
+ // An ffmpeg that stopped as it started (bad arguments, an encoder that failed) has ended it
583
+ // already, and record on says so instead of a notice.
584
+ if (rec.finishing) {
585
+ const text = await rec.finishing;
586
+ if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
587
+ out.log(text);
588
+ return;
589
+ }
590
+ rec.starting = false;
387
591
  const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
388
592
  out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
389
593
  },
390
594
  };
391
595
 
392
- module.exports = { commands, recordingOf, finishRecording, findFfmpeg, jpegSize };
596
+ module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
package/lib/runner.js CHANGED
@@ -4,9 +4,10 @@ const readline = require('readline');
4
4
  const { state, clientRecord, asClient, beforeExit } = require('./state');
5
5
  const out = require('./output');
6
6
  const { commands, cliCommands, needsTab } = require('./commands');
7
- const { dialogCommand } = require('./dialogs');
7
+ const { dialogCommand, CDP_ANSWER, CDP_REFUSED } = require('./dialogs');
8
8
  const { activeModes } = require('./modes');
9
9
  const { NO_TAB } = require('./util');
10
+ const { stepStarted, stepEnded, stepLabel } = require('./record');
10
11
  const cliNames = require('./cli-names');
11
12
 
12
13
  // playwright-cli names already explained, per client: each client is told once.
@@ -105,9 +106,13 @@ async function executeCommand(text) {
105
106
  out.error(`Error: ${NO_TAB}`);
106
107
  return { status: 'error', cmd };
107
108
  }
109
+ // A recording tab notes when each command that may change it ran, for its steps file (help record).
110
+ const step = READ_ONLY.has(cmd) || NO_TAB_NEEDED.has(cmd) || !state.page || state.page.isClosed() ? null : stepStarted(state.page, stepLabel(cmd, args));
108
111
  try {
109
112
  await run(args, all);
113
+ stepEnded(step, false);
110
114
  } catch (e) {
115
+ stepEnded(step, true);
111
116
  // Playwright colours its call log; the codes are noise anywhere but a terminal.
112
117
  e.message = String(e.message || '').replace(/\x1b\[[0-9;]*m/g, '');
113
118
  out.error(`Error: ${e.message}`);
@@ -172,7 +177,8 @@ function prompt(preserveCursor) {
172
177
  function enqueue(line) {
173
178
  const text = parseInput(line).text;
174
179
  const direct = /^quit(?:\s|$)/.test(text) || DIALOG.test(text);
175
- if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
180
+ if (CDP_ANSWER.test(text)) out.error(`Error: ${CDP_REFUSED}`);
181
+ else if (direct) handleLine(line, true).catch(e => out.error(`Error: ${e.message}`));
176
182
  else queue = queue.then(() => handleLine(line)).catch(e => out.error(`Error: ${e.message}`));
177
183
  }
178
184
 
@@ -181,6 +187,10 @@ function enqueue(line) {
181
187
  // client names the sender: its commands show as [server:<client>] and use its own selected tab.
182
188
  function submit(text, client = null) {
183
189
  if (DIALOG.test(text)) return answerDialog(text, client);
190
+ if (CDP_ANSWER.test(text)) {
191
+ console.log(`[server${client ? `:${client}` : ''}] ${text}\nError: ${CDP_REFUSED}`);
192
+ return Promise.resolve({ status: 'error', output: `Error: ${CDP_REFUSED}` });
193
+ }
184
194
  return new Promise(resolve => {
185
195
  queue = queue.then(async () => {
186
196
  if (state.stopping) return resolve({ status: 'error', output: 'The REPL is shutting down.' });
package/lib/syntax.js CHANGED
@@ -18,6 +18,13 @@ function unquote(text) {
18
18
  return match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2];
19
19
  }
20
20
 
21
+ // The words of a line as a shell reads them: quoted parts may hold spaces, and lose their quotes
22
+ // (--filename="my dir/a.webm" is one word, --filename=my dir/a.webm).
23
+ function shellWords(text) {
24
+ return [...(text || '').matchAll(/(?:"(?:[^"\\]|\\.)*"|'[^']*'|[^\s"']+|["'])+/g)]
25
+ .map(m => m[0].replace(/"((?:[^"\\]|\\.)*)"|'([^']*)'/g, (_, double, single) => (double !== undefined ? double.replace(/\\(.)/g, '$1') : single)));
26
+ }
27
+
21
28
  // The first word of args, or a quoted selector with spaces in it, and the rest of the line.
22
29
  function splitSelector(args) {
23
30
  const match = /^(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+))(?:\s+([\s\S]*))?$/.exec((args || '').trim());
@@ -47,4 +54,4 @@ function keyName(key) {
47
54
  return key.split(/\+(?!$)/).map(part => (part.length > 1 ? KEYS.get(part.toLowerCase()) || part : part)).join('+');
48
55
  }
49
56
 
50
- module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, splitSelector, keyName };
57
+ module.exports = { SELECTOR_FIRST, REF, toSelector, unquote, shellWords, splitSelector, keyName };
package/lib/tabs.js CHANGED
@@ -7,6 +7,7 @@ const { keepDrawing } = require('./cdp');
7
7
  const { ensureDialogHandler } = require('./dialogs');
8
8
  const { notLoaded, stayedPut } = require('./requestlog');
9
9
  const { viewportText } = require('./emulation');
10
+ const { recordingOf, endedByTabClose } = require('./record');
10
11
 
11
12
  // A navigation that does not load is said as wait load says it, from its request, rather than as
12
13
  // Playwright's net:: error and call log.
@@ -184,6 +185,7 @@ const commands = {
184
185
  if (all.length <= 1) throw new Error('Refusing to close the last tab');
185
186
  const url = target.url();
186
187
  const wasSelected = target === state.page;
188
+ const recording = recordingOf(target);
187
189
  await target.close();
188
190
  if (wasSelected) {
189
191
  const back = state.previousPage;
@@ -191,6 +193,7 @@ const commands = {
191
193
  state.previousPage = null;
192
194
  }
193
195
  out.log(`Closed ${url}${wasSelected && !state.page ? '; no tab is selected now' : ''}`);
196
+ if (recording) out.log(await endedByTabClose(recording));
194
197
  return listTabs();
195
198
  }
196
199
  select(byUrl(trimmed));
package/lib/tabstate.js CHANGED
@@ -11,6 +11,9 @@
11
11
  // with the owner
12
12
  // viewport the size viewport set: { width, height }, with the owner
13
13
  // recording its recording (record on): the ffmpeg writing it, its file, its owner
14
+ // cursor its cursor (cursor on): where it is drawn { x, y }, with the owner
15
+ // cursorKept its cursor is drawn again on each new page
16
+ // mouseAt where the REPL last put the mouse { x, y }, when it knows
14
17
  // routes Map(glob -> { status, body, handler, owner })
15
18
  // log its recent requests (requests)
16
19
  // consoleLog its recent console messages and page errors (console)
package/lib/util.js CHANGED
@@ -46,4 +46,9 @@ function clock(t) {
46
46
  return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}${zone}`;
47
47
  }
48
48
 
49
- module.exports = { COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };
49
+ // How the REPL, as it stops, begins what it says of each recording it ended, and of one that ended
50
+ // by itself unseen: stop prints those lines.
51
+ const STOPPED_RECORDING = 'The REPL stopped, which ended a recording';
52
+ const ENDED_UNSEEN = 'A recording ended before the REPL stopped, unseen since';
53
+
54
+ module.exports = { STOPPED_RECORDING, ENDED_UNSEEN, COMMAND_TIMEOUT, MAX_EVENT_TEXT, NO_TAB, hints, clipText, numbers, regexOf, patternText, clock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Text REPL for driving an existing Chromium through Playwright over CDP",
5
5
  "keywords": [
6
6
  "playwright",
package/skill/SKILL.md CHANGED
@@ -195,21 +195,29 @@ pw-repl send -c <name> close # your modes off; tab close
195
195
  one too; otherwise no tab is selected.
196
196
  Tab numbers change when tabs open or close; `tab <url-part>` and `tab close <url-part>` pick a tab by
197
197
  its URL and refuse if it is ambiguous.
198
- - Dialogs are never answered on their own. While one is open, its page and the commands that read it
199
- wait; `dialog` shows it, and `dialog accept` or `dialog dismiss` answers it.
198
+ - You can answer any dialog yourself, one you opened included: `dialog` shows it, and `dialog accept`
199
+ or `dialog dismiss` answers it. Nothing answers one on its own, and while one is open its page and
200
+ the commands that read it wait. One answered in the browser is gone from `dialog` too.
200
201
  - Modes and tabs stay on or open until they are turned off or closed, whoever started them. `modes`
201
202
  lists what is on in every tab (the prompt shows the selected tab's, e.g. `(watch routes:1) pw>`);
202
203
  `modes off` turns off every mode in every tab.
203
204
  - Someone may be attached to the REPL's tmux session; killing the session ends it for them too.
204
- - For a video, `help video` says which way suits what you want: `record on` records a tab's page, and a
205
- screen recorder on the person's machine records what they see.
205
+ - To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
206
+ without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
207
+ cover what is below it; `help highlight` says how to mark a screenshot without one.
208
+ - For a video, `help video` says which way suits what you want: `record on` records a tab's page (`cursor
209
+ on` draws a pointer where you act, for the viewer), and a screen recorder on the person's machine records
210
+ what they see.
206
211
 
207
212
  ## Environment
208
213
 
209
214
  - Chromium must be running with `--remote-debugging-port=9222`, unless `--launch` starts one. A headless
210
215
  one works too (`--headless=new`); nobody answers its dialogs but `dialog`.
211
216
  - `PW_CHROME` — the Chromium `--launch` starts (default: Playwright's own, then one on the `PATH`).
212
- - `PW_FFMPEG` — the ffmpeg `record` uses (default: Playwright's own, then one on the `PATH`).
217
+ - `PW_FFMPEG` — the ffmpeg `record` uses, and no other, read by the REPL, not by `send` (default:
218
+ Playwright's own, then one on the `PATH`).
219
+ - Playwright's own Chromium and ffmpeg are found where Playwright installs them: `PLAYWRIGHT_BROWSERS_PATH`,
220
+ or `~/.cache/ms-playwright`.
213
221
  - `PW_CDP_URL` — CDP endpoint (default `http://localhost:9222`).
214
222
  - `PW_SCREENSHOT_DIR` — where the REPL saves screenshots, read when it starts, not by `send`. Without it,
215
223
  a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`