pw-repl 0.8.2 → 0.9.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
@@ -33,7 +33,7 @@ async function deviceShot(device, { ref, full, type }) {
33
33
  const box = await onElement(selector, async () => {
34
34
  const locator = state.page.locator(selector);
35
35
  await locator.scrollIntoViewIfNeeded({ timeout: 5000 });
36
- return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; });
36
+ return locator.evaluate(el => { const r = el.getBoundingClientRect(); return { x: r.x + scrollX, y: r.y + scrollY, width: r.width, height: r.height }; }, null, { timeout: 5000 });
37
37
  });
38
38
  clip = box;
39
39
  }
package/lib/record.js CHANGED
@@ -13,6 +13,10 @@ const { deviceMetrics, viewportMetrics, settled } = require('./emulation');
13
13
 
14
14
  const FPS = 25;
15
15
  const MAX_SECONDS = 3600;
16
+ // While recording: a moment after each action for its result to be seen, and still page at both ends,
17
+ // so a video neither starts nor stops on an action (record on --pause, --lead, --tail).
18
+ const PACING = { pause: 750, lead: 1000, tail: 1000 };
19
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
16
20
  // Frames waiting for ffmpeg past this are dropped rather than held in memory.
17
21
  const MAX_QUEUED = 32 * 1024 * 1024;
18
22
  // How long ffmpeg may go without progress on the frames still queued for it before it is stopped,
@@ -145,7 +149,7 @@ function recordingPages() {
145
149
  return state.browser.contexts().flatMap(c => c.pages()).filter(p => recordingOf(p));
146
150
  }
147
151
 
