pw-repl 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +25 -5
- package/README.md +2 -1
- package/bin/pw-repl.js +10 -0
- package/lib/capture.js +139 -0
- package/lib/cdp.js +31 -0
- package/lib/commands.js +34 -2591
- package/lib/dialogs.js +58 -0
- package/lib/elements.js +360 -0
- package/lib/emulation.js +180 -0
- package/lib/help.js +286 -37
- package/lib/inspect.js +460 -0
- package/lib/modes.js +115 -0
- package/lib/network.js +59 -0
- package/lib/output.js +12 -2
- package/lib/requestlog.js +367 -0
- package/lib/routes.js +179 -0
- package/lib/runner.js +6 -3
- package/lib/start.js +5 -4
- package/lib/state.js +2 -0
- package/lib/tabs.js +237 -0
- package/lib/tabstate.js +35 -0
- package/lib/util.js +49 -0
- package/lib/watch.js +530 -0
- package/package.json +1 -1
- package/skill/SKILL.md +16 -0
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
|
|
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,25 @@ 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.
|
|
34
52
|
- For anything the tests can't reach, exercise the change in a running REPL of your own: on a socket of
|
|
35
53
|
your own, with `--launch` unless the change needs the user's browser, and then in a tab of your own.
|
|
36
54
|
- `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.
|
|
55
|
+
used, and leave its Custom rules section empty. Keep its `<!-- pw-repl skill stamp -->` line: `pw-repl
|
|
56
|
+
skill` replaces it with the version and a hash of the skill's text, which `where` reports, so agents
|
|
57
|
+
can tell a saved copy is out of date.
|
|
38
58
|
|
|
39
59
|
## Waves
|
|
40
60
|
|
package/README.md
CHANGED
|
@@ -159,7 +159,8 @@ pw-repl where # which REPL send would reach
|
|
|
159
159
|
```
|
|
160
160
|
|
|
161
161
|
`pw-repl send` sends one command and prints its result, through the server when it is running and through the
|
|
162
|
-
`playwright-repl` tmux session otherwise.
|
|
162
|
+
`playwright-repl` tmux session otherwise. Chain several in the shell: `&&` stops at the first that fails or
|
|
163
|
+
is not confirmed (exit 1 or 2), `;` runs on.
|
|
163
164
|
|
|
164
165
|
Several agents can share one REPL: `pw-repl send -c <name>` (or `PW_CLIENT=<name>`) sends as a client with a
|
|
165
166
|
selected tab of its own, shown in the pane as `[server:<name>]`. `modes` says which client turned each mode on,
|
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 };
|