pw-repl 0.4.2 → 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 +34 -8
- package/README.md +90 -101
- package/bin/pw-repl.js +12 -2
- package/lib/background.js +4 -3
- package/lib/capture.js +139 -0
- package/lib/cdp.js +31 -0
- package/lib/cli-names.js +1 -3
- package/lib/client.js +6 -1
- package/lib/commands.js +34 -2285
- package/lib/dialogs.js +58 -0
- package/lib/elements.js +360 -0
- package/lib/emulation.js +180 -0
- package/lib/help.js +298 -39
- 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 +7 -3
- package/lib/send.js +9 -0
- package/lib/server.js +9 -2
- package/lib/skill.js +29 -0
- package/lib/start.js +10 -5
- package/lib/state.js +5 -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 +35 -12
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// Each tab's recent requests and console messages, their bodies, and waiting for a request or a load.
|
|
2
|
+
const { state } = require('./state');
|
|
3
|
+
const out = require('./output');
|
|
4
|
+
const { printOutput } = out;
|
|
5
|
+
const { clipText, regexOf, patternText, clock } = require('./util');
|
|
6
|
+
const { tabState } = require('./tabstate');
|
|
7
|
+
const { keptSession } = require('./cdp');
|
|
8
|
+
|
|
9
|
+
// Marks a request a route answered, now: the browser reports it finished only later.
|
|
10
|
+
function markChanged(req, status, how) {
|
|
11
|
+
changedRequests.set(req, how);
|
|
12
|
+
const entry = requestEntries.get(req);
|
|
13
|
+
if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Always-on request and console logs, so what happened can be checked after
|
|
17
|
+
// someone has already clicked through without a capture running. Requests
|
|
18
|
+
// keep metadata plus a handle to the response; a body is read from the
|
|
19
|
+
// browser only when asked for, and only while the browser still has it.
|
|
20
|
+
const RECENT_MAX = 200;
|
|
21
|
+
const RECENT_DEFAULT = 20;
|
|
22
|
+
const BODY_TIMEOUT = 15000;
|
|
23
|
+
// Hidden unless asked for: they are rarely what is being checked and crowd out
|
|
24
|
+
// the API calls that are.
|
|
25
|
+
const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
|
|
26
|
+
// What watch leaves out: scripts too, since a dev server loads dozens per navigation, crowding out the API calls.
|
|
27
|
+
const WATCH_HIDDEN_TYPES = new Set([...RECENT_HIDDEN_TYPES, 'script']);
|
|
28
|
+
// The bodies body prints; any other is binary.
|
|
29
|
+
const TEXT_BODY = /json|^text\/|javascript|xml|html|x-www-form-urlencoded/;
|
|
30
|
+
|
|
31
|
+
// UTF-8 with no control characters but tabs and line breaks.
|
|
32
|
+
function looksLikeText(buffer) {
|
|
33
|
+
let text;
|
|
34
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(buffer); } catch { return false; }
|
|
35
|
+
return !/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(text);
|
|
36
|
+
}
|
|
37
|
+
// A watched tab keeps each body watch shows as it arrives, since the browser
|
|
38
|
+
// drops them once the tab navigates, often before anyone has read them.
|
|
39
|
+
const KEPT_BODY_MAX = 100 * 1024;
|
|
40
|
+
// request -> how a route changed it (faked or patched), shown after its status.
|
|
41
|
+
const changedRequests = new WeakMap();
|
|
42
|
+
const requestEntries = new WeakMap();
|
|
43
|
+
|
|
44
|
+
// The browser's own "Failed to load resource" message does not say which one.
|
|
45
|
+
function consoleText(msg) {
|
|
46
|
+
const text = msg.text();
|
|
47
|
+
const url = msg.location()?.url;
|
|
48
|
+
return /^Failed to load resource/.test(text) && url ? `${text}: ${url}` : text;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A level includes the ones above it, as playwright-cli's console [min-level]
|
|
52
|
+
// does, which sorts the types the same way; a page error is an error.
|
|
53
|
+
const CONSOLE_LEVELS = ['error', 'warning', 'info', 'debug'];
|
|
54
|
+
const DEBUG_TYPES = new Set(['clear', 'debug', 'endGroup', 'profile', 'profileEnd', 'startGroup', 'startGroupCollapsed', 'trace']);
|
|
55
|
+
|
|
56
|
+
function consoleLevel(type) {
|
|
57
|
+
if (type === 'error' || type === 'assert' || type === 'pageerror') return 'error';
|
|
58
|
+
if (type === 'warning') return 'warning';
|
|
59
|
+
return DEBUG_TYPES.has(type) ? 'debug' : 'info';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function keep(log, entry) {
|
|
63
|
+
log.push(entry);
|
|
64
|
+
if (log.length > RECENT_MAX) { log.shift(); log.dropped = true; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Said when a listing leaves some out, or it looks complete: a busy page makes more than the
|
|
68
|
+
// default in one load, and the log itself keeps only the last RECENT_MAX.
|
|
69
|
+
function cutNote(what, shown, total, dropped, command) {
|
|
70
|
+
if (shown < total) return `(last ${shown} of ${total} kept; ${command(Math.min(total, RECENT_MAX))} shows them all${dropped ? `; older ${what} are no longer kept` : ''})`;
|
|
71
|
+
return dropped ? `(older ${what} are no longer kept: only the last ${RECENT_MAX} of every kind are)` : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A request's status as requests shows it: its response's, and how a route changed it.
|
|
75
|
+
function statusOf(req, res) {
|
|
76
|
+
const status = res ? String(res.status()) : 'no response';
|
|
77
|
+
return changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// opened: the tab opened while the REPL runs. One a link opens has started loading before the REPL
|
|
81
|
+
// sees it, so the requests it made till then are asked for and put in the log, its document among them.
|
|
82
|
+
function ensureRecentLog(p, opened = false) {
|
|
83
|
+
if ('log' in tabState(p)) return;
|
|
84
|
+
const log = [];
|
|
85
|
+
const logs = [];
|
|
86
|
+
let nextId = 1;
|
|
87
|
+
let newDocument = false;
|
|
88
|
+
tabState(p).log = log;
|
|
89
|
+
tabState(p).consoleLog = logs;
|
|
90
|
+
const add = (req, t) => {
|
|
91
|
+
let navigation = false;
|
|
92
|
+
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
93
|
+
const entry = { id: nextId++, t, method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
94
|
+
if (navigation) newDocument = true;
|
|
95
|
+
requestEntries.set(req, entry);
|
|
96
|
+
keep(log, entry);
|
|
97
|
+
};
|
|
98
|
+
p.on('request', req => add(req, Date.now()));
|
|
99
|
+
if (opened) {
|
|
100
|
+
p.requests().then(async earlier => {
|
|
101
|
+
const missed = earlier.filter(req => !requestEntries.has(req));
|
|
102
|
+
if (!missed.length) return;
|
|
103
|
+
for (const req of missed) add(req, Math.round(req.timing().startTime));
|
|
104
|
+
// Numbered in the order they were made, the missed ones first: this is moments after the tab opened.
|
|
105
|
+
log.sort((a, b) => a.t - b.t);
|
|
106
|
+
log.forEach((entry, i) => { entry.id = i + 1; });
|
|
107
|
+
nextId = log.length + 1;
|
|
108
|
+
// One that finished before the REPL saw it has no event to come.
|
|
109
|
+
for (const req of missed) {
|
|
110
|
+
const res = await req.response().catch(() => null);
|
|
111
|
+
const entry = requestEntries.get(req);
|
|
112
|
+
if (!res || entry.status !== 'pending') continue;
|
|
113
|
+
entry.status = statusOf(req, res);
|
|
114
|
+
entry.ms = Math.max(0, Math.round(req.timing().responseEnd >= 0 ? req.timing().responseEnd : req.timing().responseStart));
|
|
115
|
+
entry.response = res;
|
|
116
|
+
keepBody(p, entry, res);
|
|
117
|
+
}
|
|
118
|
+
}).catch(() => {});
|
|
119
|
+
}
|
|
120
|
+
const finish = (req, status, response = null) => {
|
|
121
|
+
const entry = requestEntries.get(req);
|
|
122
|
+
if (!entry) return;
|
|
123
|
+
entry.status = status;
|
|
124
|
+
entry.ms = Date.now() - entry.t;
|
|
125
|
+
entry.response = response;
|
|
126
|
+
};
|
|
127
|
+
p.on('requestfinished', async req => {
|
|
128
|
+
let res = null;
|
|
129
|
+
try { res = await req.response(); } catch {}
|
|
130
|
+
finish(req, statusOf(req, res), res);
|
|
131
|
+
keepBody(p, requestEntries.get(req), res);
|
|
132
|
+
});
|
|
133
|
+
p.on('requestfailed', async req => {
|
|
134
|
+
// A page that never loaded: the app's next route change is not a load either.
|
|
135
|
+
if (requestEntries.get(req)?.navigation) newDocument = false;
|
|
136
|
+
const reason = req.failure()?.errorText || 'unknown';
|
|
137
|
+
// Whatever its status says: wait load must know at once that this one will never load.
|
|
138
|
+
const entry = requestEntries.get(req);
|
|
139
|
+
if (entry) entry.failed = reason;
|
|
140
|
+
// Chrome reports a response that has no body (204, 205, 304) as aborted once it has arrived: the
|
|
141
|
+
// page got its answer. Any other answered one failed while its body came in, and says so.
|
|
142
|
+
const res = await req.response().catch(() => null);
|
|
143
|
+
if (res) {
|
|
144
|
+
const status = statusOf(req, res);
|
|
145
|
+
finish(req, [204, 205, 304].includes(res.status()) ? status : `${status}, then failed: ${reason}`, res);
|
|
146
|
+
} else {
|
|
147
|
+
finish(req, `failed: ${reason}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
p.on('load', () => { tabState(p).loadedAt = Date.now(); });
|
|
151
|
+
// A page load, marked among the messages it comes between; it ages out with them. Only a new
|
|
152
|
+
// document counts: an app's own route change (pushState, a hash) navigates without loading.
|
|
153
|
+
p.on('framenavigated', frame => {
|
|
154
|
+
if (frame !== p.mainFrame() || !newDocument) return;
|
|
155
|
+
newDocument = false;
|
|
156
|
+
keep(logs, { t: Date.now(), type: 'load', text: frame.url() });
|
|
157
|
+
});
|
|
158
|
+
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
159
|
+
// Uncaught exceptions never reach the console event.
|
|
160
|
+
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A page that navigates as soon as a request answers (a login that redirects)
|
|
164
|
+
// takes the body with it before the request is even reported finished; told to
|
|
165
|
+
// in time, the browser keeps them outside the page instead. Once per tab, from
|
|
166
|
+
// its first watch, and never switched off: up to 50 MB of the browser's memory
|
|
167
|
+
// per watched tab, for as long as the REPL runs.
|
|
168
|
+
const DURABLE_TOTAL = 50 * 1024 * 1024;
|
|
169
|
+
|
|
170
|
+
async function keepBodiesDurable(p) {
|
|
171
|
+
if ('bodiesDurable' in tabState(p)) return;
|
|
172
|
+
tabState(p).bodiesDurable = true;
|
|
173
|
+
try {
|
|
174
|
+
const session = await keptSession(p);
|
|
175
|
+
await session.send('Network.configureDurableMessages', { maxTotalBufferSize: DURABLE_TOTAL, maxResourceBufferSize: KEPT_BODY_MAX });
|
|
176
|
+
} catch {
|
|
177
|
+
// An older Chrome: bodies are still kept once their requests finish.
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function keepBody(p, entry, res) {
|
|
182
|
+
if (!entry || !res || !(tabState(p).watch?.on || 'watchStarting' in tabState(p)) || WATCH_HIDDEN_TYPES.has(entry.type)) return;
|
|
183
|
+
const headers = res.headers();
|
|
184
|
+
if (!TEXT_BODY.test(headers['content-type'] || '') || Number(headers['content-length']) > KEPT_BODY_MAX) return;
|
|
185
|
+
try {
|
|
186
|
+
const buffer = await res.body();
|
|
187
|
+
if (buffer.length <= KEPT_BODY_MAX) entry.kept = buffer;
|
|
188
|
+
} catch {}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// A pattern without * is a URL part; with * it is a glob (* within a path segment, ** across).
|
|
192
|
+
function urlMatcher(pattern) {
|
|
193
|
+
if (!pattern.includes('*')) return url => url.includes(pattern);
|
|
194
|
+
const source = pattern.split('**').map(chunk => chunk.split('*').map(text => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*');
|
|
195
|
+
const regex = new RegExp(`^${source}$`);
|
|
196
|
+
return url => regex.test(url);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function printRequest(entry) {
|
|
200
|
+
out.log(`#${entry.id} ${entry.method} ${entry.status} ${entry.url}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Watches the recent log, which has every request from the moment it starts, so
|
|
204
|
+
// one that began or finished since the previous command (e.g. the click that
|
|
205
|
+
// caused it) is found rather than missed.
|
|
206
|
+
async function waitForRequest(pattern, timeout) {
|
|
207
|
+
const matches = urlMatcher(pattern);
|
|
208
|
+
const since = state.previousCommandAt || 0;
|
|
209
|
+
const deadline = Date.now() + timeout;
|
|
210
|
+
for (;;) {
|
|
211
|
+
// Begun since the previous command, or finished since, or still going: one begun earlier (a
|
|
212
|
+
// reload two commands ago) can still be the one waited for. A finished one first, so a request
|
|
213
|
+
// that never ends (a long poll) does not hide one that has.
|
|
214
|
+
const found = (tabState(state.page).log || []).filter(e => matches(e.url) && (e.t >= since || e.ms === null || e.t + e.ms >= since));
|
|
215
|
+
const done = found.find(e => e.status !== 'pending');
|
|
216
|
+
if (done) return printRequest(done);
|
|
217
|
+
const entry = found[0];
|
|
218
|
+
if (Date.now() >= deadline) throw new Error(`No response matching ${pattern} within ${timeout / 1000}s${entry ? ` (#${entry.id} is still pending)` : ''}`);
|
|
219
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Why a navigation that failed did not load, from its request.
|
|
224
|
+
// Its status says why once the request log has it failed; until then (pending, or its code) the reason is added.
|
|
225
|
+
function notLoaded(navigation) {
|
|
226
|
+
const why = stayedPut(navigation) ? `${navigation.status} (no page to show, so the tab stays where it was)` : /^failed|, then failed: /.test(navigation.status) ? navigation.status : `${navigation.status}, then failed: ${navigation.failed}`;
|
|
227
|
+
return `The page did not load: #${navigation.id} ${navigation.url} ${why}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Answered with no page (204, 205): Chrome leaves the tab on the page it was on.
|
|
231
|
+
function stayedPut(entry) {
|
|
232
|
+
return !!entry.failed && /^(?:204|205)\b/.test(entry.status);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Done once the page has loaded: after the latest navigation of the tab that
|
|
236
|
+
// began since the previous command did (the click on a link, say), or, with
|
|
237
|
+
// none, the document there now. Waiting on Playwright's load state alone would
|
|
238
|
+
// see the page being left, which has already loaded.
|
|
239
|
+
async function waitForLoad(timeout) {
|
|
240
|
+
const p = state.page;
|
|
241
|
+
const since = state.previousCommandAt || 0;
|
|
242
|
+
const deadline = Date.now() + timeout;
|
|
243
|
+
for (;;) {
|
|
244
|
+
const navigation = (tabState(p).log || []).filter(e => e.navigation && e.t >= since).pop();
|
|
245
|
+
if (navigation?.failed && navigation.status !== 'pending') throw new Error(notLoaded(navigation));
|
|
246
|
+
const loaded = navigation ? (tabState(p).loadedAt || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
|
|
247
|
+
if (loaded && !navigation && (tabState(p).loadedAt || 0) < since) {
|
|
248
|
+
// Nothing loaded since the previous command: a page that changes its URL itself (pushState) does not
|
|
249
|
+
// load, and may not have changed it yet.
|
|
250
|
+
out.log(`Loaded before the previous command, nothing since: ${p.url()} — ${await p.title().catch(() => '')}\n(A page that changes its own URL does not load: wait <selector> or wait text waits for its new view.)`);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
|
|
254
|
+
if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
|
|
255
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const commands = {
|
|
260
|
+
async requests(args) {
|
|
261
|
+
// --regex <pattern> takes the rest of the line.
|
|
262
|
+
let regex = null;
|
|
263
|
+
let regexArg = null;
|
|
264
|
+
const text = (args || '').replace(/(?:^|\s)--regex(?:\s+([\s\S]*))?$/, (_, pattern) => {
|
|
265
|
+
if (!pattern?.trim()) throw new Error('Usage: requests [--all] [n] --regex <pattern>');
|
|
266
|
+
regexArg = pattern.trim();
|
|
267
|
+
regex = regexOf(patternText(regexArg));
|
|
268
|
+
return '';
|
|
269
|
+
});
|
|
270
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
271
|
+
const everything = tokens.includes('--all');
|
|
272
|
+
if (everything) tokens.splice(tokens.indexOf('--all'), 1);
|
|
273
|
+
let count = RECENT_DEFAULT;
|
|
274
|
+
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
275
|
+
if (count < 1 || count > RECENT_MAX || tokens.length > (regex ? 0 : 1)) throw new Error(`Usage: requests [--all] [n] [url-filter | --regex <pattern>] (n from 1 to ${RECENT_MAX})`);
|
|
276
|
+
const filter = regex ? String(regex) : tokens[0];
|
|
277
|
+
const wanted = regex ? url => regex.test(url) : url => !filter || url.includes(filter);
|
|
278
|
+
const log = tabState(state.page).log || [];
|
|
279
|
+
const shown = everything ? log : log.filter(e => !RECENT_HIDDEN_TYPES.has(e.type) && !e.url.startsWith('chrome-extension://'));
|
|
280
|
+
const kept = shown.filter(e => wanted(e.url));
|
|
281
|
+
const matches = kept.slice(-count);
|
|
282
|
+
const cut = cutNote('requests', matches.length, kept.length, log.dropped, n => `requests${everything ? ' --all' : ''} ${n}${regex ? ` --regex ${regexArg}` : filter ? ` ${filter}` : ''}`);
|
|
283
|
+
// First, so the list is not read as complete, and its last line is still the latest request.
|
|
284
|
+
if (cut) out.log(cut);
|
|
285
|
+
if (!matches.length) { out.log(filter ? `No requests matching ${filter}` : 'No requests on the selected tab'); return; }
|
|
286
|
+
// Where each page load starts, as console marks it, so the requests of one load can be told
|
|
287
|
+
// apart; from the load the first one shown came from, and whether or not the filter matches it.
|
|
288
|
+
// Not one answered with no page: the tab stayed on the page it was on.
|
|
289
|
+
const loads = log.filter(e => e.navigation && !stayedPut(e));
|
|
290
|
+
let load = loads.filter(e => e.id <= matches[0].id).pop();
|
|
291
|
+
for (const e of matches) {
|
|
292
|
+
for (const next of loads.filter(n => (!load || n.id > load.id) && n.id <= e.id)) load = next;
|
|
293
|
+
if (load && !load.marked) out.log(`${clock(load.t)} --- the page loads ${load.url}`);
|
|
294
|
+
if (load) load.marked = true;
|
|
295
|
+
const ms = e.ms === null ? '-' : `${e.ms}ms`;
|
|
296
|
+
out.log(`#${e.id} ${clock(e.t)} ${e.method} ${e.status} ${ms} ${e.type} ${e.url}`);
|
|
297
|
+
}
|
|
298
|
+
for (const e of loads) delete e.marked;
|
|
299
|
+
// The numbers skip what is hidden; say so, or they look like requests went missing.
|
|
300
|
+
const first = matches[0].id;
|
|
301
|
+
const last = matches[matches.length - 1].id;
|
|
302
|
+
// With a filter, the numbers skip what does not match too, so the count would mislead.
|
|
303
|
+
const hidden = everything || filter ? 0 : log.filter(e => e.id > first && e.id < last && !shown.includes(e)).length;
|
|
304
|
+
if (hidden) out.log(`(${hidden} hidden between these: images, fonts, stylesheets, media and extension requests; requests --all shows them)`);
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
async body(args, all) {
|
|
308
|
+
const arg = (args || '').trim();
|
|
309
|
+
if (!arg) throw new Error('Usage: body <#> | body <url-part> (the number from requests, or the latest request whose URL contains url-part)');
|
|
310
|
+
const log = tabState(state.page).log || [];
|
|
311
|
+
const match = /^#?(\d+)$/.exec(arg);
|
|
312
|
+
// The latest that has finished: one still pending has no body yet.
|
|
313
|
+
const entry = match ? log.find(e => e.id === Number(match[1])) : log.filter(e => e.url.includes(arg) && e.status !== 'pending').pop();
|
|
314
|
+
if (!entry) throw new Error(match ? `No request #${match[1]} on the selected tab; requests lists them` : `No finished request on the selected tab has a URL containing ${arg}`);
|
|
315
|
+
const id = entry.id;
|
|
316
|
+
// A fake is marked answered before the browser hands over its response.
|
|
317
|
+
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
318
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
319
|
+
}
|
|
320
|
+
if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
|
|
321
|
+
let buffer = entry.kept;
|
|
322
|
+
if (!buffer) {
|
|
323
|
+
// Not withTimeout: its "timed out" error would disconnect the REPL, and
|
|
324
|
+
// reading a body cannot have changed anything.
|
|
325
|
+
let timer;
|
|
326
|
+
const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`did not arrive within ${BODY_TIMEOUT / 1000}s`)), BODY_TIMEOUT); });
|
|
327
|
+
try { buffer = await Promise.race([entry.response.body(), late]); }
|
|
328
|
+
// Playwright's reason ends in advice for its own API ("Read response.body() before..."), so only its first line is kept.
|
|
329
|
+
catch (error) { throw new Error(`The body of #${id} is not available: ${String(error.message).split('\n')[0]} (the browser drops bodies, e.g. after the tab navigates; a watched tab keeps them as they arrive)`); }
|
|
330
|
+
finally { clearTimeout(timer); }
|
|
331
|
+
}
|
|
332
|
+
const type = entry.response.headers()['content-type'] || '';
|
|
333
|
+
out.log(`#${id} ${entry.method} ${entry.status} ${entry.url} (${type || 'no content type'}, ${buffer.length} bytes)`);
|
|
334
|
+
if (!buffer.length) return;
|
|
335
|
+
// With no content type, text is told by its bytes: an error page's "not found" often has none.
|
|
336
|
+
if (!TEXT_BODY.test(type) && (type || !looksLikeText(buffer))) { out.log('[binary body not shown]'); return; }
|
|
337
|
+
let text = buffer.toString('utf8');
|
|
338
|
+
if (/json/.test(type)) { try { text = JSON.stringify(JSON.parse(text), null, 2); } catch {} }
|
|
339
|
+
printOutput(text, all);
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async console(args, all) {
|
|
343
|
+
const tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
344
|
+
let count = RECENT_DEFAULT;
|
|
345
|
+
if (tokens.length && /^\d+$/.test(tokens[0])) count = Number(tokens.shift());
|
|
346
|
+
if (count < 1 || count > RECENT_MAX || tokens.length > 1) throw new Error(`Usage: console [n] [level | filter] (n from 1 to ${RECENT_MAX})`);
|
|
347
|
+
const filter = tokens[0];
|
|
348
|
+
const level = CONSOLE_LEVELS.indexOf(filter);
|
|
349
|
+
const shown = level === -1
|
|
350
|
+
? e => !filter || e.type.includes(filter) || e.text.includes(filter)
|
|
351
|
+
: e => CONSOLE_LEVELS.indexOf(consoleLevel(e.type)) <= level;
|
|
352
|
+
const logs = tabState(state.page).consoleLog || [];
|
|
353
|
+
const kept = logs.filter(e => e.type !== 'load' && shown(e));
|
|
354
|
+
const matches = kept.slice(-count);
|
|
355
|
+
const cut = cutNote('messages', matches.length, kept.length, logs.dropped, n => `console ${n}${filter ? ` ${filter}` : ''}`);
|
|
356
|
+
if (cut) out.log(cut);
|
|
357
|
+
if (!matches.length) { out.log(filter ? `No console messages matching ${filter}` : 'No console messages on the selected tab'); return; }
|
|
358
|
+
// Where the page loaded again, so a message from an earlier load does not read as the current one's.
|
|
359
|
+
// From the load the first message shown came from, if it is still kept.
|
|
360
|
+
let first = logs.indexOf(matches[0]);
|
|
361
|
+
while (first > 0 && logs[first].type !== 'load') first -= 1;
|
|
362
|
+
const lines = logs.filter((e, i) => i >= first && (e.type === 'load' || matches.includes(e)));
|
|
363
|
+
printOutput(lines.map(e => (e.type === 'load' ? `${clock(e.t)} --- the page loaded ${e.text}` : `${clock(e.t)} [${e.type}] ${e.text}`)).join('\n'), all);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
module.exports = { commands, markChanged, RECENT_DEFAULT, RECENT_HIDDEN_TYPES, WATCH_HIDDEN_TYPES, requestEntries, consoleText, keep, ensureRecentLog, keepBodiesDurable, waitForRequest, notLoaded, stayedPut, waitForLoad };
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Routes: faking, patching, delaying or failing a tab's requests.
|
|
2
|
+
const { state } = require('./state');
|
|
3
|
+
const out = require('./output');
|
|
4
|
+
const { unquote, splitSelector } = require('./syntax');
|
|
5
|
+
const { hints } = require('./util');
|
|
6
|
+
const { tabState } = require('./tabstate');
|
|
7
|
+
const { markChanged, requestEntries } = require('./requestlog');
|
|
8
|
+
|
|
9
|
+
// Playwright routes are registered per page, so the listing is too.
|
|
10
|
+
const ROUTE_PREVIEW = 80;
|
|
11
|
+
|
|
12
|
+
function routesFor(p) {
|
|
13
|
+
if (!('routes' in tabState(p))) tabState(p).routes = new Map();
|
|
14
|
+
return tabState(p).routes;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ROUTE_DELAY_MAX = 120;
|
|
18
|
+
|
|
19
|
+
// A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
|
|
20
|
+
function mergePatch(target, patch) {
|
|
21
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
|
|
22
|
+
const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
|
|
23
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
24
|
+
if (value === null) delete result[key];
|
|
25
|
+
else result[key] = mergePatch(result[key], value);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseJson(text, what) {
|
|
31
|
+
try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// How a route treats the requests it matches: its text for listing, and what it does to each one.
|
|
35
|
+
function routeKind(how, rest, usage) {
|
|
36
|
+
if (/^\d{3}$/.test(how)) {
|
|
37
|
+
const status = Number(how);
|
|
38
|
+
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
39
|
+
// JSON unless its type is given, so a typo in the JSON is not served as text.
|
|
40
|
+
const typed = /^--content-type=("(?:[^"\\]|\\.)*"|'[^']*'|\S+)(?:\s+([\s\S]*))?$/.exec(rest || '');
|
|
41
|
+
const body = typed ? typed[2] || '' : rest || '';
|
|
42
|
+
if (!typed && body) {
|
|
43
|
+
try { JSON.parse(body); } catch (error) {
|
|
44
|
+
throw new Error(`Body is not valid JSON: ${error.message}; for another kind, give its type: route <url-glob> <status> --content-type=text/plain <body>`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Quoted when it has a parameter: --content-type="text/html; charset=utf-8".
|
|
48
|
+
const contentType = typed ? unquote(typed[1]) : body ? 'application/json' : undefined;
|
|
49
|
+
const preview = body.length > ROUTE_PREVIEW ? `${body.slice(0, ROUTE_PREVIEW)}…` : body;
|
|
50
|
+
return {
|
|
51
|
+
text: `${status}${typed ? ` (${contentType})` : ''}${preview ? ` ${preview}` : ''}`,
|
|
52
|
+
async handle(r, req, tag, notice) {
|
|
53
|
+
markChanged(req, status, 'faked');
|
|
54
|
+
await r.fulfill({ status, contentType, body });
|
|
55
|
+
notice(`Faked: ${tag()} -> ${status}`);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
if (how === 'patch') {
|
|
60
|
+
if (!rest) throw new Error(usage);
|
|
61
|
+
// Anything but an object replaces the whole body, as RFC 7386 has it: the way to change an array.
|
|
62
|
+
const patch = parseJson(rest, 'Patch');
|
|
63
|
+
return {
|
|
64
|
+
text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
|
|
65
|
+
async handle(r, req, tag, notice) {
|
|
66
|
+
const response = await r.fetch();
|
|
67
|
+
let body;
|
|
68
|
+
try { body = await response.json(); } catch {
|
|
69
|
+
await r.fulfill({ response });
|
|
70
|
+
notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
markChanged(req, response.status(), 'patched');
|
|
74
|
+
await r.fulfill({ response, json: mergePatch(body, patch) });
|
|
75
|
+
notice(`Patched: ${tag()} -> ${response.status()}`);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (how === 'delay') {
|
|
80
|
+
const seconds = Number(rest);
|
|
81
|
+
if (!/^\d+(?:\.\d+)?$/.test(rest || '') || seconds <= 0 || seconds > ROUTE_DELAY_MAX) throw new Error(`Usage: route <url-glob> delay <seconds> (up to ${ROUTE_DELAY_MAX})`);
|
|
82
|
+
return {
|
|
83
|
+
text: `delay ${seconds}s`,
|
|
84
|
+
async handle(r, req, tag, notice) {
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
86
|
+
await r.continue();
|
|
87
|
+
notice(`Delayed ${seconds}s: ${tag()}`);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (how === 'abort' && !rest) {
|
|
92
|
+
return {
|
|
93
|
+
text: 'abort',
|
|
94
|
+
async handle(r, req, tag, notice) {
|
|
95
|
+
await r.abort('failed');
|
|
96
|
+
notice(`Aborted: ${tag()}`);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
throw new Error(usage);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function listRoutes() {
|
|
104
|
+
const routes = routesFor(state.page);
|
|
105
|
+
const forms = [
|
|
106
|
+
['route <url-glob> <status> [json-body]', 'answer it with this status and JSON'],
|
|
107
|
+
['route <url-glob> <status> --content-type=<type> <body>', 'or with a body of another type'],
|
|
108
|
+
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
109
|
+
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
110
|
+
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
111
|
+
];
|
|
112
|
+
if (!routes.size) {
|
|
113
|
+
out.log('No routes on the selected tab.');
|
|
114
|
+
out.log(hints(forms));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
out.log(`Routes on the selected tab (${routes.size}):`);
|
|
118
|
+
const width = Math.max(...[...routes.keys()].map(glob => glob.length));
|
|
119
|
+
for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
|
|
120
|
+
out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function removeRoutes(glob) {
|
|
124
|
+
if (!glob) throw new Error('Usage: route off <url-glob> | route off --all');
|
|
125
|
+
const routes = routesFor(state.page);
|
|
126
|
+
if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
|
|
127
|
+
const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
|
|
128
|
+
for (const g of removed) out.log(`Removed: ${g}`);
|
|
129
|
+
if (!removed.length) out.log('No routes on the selected tab');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function unroute(p, globs) {
|
|
133
|
+
const routes = routesFor(p);
|
|
134
|
+
for (const g of globs) {
|
|
135
|
+
await p.unroute(g, routes.get(g).handler);
|
|
136
|
+
routes.delete(g);
|
|
137
|
+
}
|
|
138
|
+
return globs;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const commands = {
|
|
142
|
+
// Fulfilled inside the browser, so the page's own code handles the fake
|
|
143
|
+
// exactly as a real response and the request never reaches the network.
|
|
144
|
+
async route(args) {
|
|
145
|
+
const trimmed = (args || '').trim();
|
|
146
|
+
if (!trimmed) return listRoutes();
|
|
147
|
+
// A glob may be quoted, as it is in a shell.
|
|
148
|
+
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
149
|
+
if (off) return removeRoutes(off[1] && unquote(off[1]));
|
|
150
|
+
const usage = 'Usage: route <url-glob> <status> [json-body] | route <url-glob> patch <json> | route <url-glob> delay <seconds> | route <url-glob> abort | route off <url-glob>|--all';
|
|
151
|
+
const parsed = splitSelector(trimmed);
|
|
152
|
+
const match = /^(\S+)(?:\s+([\s\S]+))?$/.exec(parsed?.rest || '');
|
|
153
|
+
if (!match) throw new Error(usage);
|
|
154
|
+
const glob = parsed.word;
|
|
155
|
+
const [, how, rest] = match;
|
|
156
|
+
const route = routeKind(how, rest, usage);
|
|
157
|
+
const routes = routesFor(state.page);
|
|
158
|
+
const previous = routes.get(glob);
|
|
159
|
+
if (previous) await state.page.unroute(glob, previous.handler);
|
|
160
|
+
const page = state.page;
|
|
161
|
+
const notice = text => out.notice(text, page);
|
|
162
|
+
const handler = async r => {
|
|
163
|
+
const req = r.request();
|
|
164
|
+
const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
|
|
165
|
+
try {
|
|
166
|
+
await route.handle(r, req, tag, notice);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// Aborted, never continued: a request meant to be changed must not reach the page as it was.
|
|
169
|
+
await r.abort().catch(() => {});
|
|
170
|
+
notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
await state.page.route(glob, handler);
|
|
174
|
+
routes.set(glob, { text: route.text, handler, owner: state.client });
|
|
175
|
+
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
module.exports = { commands, routesFor, unroute };
|
package/lib/runner.js
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
const readline = require('readline');
|
|
4
4
|
const { state, clientRecord, asClient, beforeExit } = require('./state');
|
|
5
5
|
const out = require('./output');
|
|
6
|
-
const { commands, cliCommands, needsTab
|
|
6
|
+
const { commands, cliCommands, needsTab } = require('./commands');
|
|
7
|
+
const { dialogCommand } = require('./dialogs');
|
|
8
|
+
const { activeModes } = require('./modes');
|
|
9
|
+
const { NO_TAB } = require('./util');
|
|
7
10
|
const cliNames = require('./cli-names');
|
|
8
11
|
|
|
9
12
|
// playwright-cli names already explained, per client: each client is told once.
|
|
@@ -44,6 +47,7 @@ function parseInput(line) {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
function isUncertain(error) {
|
|
50
|
+
if (typeof error.uncertain === 'boolean') return error.uncertain;
|
|
47
51
|
const message = error.message || '';
|
|
48
52
|
if (!/timed out|timeout|connection (?:lost|closed)/i.test(message)) return false;
|
|
49
53
|
// Playwright's call log shows whether the element was ever found; if not, nothing was done.
|
|
@@ -98,7 +102,7 @@ async function executeCommand(text) {
|
|
|
98
102
|
return { status: 'error', cmd };
|
|
99
103
|
}
|
|
100
104
|
if (!NO_TAB_NEEDED.has(cmd) && needsTab(cmd, args) && (!state.page || state.page.isClosed())) {
|
|
101
|
-
out.error(
|
|
105
|
+
out.error(`Error: ${NO_TAB}`);
|
|
102
106
|
return { status: 'error', cmd };
|
|
103
107
|
}
|
|
104
108
|
try {
|
|
@@ -224,4 +228,4 @@ function drained() {
|
|
|
224
228
|
return queue;
|
|
225
229
|
}
|
|
226
230
|
|
|
227
|
-
module.exports = { enqueue, submit, answerInterrupted, drained, busy };
|
|
231
|
+
module.exports = { enqueue, submit, answerInterrupted, drained, busy, READ_ONLY, INSPECTION, NO_TAB_NEEDED };
|
package/lib/send.js
CHANGED
|
@@ -100,6 +100,7 @@ async function send(options) {
|
|
|
100
100
|
if (way.kind === 'tmux' && options.client) return fail('-c needs the command server (pw-repl serve); a REPL in tmux has no clients');
|
|
101
101
|
if (way.kind === 'tmux') return viaTmux(way.session, command, timeoutMs);
|
|
102
102
|
const answer = await client.request(way.endpoint, command, timeoutMs, name);
|
|
103
|
+
if (answer.result?.starting) return fail(`${answer.result.output} (pw-repl where says when it serves)`);
|
|
103
104
|
if (answer.result) {
|
|
104
105
|
if (answer.result.output) console.log(answer.result.output);
|
|
105
106
|
if (answer.result.unconfirmed) { console.error('completion not confirmed'); return 2; }
|
|
@@ -115,6 +116,9 @@ async function send(options) {
|
|
|
115
116
|
|
|
116
117
|
// Reports the route a command would take, checking it the way a command would.
|
|
117
118
|
async function where(options) {
|
|
119
|
+
// First, whatever else is reachable: a saved skill is compared with it (pw-repl skill: Start it).
|
|
120
|
+
const skill = require('./skill').current();
|
|
121
|
+
console.log(`skill: ${skill.hash} (pw-repl ${skill.version})`);
|
|
118
122
|
const way = route(options);
|
|
119
123
|
const info = way.kind === 'server' ? await client.healthInfo(way.endpoint, 5000) : null;
|
|
120
124
|
// The browser a running REPL uses (one it launched has its own address); otherwise the one it would
|
|
@@ -126,6 +130,11 @@ async function where(options) {
|
|
|
126
130
|
console.log(version ? `browser: ${cdp} answers (${version.Browser}${whose})` : `browser: nothing answers at ${cdp}; pw-repl run says how to start one`);
|
|
127
131
|
if (way.kind === 'server') {
|
|
128
132
|
const name = client.describe(way.endpoint);
|
|
133
|
+
if (info?.status === 'starting') {
|
|
134
|
+
// Not an error, but not serving yet: exit 64 as for no REPL, so a loop on where waits for it.
|
|
135
|
+
console.log(`server: ${name} (the REPL was started with pw-repl serve and is starting: ${info.step || 'starting'}; retry shortly)`);
|
|
136
|
+
return 64;
|
|
137
|
+
}
|
|
129
138
|
if (info) {
|
|
130
139
|
const background = require('./background').describeBackground(way.endpoint);
|
|
131
140
|
console.log(`server: ${name} (the REPL was started with pw-repl serve${background ? ' --background' : ''})`);
|
package/lib/server.js
CHANGED
|
@@ -31,7 +31,7 @@ function handle(req, res) {
|
|
|
31
31
|
// cross-origin POST, and cannot send a JSON content type without a
|
|
32
32
|
// preflight this server never answers.
|
|
33
33
|
if (req.headers.origin) return send(res, 403, { status: 'error', output: 'Requests from web pages are refused' });
|
|
34
|
-
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: 'ok', browser: state.cdpUrl, launched: state.launched });
|
|
34
|
+
if (req.method === 'GET' && req.url === '/health') return send(res, 200, { status: state.serving ? 'ok' : 'starting', step: state.serving ? undefined : state.startingStep, browser: state.cdpUrl, launched: state.launched });
|
|
35
35
|
if (req.method !== 'POST' || req.url !== '/run') return send(res, 404, { status: 'error', output: 'Use POST /run or GET /health' });
|
|
36
36
|
if (!/^application\/json\b/.test(req.headers['content-type'] || '')) return send(res, 415, { status: 'error', output: 'Content-Type must be application/json' });
|
|
37
37
|
let body = '';
|
|
@@ -50,6 +50,8 @@ function handle(req, res) {
|
|
|
50
50
|
if (client !== undefined && client !== null && !CLIENT_NAME.test(client)) {
|
|
51
51
|
return send(res, 400, { status: 'error', output: 'A client name is 1-40 letters, digits, dots, dashes and underscores' });
|
|
52
52
|
}
|
|
53
|
+
// Bound before the browser is reached, so where and send find it while it starts.
|
|
54
|
+
if (!state.serving) return send(res, 503, { status: 'error', starting: true, output: `The REPL is starting (${state.startingStep}); retry shortly` });
|
|
53
55
|
const result = await runner.submit(command.trim(), client || null);
|
|
54
56
|
if (!result.uncertain) return send(res, 200, { status: result.status, output: result.output });
|
|
55
57
|
// unconfirmed: the command may or may not have done what it was sent to do.
|
|
@@ -71,6 +73,8 @@ async function clearStaleSocket(socketPath) {
|
|
|
71
73
|
fs.unlinkSync(socketPath);
|
|
72
74
|
}
|
|
73
75
|
|
|
76
|
+
// Listens before the REPL connects, answering "starting" until the function it returns is called,
|
|
77
|
+
// so a REPL that is still connecting is not taken for one without a server.
|
|
74
78
|
async function serve(endpointArg) {
|
|
75
79
|
const endpoint = parseEndpoint(endpointArg);
|
|
76
80
|
if (endpoint.socket) await clearStaleSocket(endpoint.socket);
|
|
@@ -90,7 +94,10 @@ async function serve(endpointArg) {
|
|
|
90
94
|
const removeSocket = () => { if (endpoint.socket) try { fs.unlinkSync(endpoint.socket); } catch {} };
|
|
91
95
|
onShutdown(() => { server.close(); removeSocket(); });
|
|
92
96
|
process.on('exit', removeSocket);
|
|
93
|
-
|
|
97
|
+
return () => {
|
|
98
|
+
state.serving = true;
|
|
99
|
+
out.log(`Serving commands on ${describe(endpoint)}`);
|
|
100
|
+
};
|
|
94
101
|
}
|
|
95
102
|
|
|
96
103
|
module.exports = { serve };
|