pw-repl 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -8,15 +8,25 @@ folder this file is in) when it is not installed globally.
8
8
 
9
9
  If you hit a limitation or write a workaround, consider adding the capability to the REPL instead.
10
10
 
11
- - A command is a function in the `commands` object in `lib/commands.js`, plus an entry in `lib/help.js`
12
- under one topic.
11
+ - A command is a function in the `commands` object of the module for what it works on, plus an entry
12
+ in `lib/help.js` under one topic; `lib/commands.js` gathers every module's commands. A command that
13
+ needs no tab, only reads, or takes `--all` is also listed in `lib/runner.js`'s `NO_TAB_NEEDED`,
14
+ `READ_ONLY` or `INSPECTION`, and one that takes fixed words in `complete` (`lib/commands.js`).
15
+ - The modules, one per facility, each with its commands: `lib/tabs.js` (tabs, navigating),
16
+ `lib/elements.js` (choosing a match; the interact and mouse commands), `lib/inspect.js` (reading the
17
+ page, screenshots, eval, CDP, waits), `lib/watch.js`, `lib/requestlog.js` (requests, bodies, console),
18
+ `lib/routes.js`, `lib/network.js`, `lib/emulation.js`, `lib/capture.js`, `lib/dialogs.js` and
19
+ `lib/modes.js`. Beneath them, `lib/tabstate.js` is the one record kept per tab, `lib/cdp.js` the CDP
20
+ session kept per tab, and `lib/util.js` small shared helpers. No facility requires `lib/modes.js` or
21
+ `lib/commands.js`, and a unit test fails on a require cycle, which CommonJS would not report.
13
22
  - `bin/pw-repl.js` is the command line; `lib/start.js` connects and runs the prompt; `lib/runner.js`
14
23
  runs commands one at a time (`quit` and `dialog` skip its queue); `lib/server.js` is the command
15
24
  server; `lib/send.js` and `lib/client.js` are `send` and `where`; `lib/background.js` is
16
25
  `serve --background`, `attach` and `stop`; `lib/launch.js` is `--launch`; `lib/syntax.js` is how a
17
26
  command line's words are read, shared with `send`; `lib/cli-names.js` maps playwright-cli's command
18
27
  names to these; `lib/state.js` holds the session state, with each client's selected tab;
19
- `lib/output.js` routes all output so the server can return it.
28
+ `lib/output.js` routes all output so the server can return it; `lib/skill.js` stamps the skill
29
+ `pw-repl skill` prints, and gives `where` its hash.
20
30
  - Match playwright-cli (Microsoft's CLI for agents) where it is reasonable: agents are likelier to be
21
31
  trained on it. A new command, option or output that does what one of its does takes its name and form,
22
32
  or accepts them too (`lib/cli-names.js`, `help playwright-cli`). Differ where sharing a browser with a
@@ -26,15 +36,27 @@ If you hit a limitation or write a workaround, consider adding the capability to
26
36
  (`test/harness.js`), and the real REPL with its server. Nothing is mocked, and the shared browser is
27
37
  never touched. It finds Chromium through `PW_TEST_CHROME`, or where `--launch` looks (Playwright's
28
38
  browsers, then the `PATH`), and skips the browser tests if there is none. Add a test with each new
29
- command or behaviour.
39
+ command or behaviour. In `test/repl.test.js` each test starts in a fresh tab of its own, and every mode
40
+ is turned off after it; a test whose REPL quits, times out or is killed goes in
41
+ `test/repl-alone.test.js`, with a Chromium of its own.
42
+ - Help entries (`lib/help.js`) are plain paragraphs: `wrap()` lays each out at 104 columns, and keeps as
43
+ written a paragraph with a bullet, a table or an indented example. `test/fixtures` holds the rendered
44
+ help; after an intended change to it, save it again (the command is in `test/units.test.js`).
30
45
  - Time each full run, and put the result in the body of the commit it tests (`npm test: 151 tests, 79s`).
31
46
  Compare with the last commit that has one (`git log --grep='npm test:'`): a run that grew by more than
32
47
  a few seconds means a slow test crept in, usually one waiting out a real timeout (5s for a click).
33
48
  Find it (each test prints its time) and make it faster, or say why it can't be, before committing.
49
+ - Don't race the clock in a test: a page that changes on a timer, or a command timed against a budget,
50
+ fails on a busy machine. Have the page change when the test asks (eval), or measure the thing itself
51
+ (frames drawn, not clicks timed). A click waits for its element to hold still for two frames.
52
+ - A test that times out in a run much slower than usual (the machine is busy) may be load, not a bug:
53
+ run it again, alone. If it fails alone too, find out why before going on.
34
54
  - For anything the tests can't reach, exercise the change in a running REPL of your own: on a socket of
35
55
  your own, with `--launch` unless the change needs the user's browser, and then in a tab of your own.
36
56
  - `skill/SKILL.md` is how agents learn to use the REPL: keep it in step with a change to how it is
37
- used, and leave its Custom rules section empty.
57
+ used, and leave its Custom rules section empty. Keep its `<!-- pw-repl skill stamp -->` line: `pw-repl
58
+ skill` replaces it with the version and a hash of the skill's text, which `where` reports, so agents
59
+ can tell a saved copy is out of date.
38
60
 
39
61
  ## Waves
40
62
 
package/bin/pw-repl.js CHANGED
@@ -84,6 +84,16 @@ function parseSendArgs(args, allowCommand) {
84
84
  }
85
85
  } else if (arg === '--') { i++; break; } else break;