148
- async function startRecording(p, file, seconds, format) {
152
+ async function startRecording(p, file, seconds, format, keepSteps, pacing) {
149
153
  const exe = findFfmpeg(format);
150
154
  // As screenshot does: a tab that has not been in front since it was emulated as a phone can
151
155
  // be drawn at the window's size.
@@ -159,7 +163,7 @@ async function startRecording(p, file, seconds, format) {
159
163
  const rec = {
160
164
  page: p, file, format, exe, session, owner: state.client, startedAt: Date.now(), firstFrameAt: null,
161
165
  size: null, resizedAt: null, written: 0, dropped: 0, latest: null, ff: null, timer: null, limit: null, finishing: null,
162
- ffmpegError: '', starting: true, scale: 1, steps: [], step: null, stepsFile: null,
166
+ ffmpegError: '', starting: true, scale: 1, keepSteps, pacing, steps: [], step: null, stepsFile: null,
163
167
  holdFrames: false, restarting: false, navigationVersion: 0, restoreError: null, mainFrameId: null, screencastParams: null,
164
168
  };
165
169
  // The size is the page's as the screen shows it, in CSS pixels (a phone's too, whose frames come in
@@ -260,10 +264,10 @@ async function startRecording(p, file, seconds, format) {
260
264
  }, seconds * 1000);
261
265
  rec.onClose = () => finishRecording(rec, TAB_CLOSED).then(text => out.notice(text, p));
262
266
  p.once('close', rec.onClose);
263
- // A new page, loaded or routed to by a single-page app, is a step of its own: a zoom that follows the
264
- // cursor ends there. Its URL is left out, as a step's values are.
267
+ // A new page, loaded or routed to by a single-page app, is a step of its own, for whoever edits the
268
+ // video to cut or caption there. Its URL is left out, as a step's values are.
265
269
  rec.onNavigated = frame => {
266
- if (frame !== p.mainFrame() || rec.finishing) return;
270
+ if (frame !== p.mainFrame() || rec.finishing || !keepSteps) return;
267
271
  const t = videoTime(rec);
268
272
  // An app that sets its URL twice in a row (push, then replace) is one new page.
269
273
  const last = rec.steps[rec.steps.length - 1];
@@ -293,7 +297,7 @@ function spawnFfmpeg(rec) {
293
297
  // record on, still starting it, says so itself.
294
298
  if (!rec.finishing) {
295
299
  rec.exitedEarly = true;
296
- finishRecording(rec, 'ffmpeg exited while recording, which ended it').then(text => { if (!rec.starting) out.notice(text, rec.page); });
300
+ finishRecording(rec, 'ffmpeg exited while recording, which ended it (help record --all)').then(text => { if (!rec.starting) out.notice(text, rec.page); });
297
301
  }
298
302
  }));
299
303
  return ff;
@@ -369,7 +373,7 @@ async function finishFile(rec, shuttingDown) {
369
373
  return { failed: true, text: `Nothing was saved: ffmpeg wrote no video (${rec.written} frames sent; ${why}).` };
370
374
  }
371
375
  const notes = [];
372
- if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size`);
376
+ if (rec.resizedAt !== null) notes.push(`the page changed size at ${rec.resizedAt.toFixed(1)}s and was fitted into the first size (help record --all)`);
373
377
  if (rec.dropped) notes.push(`${rec.dropped} frames dropped, ${(rec.dropped / FPS).toFixed(1)}s, while ffmpeg fell behind`);
374
378
  // An ffmpeg stopped before it finished leaves only what it had written, which it writes in
375
379
  // blocks: seconds of video short of the frames it was sent, and no length in the file's header.
@@ -385,7 +389,7 @@ async function finishFile(rec, shuttingDown) {
385
389
 
386
390
  // The steps: when each command the REPL ran on a recording tab began and ended, in the video's own
387
391
  // seconds, and the element or point it acted on in the video's pixels, saved next to the video for
388
- // whoever edits it (a zoom on a step, say). Only that: watch is the log of what happened.
392
+ // whoever edits it (a caption or a cut at a step, say). Only that: watch is the log of what happened.
389
393
 
390
394
  // The video's clock: frames are written against the wall clock since the first, less those dropped.
391
395
  function videoTime(rec) {
@@ -398,7 +402,12 @@ function videoTime(rec) {
398
402
  const WHOLE = new Set(['click', 'dblclick', 'hover', 'check', 'uncheck', 'mousemove', 'mousedown', 'mouseup', 'mousewheel']);
399
403
  const TARGET_FIRST = new Set(['fill', 'type', 'select', 'press', 'upload']);
400
404
  function stepLabel(cmd, args) {
401
- if (WHOLE.has(cmd)) return `${cmd} ${args.replace(/\s+--modifiers=\S+/, '')}`.trim();
405
+ if (WHOLE.has(cmd)) {
406
+ const words = args.replace(/\s+--modifiers=\S+/, '').trim();
407
+ // An element's selector as written, without the quotes it needed on the command line (click's button after it).
408
+ const parsed = /^mouse/.test(cmd) ? null : splitSelector(words);
409
+ return `${cmd} ${parsed ? [parsed.word, parsed.rest].filter(Boolean).join(' ') : words}`.trim();
410
+ }
402
411
  if (!TARGET_FIRST.has(cmd)) return cmd;
403
412
  const rest = cmd === 'type' ? args.replace(/^\s*--delay(?:=|\s+)\S*\s*/, '') : args;
404
413
  const parsed = splitSelector(rest);
@@ -406,8 +415,14 @@ function stepLabel(cmd, args) {
406
415
  return parsed && parsed.rest !== undefined ? `${cmd} ${parsed.word}` : cmd;
407
416
  }
408
417
 
409
- function stepStarted(p, command) {
418
+ // The recording that keeps its steps (record on --steps), if the tab has one.
419
+ function stepsOf(p) {
410
420
  const rec = recordingOf(p);
421
+ return rec?.keepSteps ? rec : null;
422
+ }
423
+
424
+ function stepStarted(p, command) {
425
+ const rec = stepsOf(p);
411
426
  if (!rec || rec.finishing) return null;
412
427
  // After the page changed size, its frames are fitted into the first size, so its pixels are not the video's.
413
428
  rec.step = { rec, t0: videoTime(rec), t1: null, command, box: null, point: null, offSize: rec.resizedAt !== null, failed: false };
@@ -424,7 +439,7 @@ function stepEnded(step, failed) {
424
439
 
425
440
  // The element (box) or point the running step acts on, in CSS pixels.
426
441
  function stepTarget(p, box, point) {
427
- const rec = recordingOf(p);
442
+ const rec = stepsOf(p);
428
443
  if (!rec?.step) return;
429
444
  const scaled = o => o && Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Math.round(v * rec.scale)]));
430
445
  rec.step.box = scaled(box);
@@ -433,13 +448,20 @@ function stepTarget(p, box, point) {
433
448
 
434
449
  // Where the cursor went (cursor on), and when: a line of its own, not a command's step.
435
450
  function cursorStep(p, what, point, ms) {
436
- const rec = recordingOf(p);
451
+ const rec = stepsOf(p);
437
452
  if (!rec || rec.finishing) return;
438
453
  const t0 = videoTime(rec);
439
454
  const scaled = Math.round(point.x * rec.scale);
440
455
  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 });
441
456
  }
442
457
 
458
+ // The pause after an action on a recording tab, for its result to be seen (record on --pause).
459
+ const ACTIONS = new Set([...WHOLE, ...TARGET_FIRST]);
460
+ async function pauseAfter(p, cmd) {
461
+ const rec = p && !p.isClosed() ? recordingOf(p) : null;
462
+ if (rec && !rec.finishing && rec.pacing.pause && ACTIONS.has(cmd)) await sleep(rec.pacing.pause);
463
+ }
464
+
443
465
  function stepsPath(file) {
444
466
  return file.replace(/\.[^./]+$/, '') + '.steps.txt';
445
467
  }
@@ -447,7 +469,7 @@ function stepsPath(file) {
447
469
  // 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
448
470
  // pixels, is - - - -), then the command. Plain words, for a shell script to read.
449
471
  function writeSteps(rec, seconds) {
450
- if (!rec.steps.length) return null;
472
+ if (!rec.keepSteps || !rec.steps.length) return null;
451
473
  const file = stepsPath(rec.file);
452
474
  const lines = [
453
475
  `# pw-repl record steps, for a video of ${rec.size.width}x${rec.size.height}, ${seconds.toFixed(2)}s long`,
@@ -491,6 +513,12 @@ async function finishAll() {
491
513
 
492
514
  onShutdown(finishAll);
493
515
 
516
+ // The pacing options record on was given, where they differ from the defaults.
517
+ function pacingText(pacing) {
518
+ const changed = Object.keys(PACING).filter(k => pacing[k] !== PACING[k]).map(k => `--${k}=${pacing[k]}`);
519
+ return changed.length ? `, ${changed.join(' ')}` : '';
520
+ }
521
+
494
522
  function statusText(p) {
495
523
  const rec = recordingOf(p);
496
524
  if (!rec) {
@@ -500,8 +528,8 @@ function statusText(p) {
500
528
  }
501
529
  const since = ((Date.now() - (rec.firstFrameAt || rec.startedAt)) / 1000).toFixed(1);
502
530
  const size = rec.size ? `, ${rec.size.width}x${rec.size.height}` : ', no frame yet';
503
- const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size` : '';
504
- return `Recording the selected tab to ${rec.file} since ${clock(rec.startedAt)} (${since}s${size})${rec.owner ? `, by ${rec.owner}` : ''}${resized}; record off stops it and saves the file.`;
531
+ const resized = rec.resizedAt !== null ? `; the page changed size at ${rec.resizedAt.toFixed(1)}s and is fitted into the first size (help record --all)` : '';
532
+ return `Recording the selected tab to ${rec.file} since ${clock(rec.startedAt)} (${since}s${size})${rec.owner ? `, by ${rec.owner}` : ''}${rec.keepSteps ? ', with its steps' : ''}${pacingText(rec.pacing)}${resized}; record off stops it and saves the file.`;
505
533
  }
506
534
 
507
535
  // A file word, as given or as playwright-cli's --filename=<file> (or --filename <file>); any other
@@ -509,9 +537,17 @@ function statusText(p) {
509
537
  function fileWords(words, usage) {
510
538
  const files = [];
511
539
  const rest = [];
540
+ let steps = false;
541
+ const pacing = {};
512
542
  for (let i = 0; i < words.length; i++) {
513
543
  const word = words[i];
514
- if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
544
+ const timing = /^--(pause|lead|tail)(?:=(.*))?$/.exec(word);
545
+ if (word === '--steps') steps = true;
546
+ else if (timing) {
547
+ if (!/^\d+$/.test(timing[2] ?? '') || Number(timing[2]) > 10000) throw new Error(`record on --${timing[1]}=<ms> takes milliseconds, from 0 to 10000 (default ${PACING[timing[1]]})`);
548
+ pacing[timing[1]] = Number(timing[2]);
549
+ }
550
+ else if (word.startsWith('--filename=') && word.length > 11) files.push(word.slice(11));
515
551
  else if (word === '--filename' && words[i + 1]) files.push(words[++i]);
516
552
  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)}`);
517
553
  // playwright-cli's video-start --size: here the video is the page's size.
@@ -521,7 +557,7 @@ function fileWords(words, usage) {
521
557
  else files.push(word);
522
558
  }
523
559
  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}`);
524
- return { file: files.length ? path.resolve(files[0]) : null, rest };
560
+ return { file: files.length ? path.resolve(files[0]) : null, rest, steps, pacing };
525
561
  }
526
562
 
527
563
  function checkFile(file, format) {
@@ -550,7 +586,7 @@ const commands = {
550
586
  async record(args) {
551
587
  // A file may be quoted, with spaces in it.
552
588
  const words = shellWords(args);
553
- const usage = `Usage: record [on [file.webm|file.mp4] [seconds] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
589
+ const usage = `Usage: record [on [file.webm|file.mp4] [seconds] [--steps] [--pause=<ms>] [--lead=<ms>] [--tail=<ms>] | off [file]] (seconds from 1 to ${MAX_SECONDS})`;
554
590
  if (!words.length) {
555
591
  await Promise.allSettled([...(saving.get(state.client ?? null) || [])]);
556
592
  out.log(statusText(state.page));
@@ -562,8 +598,10 @@ const commands = {
562
598
  const noTab = !state.page || state.page.isClosed();
563
599
  if (noTab && what === 'on') throw new Error(NO_TAB);
564
600
  // A file given is relative to the REPL's folder; pw-repl send makes it the sender's.
565
- const { file: named, rest: numbers } = fileWords(rest, usage);
601
+ const { file: named, rest: numbers, steps, pacing } = fileWords(rest, usage);
566
602
  if (what === 'off') {
603
+ if (Object.keys(pacing).length) throw new Error(`--${Object.keys(pacing)[0]} goes on record on`);
604
+ if (steps) throw new Error('--steps goes on record on, which notes each step from the start: record off, then record on <file> --steps for a take with its steps');
567
605
  if (numbers.length) throw new Error(usage);
568
606
  const rec = noTab ? null : recordingOf(state.page);
569
607
  if (!rec) {
@@ -585,6 +623,8 @@ const commands = {
585
623
  // Any client with the tab selected stops it, as it turns off any mode; both are told whose it was.
586
624
  const other = (rec.owner ?? null) !== (state.client ?? null);
587
625
  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.
627
+ if (!rec.finishing) await sleep(rec.pacing.tail);
588
628
  const text = await finishRecording(rec, other ? `${clientName(state.client)}'s record off ended ${clientName(rec.owner)}'s recording` : null);
589
629
  if (rec.failed) { lastEnded.get(rec.owner ?? null).reported = true; throw new Error(text); }
590
630
  if (!rename || !fs.existsSync(rec.file)) { out.log(text); return; }
@@ -625,7 +665,7 @@ const commands = {
625
665
  }
626
666
  let rec;
627
667
  try {
628
- rec = await startRecording(state.page, file, seconds, format);
668
+ rec = await startRecording(state.page, file, seconds, format, steps, { ...PACING, ...pacing });
629
669
  } catch (error) {
630
670
  fs.rmSync(file, { force: true });
631
671
  throw error;
@@ -639,9 +679,13 @@ const commands = {
639
679
  return;
640
680
  }
641
681
  rec.starting = false;
682
+ // The lead: still page before the first action, counted from the first frame.
683
+ const until = Date.now() + 2000;
684
+ while (rec.firstFrameAt === null && !rec.finishing && Date.now() < until) await sleep(20);
685
+ if (rec.firstFrameAt !== null && !rec.finishing) await sleep(Math.max(0, rec.pacing.lead - (Date.now() - rec.firstFrameAt)));
642
686
  const limit = numbers.length ? `for ${seconds}s` : `until record off (at most ${MAX_SECONDS / 60} minutes)`;
643
- out.log(`Recording the selected tab to ${file} ${limit}; record off stops it and saves the file.`);
687
+ out.log(`Recording the selected tab to ${file} ${limit}${steps ? ', with its steps' : ''}${pacingText(rec.pacing)}; record off stops it and saves the file.`);
644
688
  },
645
689
  };
646
690
 
647
- module.exports = { commands, recordingOf, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
691
+ module.exports = { commands, recordingOf, stepsOf, pauseAfter, finishRecording, endedByTabClose, findFfmpeg, jpegSize, stepStarted, stepEnded, stepTarget, cursorStep, stepLabel };
package/lib/runner.js CHANGED
@@ -7,7 +7,7 @@ const { commands, cliCommands, needsTab } = require('./commands');
7
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
+ const { stepStarted, stepEnded, stepLabel, pauseAfter } = require('./record');
11
11
  const cliNames = require('./cli-names');
12
12
 
13
13
  // playwright-cli names already explained, per client: each client is told once.
@@ -111,6 +111,7 @@ async function executeCommand(text) {
111
111
  try {
112
112
  await run(args, all);
113
113
  stepEnded(step, false);
114
+ await pauseAfter(state.page, cmd);
114
115
  } catch (e) {
115
116
  stepEnded(step, true);
116
117
  // Playwright colours its call log; the codes are noise anywhere but a terminal.
package/lib/send.js CHANGED
@@ -6,6 +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
10
 
10
11
  // The prompt, after the modes on in the selected tab if any: (watch network:off) pw[serve]>
11
12
  const PROMPT = /^(?:\([^()]*\) )?pw(\[serve\])?>\s*$/;
@@ -87,7 +88,73 @@ async function viaTmux(session, command, timeoutMs) {
87
88
  return 2;
88
89
  }
89
90
 
91
+ // A command's words as send was given them, made into the line the REPL reads: quoted again where the
92
+ // shell had kept a word whole, and with the files it reads or writes made absolute from the sender's
93
+ // folder, since the REPL's may be another.
94
+ function commandLine(words) {
95
+ // For a command that reads a quoted selector (fill "text=Your name" Ada), a
96
+ // word the shell kept whole is quoted again so it stays one word. Any other
97
+ // command takes the rest of its line as it is (eval, route's JSON), so its
98
+ // words are joined as they are.
99
+ const { SELECTOR_FIRST } = require('./syntax');
100
+ // So does playwright-cli's storage key (localstorage-set "my key" v).
101
+ // And record's file, which may have spaces in it.
102
+ const requote = words.length > 1 && (SELECTOR_FIRST.has(words[0]) || /^(?:local|session)storage-/.test(words[0]) || words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0]));
103
+ // An empty word (fill #name "") is the empty value.
104
+ // upload's files are read by the REPL, whose folder may not be this one.
105
+ // So are the files upload reads and screenshot --filename writes.
106
+ const resolve = require('path').resolve;
107
+ let resolved = words[0] === 'upload' ? words.map((w, n) => (n > 1 ? resolve(w) : w)) : words;
108
+ // record on <file> writes it too.
109
+ if (words[0] === 'record' || /^video-(?:start|stop)$/.test(words[0])) {
110
+ resolved = words.map((w, n) => {
111
+ if (n < (words[0] === 'record' ? 2 : 1)) return w;
112
+ if (w.startsWith('--filename=') && w.length > 11) return `--filename=${resolve(w.slice(11))}`;
113
+ return words[n - 1] === '--filename' || !/^(?:\d+|-.*)$/.test(w) ? resolve(w) : w;
114
+ });
115
+ }
116
+ if (words[0] === 'screenshot') {
117
+ resolved = words.map((w, n) => (w.startsWith('--filename=') && w.length > 11 ? `--filename=${resolve(w.slice(11))}` : words[n - 1] === '--filename' ? resolve(w) : w));
118
+ }
119
+ const quoted = requote ? resolved.map(w => (w === '' || /[\s"']/.test(w) ? JSON.stringify(w) : w)) : resolved;
120
+ // An option's value the shell kept whole stays whole too: --device='iPhone 15'.
121
+ // Single quotes escape nothing, so a backslash (a regexp's \d) reaches it as typed.
122
+ for (const [n, w] of quoted.entries()) {
123
+ const option = /^(--[A-Za-z][\w-]*=)([\s\S]*\s[\s\S]*)$/.exec(w);
124
+ if (n && option && !requote) quoted[n] = `${option[1]}${option[2].includes("'") ? JSON.stringify(option[2]) : `'${option[2]}'`}`;
125
+ }
126
+ return quoted.join(' ').trim();
127
+ }
128
+
129
+ // A file of commands, one per line, run in turn until one fails. A recording it started and did not
130
+ // 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)$/;
132
+ async function sendFile(options) {
133
+ let text;
134
+ try { text = fs.readFileSync(options.file, 'utf8'); } catch (error) { return fail(`cannot read ${options.file} (${error.code || error.message})`); }
135
+ const lines = text.split(/\r?\n/);
136
+ let recording = false;
137
+ for (const [i, raw] of lines.entries()) {
138
+ const line = raw.trim();
139
+ if (!line || line.startsWith('#')) continue;
140
+ console.log(`> ${line}`);
141
+ // Read as the prompt reads it; a file it reads or writes is the sender's, as on send's command line.
142
+ const words = shellWords(line);
143
+ const command = PATHS.test(words[0]) ? commandLine(words) : line;
144
+ const status = await send({ ...options, file: null, command });
145
+ if (status !== 0) {
146
+ console.error(`pw-repl: stopped at line ${i + 1} of ${options.file} (${line}), exit status ${status}`);
147
+ if (recording) console.error('pw-repl: record off was not reached, so the recording is still on; pw-repl send record off ends it');
148
+ return status;
149
+ }
150
+ if (/^(?:record\s+on|video-start)\b/.test(line)) recording = true;
151
+ if (/^(?:record\s+off|video-stop)\b/.test(line)) recording = false;
152
+ }
153
+ return 0;
154
+ }
155
+
90
156
  async function send(options) {
157
+ if (options.file) return sendFile(options);
91
158
  const command = options.command;
92
159
  if (!command) return fail('no command given');
93
160
  // A newline would reach the REPL as a second, unmarked command.
@@ -157,4 +224,4 @@ async function where(options) {
157
224
  return 64;
158
225
  }
159
226
 
160
- module.exports = { send, where };
227
+ module.exports = { send, where, commandLine };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pw-repl",
3
- "version": "0.8.2",
3
+ "version": "0.9.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
@@ -63,10 +63,15 @@ opens in a new tab.
63
63
  tmux send-keys -t playwright-repl Enter
64
64
  ```
65
65
 
66
- `serve` and `serve --background` take a socket path of your own instead of the default
67
- (`pw-repl serve --background /tmp/mine.sock`); `send`, `attach`, `stop` and `where` then need
68
- `-e /tmp/mine.sock`, or `PW_SOCKET=/tmp/mine.sock`; a socket file left by a REPL that died is replaced
69
- when the next one starts there. The log of a background REPL is next to its socket
66
+ A REPL of your own goes on a socket of its own, named with `-e` on every command, `serve` included:
67
+
68
+ ```bash
69
+ pw-repl serve --background --launch -e /tmp/mine.sock
70
+ pw-repl send -e /tmp/mine.sock tab new http://localhost:3000
71
+ pw-repl stop -e /tmp/mine.sock
72
+ ```
73
+
74
+ A socket file left by a REPL that died is replaced when the next one starts there. The log of a background REPL is next to its socket
70
75
  (`/tmp/mine.log`), appended to run after run, with a line where each starts and stops (past 5 MB, a
71
76
  start moves it to `/tmp/mine.log.1`); `tail -f` on it follows along without a terminal to attach from.
72
77
  `serve <port>` listens on TCP 127.0.0.1 instead of a socket, with no access control.
@@ -114,6 +119,10 @@ spaces and all: `pw-repl send click "text=Your name"`. It works however the REPL
114
119
  `pw-repl where` says which one a command would reach (the server, or the tmux pane running the
115
120
  REPL), or why neither is reachable, without running anything.
116
121
 
122
+ `send --file take.txt` runs a file of commands, one per line, as typed at the prompt (no shell quoting;
123
+ files relative to your folder, as on `send`'s command line; `#` comments), and stops at the first that fails, saying which line.
124
+ It is the way to record a video without gaps (`help record`), and to keep a repro to run again.
125
+
117
126
  `send` takes one command; chain several in your shell. Use `&&` when a step only makes sense if the one
118
127
  before it worked: it stops at any exit status but 0, a failure and an outcome not confirmed included.
119
128
  Use `;` when the steps do not depend on each other. `-t` is each command's own limit. Write each
@@ -157,7 +166,7 @@ blindly retry a change), `64` usage or the REPL is not reachable. `pw-repl --hel
157
166
 
158
167
  ## Learn the commands
159
168
 
160
- Run `pw-repl send help`. It lists six topics; `help <topic>` lists their commands, `help <command>`
169
+ Run `pw-repl send help`. It lists seven topics; `help <topic>` lists their commands, `help <command>`
161
170
  gives usage and caveats, and `help --all` prints everything at once. pw-repl answers it itself: it needs
162
171
  no running REPL, and never reaches one. The
163
172
  help is the command reference; this file does not repeat it. If you know playwright-cli, its command
@@ -205,9 +214,9 @@ pw-repl send -c <name> close # your modes off; tab close
205
214
  - To point something out, to the person or in a screenshot, `highlight <ref|selector>` draws a box over it
206
215
  without changing the page (`--style=<css>` restyles it; `highlight off` hides them). Its label can
207
216
  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.
217
+ - For a video, `help video` says how: a take written as a file and sent with `send --file`, `record on`
218
+ around it, `cursor on` for a pointer, and the pacing recording adds for the viewer; a screen recorder on
219
+ the user's machine records what they see.
211
220
 
212
221
  ## Environment
213
222
 
@@ -223,8 +232,8 @@ pw-repl send -c <name> close # your modes off; tab close
223
232
  a REPL on a socket of its own saves them next to its socket, and any other in `/tmp`. `screenshot`
224
233
  prints each file's path; other REPLs may save theirs in the same place. Remove only your
225
234
  own, and keep those too if they are needed, e.g. as evidence or as something to hand over.
226
- - `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `send`
227
- looks for when neither is given (default `/tmp/playwright-repl.sock`).
235
+ - `PW_ENDPOINT` / `PW_TMUX_SESSION` — defaults for `send -e` / `-s`. `PW_SOCKET` — the socket `serve`
236
+ serves on and `send` looks for when neither is given (default `/tmp/playwright-repl.sock`).
228
237
 
229
238
  ## Custom rules
230
239