86
86
  }
87
+ // An address that is refused (not loopback) is a usage error, said once, not a stack trace.
88
+ const endpoint = options.endpoint || process.env.PW_ENDPOINT;
89
+ if (endpoint) {
90
+ try {
91
+ require('../lib/client').parseEndpoint(endpoint);
92
+ } catch (error) {
93
+ console.error(`pw-repl: ${error.message}`);
94
+ process.exit(64);
95
+ }
96
+ }
87
97
  const words = args.slice(i);
88
98
  if (!allowCommand && words.length) usage();
89
99
  // For a command that reads a quoted selector (fill "text=Your name" Ada), a
package/lib/capture.js ADDED
@@ -0,0 +1,139 @@
1
+ // Capturing a tab's requests and console messages together, from now until capture off.
2
+ const { state, onShutdown } = require('./state');
3
+ const out = require('./output');
4
+ const { printOutput } = out;
5
+ const { numbers, MAX_EVENT_TEXT, NO_TAB, clock, hints } = require('./util');
6
+ const { consoleText, requestEntries } = require('./requestlog');
7
+
8
+ const MAX_CAPTURE_EVENTS = 10000;
9
+ const MAX_CAPTURE_TEXT = 400000;
10
+
11
+ let cap = null;
12
+ let lastCapture = null;
13
+
14
+ // The capture running now, or null, for the modes it counts as.
15
+ function currentCapture() {
16
+ return cap;
17
+ }
18
+
19
+ // Records the selected tab's requests and console messages together, in time
20
+ // order, until capture off, or for a set number of seconds.
21
+ async function startCapture(tokens) {
22
+ if (cap) { out.log('Already capturing; capture off stops it.'); return; }
23
+ if (!state.page || state.page.isClosed()) throw new Error(NO_TAB);
24
+ const usage = 'Usage: capture on [requests|console] [seconds] (seconds from 1 to 3600)';
25
+ const kinds = tokens.filter(t => t === 'requests' || t === 'console');
26
+ const numbers = tokens.filter(t => /^\d+$/.test(t));
27
+ if (kinds.length > 1 || numbers.length > 1 || kinds.length + numbers.length !== tokens.length) throw new Error(usage);
28
+ const seconds = numbers.length ? Number(numbers[0]) : null;
29
+ if (seconds !== null && (seconds < 1 || seconds > 3600)) throw new Error(usage);
30
+ const label = kinds[0] || 'requests and console';
31
+ const startedAt = Date.now();
32
+ const events = [];
33
+ const handlers = [];
34
+ let dropped = 0;
35
+ let shortened = 0;
36
+ let bytes = 0;
37
+ const record = event => {
38
+ let text = String(event.text);
39
+ if (text.length > MAX_EVENT_TEXT) { text = `${text.slice(0, MAX_EVENT_TEXT)}…`; shortened += 1; }
40
+ const size = text.length;
41
+ if (events.length >= MAX_CAPTURE_EVENTS || bytes + size > MAX_CAPTURE_TEXT) { dropped += 1; return; }
42
+ bytes += size;
43
+ events.push({ ...event, text });
44
+ };
45
+ const page = state.page;
46
+ const listen = (event, handler) => { page.on(event, handler); handlers.push([event, handler]); };
47
+ if (label !== 'console') listen('request', req => record({ t: Date.now(), tag: 'request', req, text: `${req.method()} ${req.url()}` }));
48
+ if (label !== 'requests') {
49
+ listen('console', msg => record({ t: Date.now(), tag: msg.type(), text: consoleText(msg) }));
50
+ listen('pageerror', error => record({ t: Date.now(), tag: 'pageerror', text: error.stack || error.message }));
51
+ }
52
+ // A capture must not outlive its tab: nothing could see or stop it.
53
+ listen('close', () => {
54
+ if (cap?.page !== page) return;
55
+ endCapture();
56
+ out.notice('The captured tab closed, which ended the capture; capture shows what it recorded.', page);
57
+ });
58
+ const capture = {
59
+ page, label, startedAt, events, handlers, wake: null, owner: state.client,
60
+ get dropped() { return dropped; },
61
+ get shortened() { return shortened; },
62
+ };
63
+ cap = capture;
64
+ if (seconds) {
65
+ out.log(`Capturing ${label} for ${seconds}s...`);
66
+ // Also ends early if the capture does, e.g. because its tab closed.
67
+ await new Promise(resolve => {
68
+ const timer = setTimeout(resolve, seconds * 1000);
69
+ capture.wake = () => { clearTimeout(timer); resolve(); };
70
+ });
71
+ if (cap === capture) stopCapture();
72
+ else printCapture(lastCapture);
73
+ } else {
74
+ out.log(`Capturing ${label}; capture off stops it and prints what it recorded.`);
75
+ }
76
+ }
77
+
78
+ function endCapture() {
79
+ cap.handlers.forEach(([event, h]) => cap.page.off(event, h));
80
+ const { label, startedAt, events, dropped, shortened, wake } = cap;
81
+ cap = null;
82
+ lastCapture = { label, startedAt, events, dropped, shortened };
83
+ if (wake) wake();
84
+ }
85
+
86
+ function stopCapture(all = false) {
87
+ if (!cap) { out.log('Not capturing.'); return; }
88
+ endCapture();
89
+ printCapture(lastCapture, all);
90
+ }
91
+
92
+ // One line per event, from the start of the capture: a request as requests
93
+ // shows it (its status is looked up now, so it is known if it has finished),
94
+ // a console message or page error as console shows it.
95
+ function printCapture(capture, all = false) {
96
+ const lines = capture.events.map(event => {
97
+ const at = `+${((event.t - capture.startedAt) / 1000).toFixed(3)}s`;
98
+ if (event.tag !== 'request') return `${at} [${event.tag}] ${event.text}`;
99
+ const entry = requestEntries.get(event.req);
100
+ return entry ? `${at} #${entry.id} ${entry.method} ${entry.status} ${entry.url}` : `${at} ${event.text}`;
101
+ });
102
+ if (lines.length) printOutput(lines.join('\n'), all);
103
+ else out.log('Nothing captured');
104
+ const notes = [];
105
+ if (capture.dropped) notes.push(`${capture.dropped} event(s) omitted`);
106
+ if (capture.shortened) notes.push(`${capture.shortened} message(s) shortened`);
107
+ if (notes.length) out.log(`[capture limits: ${notes.join('; ')}]`);
108
+ }
109
+
110
+ function discardCapture() {
111
+ if (!cap) return;
112
+ cap.handlers.forEach(([event, handler]) => cap.page.off(event, handler));
113
+ cap = null;
114
+ }
115
+
116
+ onShutdown(discardCapture);
117
+
118
+ const commands = {
119
+ async capture(args, all) {
120
+ const [sub, ...rest] = (args || '').trim().split(/\s+/).filter(Boolean);
121
+ if (sub === 'on') return startCapture(rest);
122
+ if (sub === 'off' && !rest.length) return stopCapture(all);
123
+ if (sub) throw new Error('Usage: capture on [requests|console] [seconds] | capture off');
124
+ if (cap) {
125
+ out.log(`Capturing ${cap.label} on ${cap.page.url()} since ${clock(cap.startedAt)} (${cap.events.length} so far)`);
126
+ out.log(hints([['capture off', 'stop and print what it recorded']]));
127
+ return;
128
+ }
129
+ if (lastCapture) {
130
+ out.log(`Not capturing. The last capture, of ${lastCapture.label} from ${clock(lastCapture.startedAt)}:`);
131
+ printCapture(lastCapture, all);
132
+ } else {
133
+ out.log('Not capturing.');
134
+ }
135
+ out.log(hints([['capture on [requests|console] [seconds]', 'record the selected tab\'s requests and console messages in time order']]));
136
+ },
137
+ };
138
+
139
+ module.exports = { commands, currentCapture, endCapture };
package/lib/cdp.js ADDED
@@ -0,0 +1,31 @@
1
+ // The CDP session kept for each tab, and keeping a background tab drawing.
2
+ const { tabState } = require('./tabstate');
3
+
4
+ // Per page: a CDP session's emulation applies to the page it is attached to
5
+ // and resets, in part, when it detaches, so one is kept for each tab.
6
+ function keptSession(p) {
7
+ if (!('session' in tabState(p))) tabState(p).session = p.context().newCDPSession(p).catch(error => { delete tabState(p).session; throw error; });
8
+ return tabState(p).session;
9
+ }
10
+
11
+ // Chrome all but stops drawing a tab that is not in front once it has had
12
+ // input (about a frame a second), and Playwright waits for frames to see an
13
+ // element hold still before acting on it, so each click in a tab opened in the
14
+ // background took 1-2s. A screencast keeps the tab drawing without bringing it
15
+ // to the front, for as long as the REPL runs (the throttle is back within
16
+ // seconds of stopping it). Its frames are tiny and rare; the cost is that the
17
+ // tab renders at full rate, as if in front, an animation or a video included.
18
+ async function keepDrawing(p) {
19
+ if ('drawing' in tabState(p)) return;
20
+ tabState(p).drawing = true;
21
+ try {
22
+ const session = await keptSession(p);
23
+ session.on('Page.screencastFrame', frame => session.send('Page.screencastFrameAck', { sessionId: frame.sessionId }).catch(() => {}));
24
+ await session.send('Page.startScreencast', { format: 'jpeg', quality: 1, maxWidth: 8, maxHeight: 8, everyNthFrame: 60 });
25
+ } catch {
26
+ // Only slower without it.
27
+ delete tabState(p).drawing;
28
+ }
29
+ }
30
+
31
+ module.exports = { keptSession, keepDrawing };