pw-repl 0.2.2 → 0.3.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 +10 -6
- package/README.md +31 -21
- package/bin/pw-repl.js +20 -6
- package/lib/background.js +3 -1
- package/lib/client.js +16 -6
- package/lib/commands.js +424 -73
- package/lib/help.js +36 -16
- package/lib/launch.js +115 -0
- package/lib/runner.js +2 -2
- package/lib/send.js +7 -4
- package/lib/server.js +2 -2
- package/lib/start.js +19 -6
- package/lib/state.js +5 -1
- package/lib/syntax.js +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -4
package/lib/commands.js
CHANGED
|
@@ -3,6 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const { state, withTimeout, onShutdown, shutdown } = require('./state');
|
|
4
4
|
const out = require('./output');
|
|
5
5
|
const HELP = require('./help');
|
|
6
|
+
const { devices } = require('playwright-core');
|
|
6
7
|
const { toSelector, unquote, splitSelector } = require('./syntax');
|
|
7
8
|
|
|
8
9
|
const { printOutput, OUTPUT_LIMIT } = out;
|
|
@@ -143,6 +144,8 @@ async function dialogCommand(args) {
|
|
|
143
144
|
// A tab in a browser the REPL connected to has no viewport set, so its size is
|
|
144
145
|
// the window's; it is read from the page.
|
|
145
146
|
async function viewportText() {
|
|
147
|
+
const device = emulations.get(state.page)?.device;
|
|
148
|
+
if (device) return `${devices[device].viewport.width}x${devices[device].viewport.height} (emulate mobile: ${device})`;
|
|
146
149
|
const set = state.page.viewportSize();
|
|
147
150
|
if (set) return `${set.width}x${set.height} (set with viewport)`;
|
|
148
151
|
const size = await state.page.evaluate(() => `${innerWidth}x${innerHeight}`).catch(() => null);
|
|
@@ -166,25 +169,122 @@ function nextScreenshotPath(name) {
|
|
|
166
169
|
return path.join(SCREENSHOT_DIR, `${filename}.png`);
|
|
167
170
|
}
|
|
168
171
|
|
|
169
|
-
// Per page: emulation applies to the page
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const networkCut = new WeakSet();
|
|
172
|
+
// Per page: a CDP session's emulation applies to the page it is attached to
|
|
173
|
+
// and resets, in part, when it detaches, so one is kept for each tab.
|
|
174
|
+
const keptSessions = new WeakMap();
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
176
|
+
function keptSession(p) {
|
|
177
|
+
if (!keptSessions.has(p)) keptSessions.set(p, p.context().newCDPSession(p).catch(error => { keptSessions.delete(p); throw error; }));
|
|
178
|
+
return keptSessions.get(p);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const networkEnabled = new WeakSet();
|
|
182
|
+
// page -> how its network is changed: { offline: true }, or slowed { latency, down, up } (ms, kbps).
|
|
183
|
+
const networkChanged = new WeakMap();
|
|
184
|
+
// DevTools' "Slow 4G".
|
|
185
|
+
const SLOW_DEFAULT = { latency: 563, down: 1440, up: 675 };
|
|
186
|
+
const SLOW_LATENCY_MAX = 10000;
|
|
187
|
+
|
|
188
|
+
// setting null restores the network.
|
|
189
|
+
async function setNetwork(p, setting) {
|
|
190
|
+
const session = await keptSession(p);
|
|
191
|
+
if (!networkEnabled.has(p)) { await session.send('Network.enable'); networkEnabled.add(p); }
|
|
181
192
|
await session.send('Network.emulateNetworkConditions', {
|
|
182
|
-
offline:
|
|
183
|
-
latency: 0,
|
|
184
|
-
|
|
185
|
-
|
|
193
|
+
offline: !!setting?.offline,
|
|
194
|
+
latency: setting?.latency || 0,
|
|
195
|
+
// kbps to bytes per second; -1 is no limit.
|
|
196
|
+
downloadThroughput: setting?.down ? setting.down * 125 : -1,
|
|
197
|
+
uploadThroughput: setting?.up ? setting.up * 125 : -1,
|
|
186
198
|
});
|
|
187
|
-
if (
|
|
199
|
+
if (setting) networkChanged.set(p, setting); else networkChanged.delete(p);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function networkText(setting) {
|
|
203
|
+
return setting.offline ? 'off (offline)' : `slow (${setting.latency}ms latency, ${setting.down} kbps down, ${setting.up} kbps up)`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// page -> what emulate changed: { device, scheme, locale, timezone }, each unset when off.
|
|
207
|
+
const emulations = new WeakMap();
|
|
208
|
+
const DEFAULT_DEVICE = 'Pixel 7';
|
|
209
|
+
const EMULATE_KINDS = ['mobile', 'dark', 'light', 'locale', 'timezone'];
|
|
210
|
+
|
|
211
|
+
function emulatedKinds(e) {
|
|
212
|
+
return [e?.device && 'mobile', e?.scheme, e?.locale && 'locale', e?.timezone && 'timezone'].filter(Boolean);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function deviceNamed(name) {
|
|
216
|
+
const found = Object.keys(devices).find(d => d.toLowerCase() === name.toLowerCase());
|
|
217
|
+
if (!found) throw new Error(`No device ${JSON.stringify(name)}; names are Playwright's, e.g. Pixel 7, iPhone 13, iPad Mini, Galaxy S9+`);
|
|
218
|
+
return found;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function deviceText(name) {
|
|
222
|
+
const d = devices[name];
|
|
223
|
+
return `${name}, ${d.viewport.width}x${d.viewport.height} at ${d.deviceScaleFactor}x${d.hasTouch ? ', touch' : ''}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Sends only what changed. Chrome takes the user agent and the languages
|
|
227
|
+
// together, so a change to either sends both.
|
|
228
|
+
async function setEmulation(p, next) {
|
|
229
|
+
const session = await keptSession(p);
|
|
230
|
+
const previous = emulations.get(p) || {};
|
|
231
|
+
const send = (method, params) => session.send(method, params);
|
|
232
|
+
if (next.device !== previous.device) {
|
|
233
|
+
const d = devices[next.device];
|
|
234
|
+
if (d) {
|
|
235
|
+
await send('Emulation.setDeviceMetricsOverride', {
|
|
236
|
+
width: d.viewport.width, height: d.viewport.height, deviceScaleFactor: d.deviceScaleFactor, mobile: d.isMobile,
|
|
237
|
+
screenWidth: d.screen?.width || d.viewport.width, screenHeight: d.screen?.height || d.viewport.height,
|
|
238
|
+
});
|
|
239
|
+
} else {
|
|
240
|
+
await send('Emulation.clearDeviceMetricsOverride');
|
|
241
|
+
}
|
|
242
|
+
await send('Emulation.setTouchEmulationEnabled', d?.hasTouch ? { enabled: true, maxTouchPoints: 5 } : { enabled: false });
|
|
243
|
+
}
|
|
244
|
+
if (next.device !== previous.device || next.locale !== previous.locale) {
|
|
245
|
+
if (next.device || next.locale) {
|
|
246
|
+
const userAgent = next.device ? devices[next.device].userAgent : (await send('Browser.getVersion')).userAgent;
|
|
247
|
+
await send('Emulation.setUserAgentOverride', { userAgent, ...(next.locale ? { acceptLanguage: next.locale } : {}) });
|
|
248
|
+
} else {
|
|
249
|
+
// An empty user agent ends the override.
|
|
250
|
+
await send('Emulation.setUserAgentOverride', { userAgent: '' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (next.locale !== previous.locale) await send('Emulation.setLocaleOverride', next.locale ? { locale: next.locale } : {});
|
|
254
|
+
if (next.timezone !== previous.timezone) await send('Emulation.setTimezoneOverride', { timezoneId: next.timezone || '' });
|
|
255
|
+
if (next.scheme !== previous.scheme) await send('Emulation.setEmulatedMedia', { features: next.scheme ? [{ name: 'prefers-color-scheme', value: next.scheme }] : [] });
|
|
256
|
+
if (emulatedKinds(next).length) emulations.set(p, next); else emulations.delete(p);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function showEmulation() {
|
|
260
|
+
const e = emulations.get(state.page);
|
|
261
|
+
const forms = [
|
|
262
|
+
['emulate mobile [device]', `a phone's screen, touch and user agent (default ${DEFAULT_DEVICE})`],
|
|
263
|
+
['emulate dark | light', 'the color scheme the page sees'],
|
|
264
|
+
['emulate locale <tag>', 'its language and formats, e.g. fr-FR'],
|
|
265
|
+
['emulate timezone <zone>', 'e.g. Asia/Tokyo'],
|
|
266
|
+
];
|
|
267
|
+
if (!e) {
|
|
268
|
+
out.log('Nothing is emulated in the selected tab.');
|
|
269
|
+
out.log(hints(forms));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
out.log('Emulated in the selected tab:');
|
|
273
|
+
const rows = [
|
|
274
|
+
e.device && ['mobile', deviceText(e.device)],
|
|
275
|
+
e.scheme && ['color scheme', e.scheme],
|
|
276
|
+
e.locale && ['locale', e.locale],
|
|
277
|
+
e.timezone && ['timezone', e.timezone],
|
|
278
|
+
].filter(Boolean);
|
|
279
|
+
for (const [what, value] of rows) out.log(` ${what.padEnd(12)} ${value}`);
|
|
280
|
+
out.log(hints([...forms, ['emulate <what> off | emulate off', 'stop one, or all']]));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Resets every tab's emulation, which detaching would only partly undo.
|
|
284
|
+
async function resetEmulations() {
|
|
285
|
+
if (!state.browser || state.connectionLost) return;
|
|
286
|
+
const pages = state.browser.contexts().flatMap(c => c.pages()).filter(p => emulations.has(p) && !p.isClosed());
|
|
287
|
+
await Promise.all(pages.map(p => setEmulation(p, {}).catch(() => {})));
|
|
188
288
|
}
|
|
189
289
|
|
|
190
290
|
// page -> Map(glob -> { status, body, handler }). Playwright routes are
|
|
@@ -197,23 +297,108 @@ function routesFor(p) {
|
|
|
197
297
|
return pageRoutes.get(p);
|
|
198
298
|
}
|
|
199
299
|
|
|
300
|
+
const ROUTE_DELAY_MAX = 120;
|
|
301
|
+
|
|
302
|
+
// Marks a request a route answered, now: the browser reports it finished only later.
|
|
303
|
+
function markChanged(req, status, how) {
|
|
304
|
+
changedRequests.set(req, how);
|
|
305
|
+
const entry = requestEntries.get(req);
|
|
306
|
+
if (entry) { entry.status = `${status} ${how}`; entry.ms = Date.now() - entry.t; }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// A JSON Merge Patch (RFC 7386): objects merge, null removes a key, anything else replaces.
|
|
310
|
+
function mergePatch(target, patch) {
|
|
311
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return patch;
|
|
312
|
+
const result = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
|
|
313
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
314
|
+
if (value === null) delete result[key];
|
|
315
|
+
else result[key] = mergePatch(result[key], value);
|
|
316
|
+
}
|
|
317
|
+
return result;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseJson(text, what) {
|
|
321
|
+
try { return JSON.parse(text); } catch (error) { throw new Error(`${what} is not valid JSON: ${error.message}`); }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// How a route treats the requests it matches: its text for listing, and what it does to each one.
|
|
325
|
+
function routeKind(how, rest, usage) {
|
|
326
|
+
if (/^\d{3}$/.test(how)) {
|
|
327
|
+
const status = Number(how);
|
|
328
|
+
if (status < 200 || status > 599) throw new Error('Status must be from 200 to 599');
|
|
329
|
+
if (!rest) throw new Error(usage);
|
|
330
|
+
parseJson(rest, 'Body');
|
|
331
|
+
const preview = rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest;
|
|
332
|
+
return {
|
|
333
|
+
text: `${status} ${preview}`,
|
|
334
|
+
async handle(r, req, tag) {
|
|
335
|
+
markChanged(req, status, 'faked');
|
|
336
|
+
await r.fulfill({ status, contentType: 'application/json', body: rest });
|
|
337
|
+
out.notice(`Faked: ${tag()} -> ${status}`);
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
if (how === 'patch') {
|
|
342
|
+
if (!rest) throw new Error(usage);
|
|
343
|
+
const patch = parseJson(rest, 'Patch');
|
|
344
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('Patch must be a JSON object, e.g. {"total": 0}');
|
|
345
|
+
return {
|
|
346
|
+
text: `patch ${rest.length > ROUTE_PREVIEW ? `${rest.slice(0, ROUTE_PREVIEW)}…` : rest}`,
|
|
347
|
+
async handle(r, req, tag) {
|
|
348
|
+
const response = await r.fetch();
|
|
349
|
+
let body;
|
|
350
|
+
try { body = await response.json(); } catch {
|
|
351
|
+
await r.fulfill({ response });
|
|
352
|
+
out.notice(`Not patched: ${tag()} — its response is not JSON, so it went through unchanged`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
markChanged(req, response.status(), 'patched');
|
|
356
|
+
await r.fulfill({ response, json: mergePatch(body, patch) });
|
|
357
|
+
out.notice(`Patched: ${tag()} -> ${response.status()}`);
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
if (how === 'delay') {
|
|
362
|
+
const seconds = Number(rest);
|
|
363
|
+
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})`);
|
|
364
|
+
return {
|
|
365
|
+
text: `delay ${seconds}s`,
|
|
366
|
+
async handle(r, req, tag) {
|
|
367
|
+
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
368
|
+
await r.continue();
|
|
369
|
+
out.notice(`Delayed ${seconds}s: ${tag()}`);
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (how === 'abort' && !rest) {
|
|
374
|
+
return {
|
|
375
|
+
text: 'abort',
|
|
376
|
+
async handle(r, req, tag) {
|
|
377
|
+
await r.abort('failed');
|
|
378
|
+
out.notice(`Aborted: ${tag()}`);
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
throw new Error(usage);
|
|
383
|
+
}
|
|
384
|
+
|
|
200
385
|
function listRoutes() {
|
|
201
386
|
const routes = routesFor(state.page);
|
|
387
|
+
const forms = [
|
|
388
|
+
['route <url-glob> <status> <json-body>', 'answer it with this status and JSON'],
|
|
389
|
+
['route <url-glob> patch <json>', 'let it through, then change its JSON'],
|
|
390
|
+
['route <url-glob> delay <seconds>', 'hold it, then let it through'],
|
|
391
|
+
['route <url-glob> abort', 'fail it as if the connection broke'],
|
|
392
|
+
];
|
|
202
393
|
if (!routes.size) {
|
|
203
|
-
out.log('No
|
|
204
|
-
out.log(hints(
|
|
394
|
+
out.log('No routes on the selected tab.');
|
|
395
|
+
out.log(hints(forms));
|
|
205
396
|
return;
|
|
206
397
|
}
|
|
207
|
-
out.log(`
|
|
398
|
+
out.log(`Routes on the selected tab (${routes.size}):`);
|
|
208
399
|
const width = Math.max(...[...routes.keys()].map(glob => glob.length));
|
|
209
|
-
for (const [glob, {
|
|
210
|
-
|
|
211
|
-
out.log(` ${glob.padEnd(width)} ${status} ${preview}`);
|
|
212
|
-
}
|
|
213
|
-
out.log(hints([
|
|
214
|
-
['route <url-glob> <status> <json-body>', 'add one, or replace the one for that glob'],
|
|
215
|
-
['route off <url-glob> | route off --all', 'remove'],
|
|
216
|
-
]));
|
|
400
|
+
for (const [glob, { text }] of routes) out.log(` ${glob.padEnd(width)} ${text}`);
|
|
401
|
+
out.log(hints([...forms, ['route off <url-glob> | route off --all', 'remove']]));
|
|
217
402
|
}
|
|
218
403
|
|
|
219
404
|
async function removeRoutes(glob) {
|
|
@@ -222,7 +407,7 @@ async function removeRoutes(glob) {
|
|
|
222
407
|
if (glob !== '--all' && !routes.has(glob)) throw new Error(`No route for ${glob} on the selected tab`);
|
|
223
408
|
const removed = await unroute(state.page, glob === '--all' ? [...routes.keys()] : [glob]);
|
|
224
409
|
for (const g of removed) out.log(`Removed: ${g}`);
|
|
225
|
-
if (!removed.length) out.log('No
|
|
410
|
+
if (!removed.length) out.log('No routes on the selected tab');
|
|
226
411
|
}
|
|
227
412
|
|
|
228
413
|
async function unroute(p, globs) {
|
|
@@ -246,8 +431,11 @@ const BODY_TIMEOUT = 15000;
|
|
|
246
431
|
const RECENT_HIDDEN_TYPES = new Set(['image', 'font', 'stylesheet', 'media']);
|
|
247
432
|
const recentLogs = new WeakMap();
|
|
248
433
|
const consoleLogs = new WeakMap();
|
|
249
|
-
|
|
434
|
+
// request -> how a route changed it (faked or patched), shown after its status.
|
|
435
|
+
const changedRequests = new WeakMap();
|
|
250
436
|
const requestEntries = new WeakMap();
|
|
437
|
+
// page -> when its page last fired its load event.
|
|
438
|
+
const loadedAt = new WeakMap();
|
|
251
439
|
// Tabs this REPL opened with tab new.
|
|
252
440
|
const openedTabs = new WeakSet();
|
|
253
441
|
|
|
@@ -275,7 +463,9 @@ function ensureRecentLog(p) {
|
|
|
275
463
|
recentLogs.set(p, log);
|
|
276
464
|
consoleLogs.set(p, logs);
|
|
277
465
|
p.on('request', req => {
|
|
278
|
-
|
|
466
|
+
let navigation = false;
|
|
467
|
+
try { navigation = req.isNavigationRequest() && req.frame() === p.mainFrame(); } catch {}
|
|
468
|
+
const entry = { id: nextId++, t: Date.now(), method: req.method(), url: req.url(), type: req.resourceType(), status: 'pending', ms: null, response: null, navigation };
|
|
279
469
|
requestEntries.set(req, entry);
|
|
280
470
|
keep(log, entry);
|
|
281
471
|
});
|
|
@@ -293,9 +483,10 @@ function ensureRecentLog(p) {
|
|
|
293
483
|
res = await req.response();
|
|
294
484
|
if (res) status = String(res.status());
|
|
295
485
|
} catch {}
|
|
296
|
-
finish(req,
|
|
486
|
+
finish(req, changedRequests.has(req) ? `${status} ${changedRequests.get(req)}` : status, res);
|
|
297
487
|
});
|
|
298
488
|
p.on('requestfailed', req => finish(req, `failed: ${req.failure()?.errorText || 'unknown'}`));
|
|
489
|
+
p.on('load', () => loadedAt.set(p, Date.now()));
|
|
299
490
|
p.on('console', msg => keep(logs, { t: Date.now(), type: msg.type(), text: clipText(consoleText(msg)) }));
|
|
300
491
|
// Uncaught exceptions never reach the console event.
|
|
301
492
|
p.on('pageerror', error => keep(logs, { t: Date.now(), type: 'pageerror', text: clipText(error.stack || error.message) }));
|
|
@@ -675,7 +866,10 @@ function stopWatching(p, watch) {
|
|
|
675
866
|
function activeModes(p) {
|
|
676
867
|
const modes = [];
|
|
677
868
|
if (watches.get(p)?.on) modes.push('watch');
|
|
678
|
-
|
|
869
|
+
const network = networkChanged.get(p);
|
|
870
|
+
if (network) modes.push(network.offline ? 'network:off' : 'network:slow');
|
|
871
|
+
const emulated = emulatedKinds(emulations.get(p));
|
|
872
|
+
if (emulated.length) modes.push(`emulate:${emulated.join(',')}`);
|
|
679
873
|
const routes = pageRoutes.get(p)?.size;
|
|
680
874
|
if (routes) modes.push(`routes:${routes}`);
|
|
681
875
|
if (cap && cap.page === p) modes.push('capture');
|
|
@@ -693,6 +887,7 @@ function listModes() {
|
|
|
693
887
|
['capture on', 'record requests and console messages together'],
|
|
694
888
|
['route <url-glob> <status> <json-body>', 'fake a response'],
|
|
695
889
|
['network off', 'cut the tab\'s network'],
|
|
890
|
+
['emulate mobile', 'show the tab as a phone would'],
|
|
696
891
|
]));
|
|
697
892
|
return;
|
|
698
893
|
}
|
|
@@ -717,7 +912,8 @@ async function allModesOff() {
|
|
|
717
912
|
if (cap && cap.page === p) { endCapture(); done.push('capture off (capture shows it)'); }
|
|
718
913
|
const globs = [...(pageRoutes.get(p)?.keys() || [])];
|
|
719
914
|
if (globs.length) { await unroute(p, globs); done.push(`${globs.length} route${globs.length === 1 ? '' : 's'} removed`); }
|
|
720
|
-
if (
|
|
915
|
+
if (networkChanged.has(p)) { await setNetwork(p, null); done.push('network on'); }
|
|
916
|
+
if (emulations.has(p)) { await setEmulation(p, {}); done.push('emulate off'); }
|
|
721
917
|
} catch (error) {
|
|
722
918
|
problem = error.message;
|
|
723
919
|
}
|
|
@@ -728,6 +924,19 @@ async function allModesOff() {
|
|
|
728
924
|
if (!any) out.log('No modes were on.');
|
|
729
925
|
}
|
|
730
926
|
|
|
927
|
+
// The type a page sees for a file it is given, from its extension.
|
|
928
|
+
const MIME_TYPES = {
|
|
929
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
|
930
|
+
pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', json: 'application/json', html: 'text/html',
|
|
931
|
+
xml: 'application/xml', zip: 'application/zip', mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg',
|
|
932
|
+
wav: 'audio/wav', doc: 'application/msword', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
933
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
function mimeType(file) {
|
|
937
|
+
return MIME_TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
|
|
938
|
+
}
|
|
939
|
+
|
|
731
940
|
// Commands that take only a selector take the whole line, spaces and all.
|
|
732
941
|
function soleSelector(args, usage) {
|
|
733
942
|
const text = unquote((args || '').trim());
|
|
@@ -790,6 +999,35 @@ async function waitForRequest(pattern, timeout) {
|
|
|
790
999
|
}
|
|
791
1000
|
}
|
|
792
1001
|
|
|
1002
|
+
// Done once the page has loaded: after the latest navigation of the tab that
|
|
1003
|
+
// began since the previous command did (the click on a link, say), or, with
|
|
1004
|
+
// none, the document there now. Waiting on Playwright's load state alone would
|
|
1005
|
+
// see the page being left, which has already loaded.
|
|
1006
|
+
async function waitForLoad(timeout) {
|
|
1007
|
+
const p = state.page;
|
|
1008
|
+
const since = state.previousCommandAt || 0;
|
|
1009
|
+
const deadline = Date.now() + timeout;
|
|
1010
|
+
for (;;) {
|
|
1011
|
+
const navigation = (recentLogs.get(p) || []).filter(e => e.navigation && e.t >= since).pop();
|
|
1012
|
+
if (navigation && navigation.status.startsWith('failed')) throw new Error(`The page did not load: #${navigation.id} ${navigation.url} ${navigation.status}`);
|
|
1013
|
+
const loaded = navigation ? (loadedAt.get(p) || 0) >= navigation.t : await p.evaluate(() => document.readyState === 'complete').catch(() => false);
|
|
1014
|
+
if (loaded) { out.log(`Loaded: ${p.url()} — ${await p.title().catch(() => '')}`); return; }
|
|
1015
|
+
if (Date.now() >= deadline) throw new Error(`${p.url()} did not finish loading within ${timeout / 1000}s`);
|
|
1016
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Gone once no match is visible, whether it was removed or hidden.
|
|
1021
|
+
async function waitGone(locator, what, timeout) {
|
|
1022
|
+
try {
|
|
1023
|
+
await locator.filter({ visible: true }).first().waitFor({ state: 'detached', timeout });
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (/Timeout \d+ms exceeded/.test(error.message)) throw new Error(`Still visible after ${timeout / 1000}s: ${what}`);
|
|
1026
|
+
throw error;
|
|
1027
|
+
}
|
|
1028
|
+
out.log(`Gone: ${what}`);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
793
1031
|
const SNAPSHOT_HINT_LINES = 60;
|
|
794
1032
|
|
|
795
1033
|
// Each matching line (any part of it: role, name, flags such as [disabled]),
|
|
@@ -1089,6 +1327,32 @@ const commands = {
|
|
|
1089
1327
|
out.log(`Unchecked: ${args.trim()}`);
|
|
1090
1328
|
},
|
|
1091
1329
|
|
|
1330
|
+
// The files are read here and handed to the page as their contents, not
|
|
1331
|
+
// their paths: the browser may run on another machine, or in another
|
|
1332
|
+
// container, where the paths mean nothing.
|
|
1333
|
+
async upload(args) {
|
|
1334
|
+
const parsed = splitSelector(args);
|
|
1335
|
+
if (!parsed || parsed.rest === undefined) throw new Error('Usage: upload <selector> <file>..., e.g. upload "input[type=file]" photo.png; quote a path with spaces');
|
|
1336
|
+
const files = [...parsed.rest.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)].map(m => path.resolve(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : m[2] ?? m[3]));
|
|
1337
|
+
const payloads = files.map(file => {
|
|
1338
|
+
let buffer;
|
|
1339
|
+
try { buffer = fs.readFileSync(file); } catch (error) { throw new Error(`Cannot read ${file}: ${error.code === 'ENOENT' ? 'no such file' : error.code === 'EISDIR' ? 'it is a folder' : error.message}`); }
|
|
1340
|
+
return { name: path.basename(file), mimeType: mimeType(file), buffer };
|
|
1341
|
+
});
|
|
1342
|
+
const { selector } = parsed;
|
|
1343
|
+
await onElement(selector, async () => {
|
|
1344
|
+
try {
|
|
1345
|
+
await state.page.setInputFiles(selector, payloads, { timeout: 5000 });
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (!/not an HTMLInputElement/.test(error.message)) throw error;
|
|
1348
|
+
// A button that opens the file picker itself: click it, and answer the picker.
|
|
1349
|
+
const [chooser] = await Promise.all([state.page.waitForEvent('filechooser', { timeout: 5000 }), state.page.click(selector, { timeout: 5000 })]);
|
|
1350
|
+
await chooser.setFiles(payloads);
|
|
1351
|
+
}
|
|
1352
|
+
});
|
|
1353
|
+
out.log(`Chose ${files.length === 1 ? 'a file' : `${files.length} files`} in ${parsed.word}: ${files.join(', ')}`);
|
|
1354
|
+
},
|
|
1355
|
+
|
|
1092
1356
|
async text(args, all) {
|
|
1093
1357
|
const selector = soleSelector(args, 'text <selector>');
|
|
1094
1358
|
printOutput(await onElement(selector, () => state.page.innerText(selector, { timeout: 5000 })), all);
|
|
@@ -1109,6 +1373,36 @@ const commands = {
|
|
|
1109
1373
|
printOutput(result, all);
|
|
1110
1374
|
},
|
|
1111
1375
|
|
|
1376
|
+
// The page's own event listeners on an element, which only DevTools can
|
|
1377
|
+
// list: the element is handed to a CDP session through the page, so any
|
|
1378
|
+
// Playwright selector works, and the session asks for its listeners.
|
|
1379
|
+
async listeners(args, all) {
|
|
1380
|
+
const text = (args || '').trim();
|
|
1381
|
+
const usage = 'listeners <selector> | listeners document | listeners window';
|
|
1382
|
+
if (!text) throw new Error(`Usage: ${usage}`);
|
|
1383
|
+
const which = text === 'document' || text === 'window' ? text : null;
|
|
1384
|
+
const selector = which ? null : soleSelector(args, usage);
|
|
1385
|
+
if (selector) await onElement(selector, () => state.page.locator(selector).first().evaluate(el => { window.__pwReplListenersOf = el; }, null, { timeout: 5000 }));
|
|
1386
|
+
const cdp = await state.page.context().newCDPSession(state.page);
|
|
1387
|
+
try {
|
|
1388
|
+
// Chrome includes each handler's source only for an object in a named group.
|
|
1389
|
+
const { result } = await cdp.send('Runtime.evaluate', { expression: which || 'window.__pwReplListenersOf', objectGroup: 'pw-repl-listeners' });
|
|
1390
|
+
if (!result.objectId) throw new Error(`No element matches ${text}`);
|
|
1391
|
+
const { listeners } = await cdp.send('DOMDebugger.getEventListeners', { objectId: result.objectId });
|
|
1392
|
+
if (!listeners.length) { out.log(`No event listeners on ${text}`); return; }
|
|
1393
|
+
const lines = listeners.map(l => {
|
|
1394
|
+
const how = [l.useCapture && 'capture', l.once && 'once', l.passive && 'passive'].filter(Boolean).join(', ');
|
|
1395
|
+
const handler = (l.handler?.description || '').replace(/\s+/g, ' ').slice(0, 100);
|
|
1396
|
+
return `${l.type}${how ? ` (${how})` : ''}: ${handler} (line ${l.lineNumber + 1})`;
|
|
1397
|
+
});
|
|
1398
|
+
printOutput(lines.join('\n'), all);
|
|
1399
|
+
} finally {
|
|
1400
|
+
await cdp.send('Runtime.evaluate', { expression: 'delete window.__pwReplListenersOf' }).catch(() => {});
|
|
1401
|
+
await cdp.send('Runtime.releaseObjectGroup', { objectGroup: 'pw-repl-listeners' }).catch(() => {});
|
|
1402
|
+
await cdp.detach().catch(() => {});
|
|
1403
|
+
}
|
|
1404
|
+
},
|
|
1405
|
+
|
|
1112
1406
|
async count(args) {
|
|
1113
1407
|
const selector = soleSelector(args, 'count <selector>');
|
|
1114
1408
|
const els = await state.page.$$(selector);
|
|
@@ -1202,7 +1496,7 @@ const commands = {
|
|
|
1202
1496
|
// Playwright's labels are e5, or frame-prefixed like f1e5 in newer versions.
|
|
1203
1497
|
const selector = toSelector(rest);
|
|
1204
1498
|
const target = selector ? state.page.locator(selector).first() : state.page;
|
|
1205
|
-
const raw = await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1499
|
+
const raw = selector ? await onElement(selector, () => target.ariaSnapshot({ mode: 'ai', timeout: 5000 })) : await target.ariaSnapshot({ mode: 'ai', timeout: 5000 });
|
|
1206
1500
|
const text = full ? raw : compactSnapshot(raw);
|
|
1207
1501
|
if (needle !== null) {
|
|
1208
1502
|
const hits = grepSnapshot(text, needle);
|
|
@@ -1218,28 +1512,82 @@ const commands = {
|
|
|
1218
1512
|
if (!args) { out.log(await viewportText()); return; }
|
|
1219
1513
|
const [w, h] = args.split('x').map(Number);
|
|
1220
1514
|
if (!w || !h) throw new Error('Usage: viewport <width>x<height>');
|
|
1515
|
+
// Both set the screen size; whichever came last would win without saying so.
|
|
1516
|
+
if (emulations.get(state.page)?.device) throw new Error('emulate mobile sets the size of the selected tab; emulate mobile off first');
|
|
1221
1517
|
await state.page.setViewportSize({ width: w, height: h });
|
|
1222
1518
|
out.log(`Viewport set to ${w}x${h}`);
|
|
1223
1519
|
},
|
|
1224
1520
|
|
|
1521
|
+
// Per tab, like network: the settings live in the tab's kept CDP session.
|
|
1522
|
+
async emulate(args) {
|
|
1523
|
+
const words = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1524
|
+
if (!words.length) return showEmulation();
|
|
1525
|
+
const usage = 'Usage: emulate [mobile [device] | dark | light | locale <tag> | timezone <zone>], emulate <what> off, emulate off';
|
|
1526
|
+
const [what, ...rest] = words;
|
|
1527
|
+
const value = rest.join(' ');
|
|
1528
|
+
const current = emulations.get(state.page) || {};
|
|
1529
|
+
if (what === 'off' && !rest.length) {
|
|
1530
|
+
const was = emulatedKinds(current);
|
|
1531
|
+
await setEmulation(state.page, {});
|
|
1532
|
+
out.log(was.length ? `Stopped emulating in the selected tab: ${was.join(', ')}` : 'Nothing was emulated in the selected tab.');
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
if (!EMULATE_KINDS.includes(what)) throw new Error(usage);
|
|
1536
|
+
const key = { mobile: 'device', dark: 'scheme', light: 'scheme', locale: 'locale', timezone: 'timezone' }[what];
|
|
1537
|
+
if (value === 'off') {
|
|
1538
|
+
await setEmulation(state.page, { ...current, [key]: undefined });
|
|
1539
|
+
out.log(`Stopped emulating ${key === 'scheme' ? 'the color scheme' : what} in the selected tab`);
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
let next;
|
|
1543
|
+
if (what === 'mobile') {
|
|
1544
|
+
next = deviceNamed(value || DEFAULT_DEVICE);
|
|
1545
|
+
} else if (what === 'dark' || what === 'light') {
|
|
1546
|
+
if (value) throw new Error(usage);
|
|
1547
|
+
next = what;
|
|
1548
|
+
} else if (!value || rest.length > 1) {
|
|
1549
|
+
throw new Error(`Usage: emulate ${what} ${what === 'locale' ? '<tag>, e.g. fr-FR' : '<zone>, e.g. Asia/Tokyo'}`);
|
|
1550
|
+
} else if (what === 'locale') {
|
|
1551
|
+
try { next = Intl.getCanonicalLocales(value)[0]; } catch { throw new Error(`Not a locale: ${value}; e.g. fr-FR, de, pt-BR`); }
|
|
1552
|
+
} else {
|
|
1553
|
+
// Checked, but kept as given: Node's names can be older ones (Asia/Calcutta for Asia/Kolkata).
|
|
1554
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: value }); } catch { throw new Error(`Not a timezone: ${value}; e.g. Asia/Tokyo, America/New_York, UTC`); }
|
|
1555
|
+
next = value;
|
|
1556
|
+
}
|
|
1557
|
+
await setEmulation(state.page, { ...current, [key]: next });
|
|
1558
|
+
const shown = what === 'mobile' ? `mobile (${deviceText(next)})` : what === 'dark' || what === 'light' ? `${what} mode` : `${what} ${next}`;
|
|
1559
|
+
out.log(`Emulating ${shown} in the selected tab`);
|
|
1560
|
+
// The page reads these when it loads; the rest applies at once.
|
|
1561
|
+
if (what === 'mobile' || what === 'locale') out.log('The page sees its user agent, touch and languages from its next load: reload to see all of it.');
|
|
1562
|
+
},
|
|
1563
|
+
|
|
1225
1564
|
async wait(args) {
|
|
1226
|
-
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob
|
|
1565
|
+
const usage = 'Usage: wait <selector> | wait text <text> | wait request <url-part|glob> | wait load, with optional [seconds]; --gone for a selector or text';
|
|
1227
1566
|
let tokens = (args || '').trim().split(/\s+/).filter(Boolean);
|
|
1567
|
+
const gone = tokens.includes('--gone');
|
|
1568
|
+
tokens = tokens.filter(t => t !== '--gone');
|
|
1228
1569
|
let seconds = WAIT_DEFAULT;
|
|
1229
1570
|
if (tokens.length > 1 && /^\d+$/.test(tokens[tokens.length - 1])) seconds = Number(tokens.pop());
|
|
1230
1571
|
if (!tokens.length || seconds < 1 || seconds > WAIT_MAX) throw new Error(`${usage} (1-${WAIT_MAX})`);
|
|
1231
1572
|
const timeout = seconds * 1000;
|
|
1232
1573
|
const kind = tokens[0];
|
|
1574
|
+
if (kind === 'load' && tokens.length === 1) {
|
|
1575
|
+
if (gone) throw new Error(usage);
|
|
1576
|
+
return waitForLoad(timeout);
|
|
1577
|
+
}
|
|
1233
1578
|
if ((kind === 'text' || kind === 'request') && tokens.length > 1) {
|
|
1234
1579
|
const what = tokens.slice(1).join(' ').replace(/^(["'])(.*)\1$/, '$2').replace(/\\"/g, '"');
|
|
1235
|
-
if (kind === '
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
return;
|
|
1580
|
+
if (kind === 'request') {
|
|
1581
|
+
if (gone) throw new Error(usage);
|
|
1582
|
+
return waitForRequest(what, timeout);
|
|
1239
1583
|
}
|
|
1240
|
-
return
|
|
1584
|
+
if (gone) return waitGone(state.page.getByText(what), what, timeout);
|
|
1585
|
+
await state.page.getByText(what).first().waitFor({ state: 'visible', timeout });
|
|
1586
|
+
out.log(`Visible: ${what}`);
|
|
1587
|
+
return;
|
|
1241
1588
|
}
|
|
1242
1589
|
const selector = toSelector(unquote(tokens.join(' ')));
|
|
1590
|
+
if (gone) return waitGone(state.page.locator(selector), tokens.join(' '), timeout);
|
|
1243
1591
|
await state.page.waitForSelector(selector, { state: 'attached', timeout });
|
|
1244
1592
|
out.log(`Found: ${tokens.join(' ')}`);
|
|
1245
1593
|
},
|
|
@@ -1253,22 +1601,28 @@ const commands = {
|
|
|
1253
1601
|
// A stopped upstream does not reach the browser as a failure: the dev proxy
|
|
1254
1602
|
// holds the request open instead of refusing it. Cutting the connection at
|
|
1255
1603
|
// the browser is what a visitor's wifi or VPN dropping looks like to the page.
|
|
1256
|
-
// The CDP session is kept alive because emulation resets when it detaches.
|
|
1257
1604
|
async network(args) {
|
|
1258
|
-
const
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
out.log('The selected tab\'s network is on.');
|
|
1265
|
-
out.log(hints([['network off', 'cut it, like dropped wifi']]));
|
|
1266
|
-
}
|
|
1605
|
+
const words = (args || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1606
|
+
const setting = networkChanged.get(state.page);
|
|
1607
|
+
if (!words.length) {
|
|
1608
|
+
out.log(`The selected tab's network is ${setting ? networkText(setting) : 'on'}.`);
|
|
1609
|
+
if (setting) out.log(hints([['network on', 'restore it']]));
|
|
1610
|
+
else out.log(hints([['network off', 'cut it, like dropped wifi'], ['network slow [<ms> [<kbps>]]', `slow it (default ${networkText(SLOW_DEFAULT).slice(6, -1)})`]]));
|
|
1267
1611
|
return;
|
|
1268
1612
|
}
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1613
|
+
const [how, ...rest] = words;
|
|
1614
|
+
if ((how === 'on' || how === 'off') && !rest.length) {
|
|
1615
|
+
await setNetwork(state.page, how === 'on' ? null : { offline: true });
|
|
1616
|
+
out.log(`The selected tab's network is ${how}`);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const usage = `Usage: network [on | off | slow [<latency-ms> [<kbps>]]] (latency up to ${SLOW_LATENCY_MAX}ms)`;
|
|
1620
|
+
if (how !== 'slow' || rest.length > 2 || !rest.every(w => /^\d+$/.test(w))) throw new Error(usage);
|
|
1621
|
+
const [latency, kbps] = rest.map(Number);
|
|
1622
|
+
if (latency > SLOW_LATENCY_MAX || kbps === 0) throw new Error(usage);
|
|
1623
|
+
const slow = rest.length ? { latency, down: kbps || SLOW_DEFAULT.down, up: kbps || SLOW_DEFAULT.up } : SLOW_DEFAULT;
|
|
1624
|
+
await setNetwork(state.page, slow);
|
|
1625
|
+
out.log(`The selected tab's network is ${networkText(slow)}`);
|
|
1272
1626
|
},
|
|
1273
1627
|
|
|
1274
1628
|
// Fulfilled inside the browser, so the page's own code handles the fake
|
|
@@ -1278,35 +1632,28 @@ const commands = {
|
|
|
1278
1632
|
if (!trimmed) return listRoutes();
|
|
1279
1633
|
const off = /^off(?:\s+(\S+))?$/.exec(trimmed);
|
|
1280
1634
|
if (off) return removeRoutes(off[1]);
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
try { JSON.parse(body); } catch (error) { throw new Error(`Body is not valid JSON: ${error.message}`); }
|
|
1635
|
+
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';
|
|
1636
|
+
const match = /^(\S+)\s+(\S+)(?:\s+([\s\S]+))?$/.exec(trimmed);
|
|
1637
|
+
if (!match) throw new Error(usage);
|
|
1638
|
+
const [, glob, how, rest] = match;
|
|
1639
|
+
const route = routeKind(how, rest, usage);
|
|
1287
1640
|
const routes = routesFor(state.page);
|
|
1288
1641
|
const previous = routes.get(glob);
|
|
1289
1642
|
if (previous) await state.page.unroute(glob, previous.handler);
|
|
1290
1643
|
const handler = async r => {
|
|
1291
1644
|
const req = r.request();
|
|
1292
|
-
|
|
1645
|
+
const tag = () => { const id = requestEntries.get(req)?.id; return `${id ? `#${id} ` : ''}${req.method()} ${req.url()}`; };
|
|
1293
1646
|
try {
|
|
1294
|
-
await
|
|
1295
|
-
// Recorded now: the browser reports the request finished only later.
|
|
1296
|
-
const entry = requestEntries.get(req);
|
|
1297
|
-
if (entry) { entry.status = `${status} faked`; entry.ms = Date.now() - entry.t; }
|
|
1298
|
-
const id = entry?.id;
|
|
1299
|
-
out.notice(`Faked: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} -> ${status}`);
|
|
1647
|
+
await route.handle(r, req, tag);
|
|
1300
1648
|
} catch (error) {
|
|
1301
|
-
// Aborted, never continued: a request meant to be
|
|
1649
|
+
// Aborted, never continued: a request meant to be changed must not reach the page as it was.
|
|
1302
1650
|
await r.abort().catch(() => {});
|
|
1303
|
-
|
|
1304
|
-
out.notice(`Fake failed: ${id ? `#${id} ` : ''}${req.method()} ${req.url()} — ${error.message}; the request was aborted`);
|
|
1651
|
+
out.notice(`Route failed: ${tag()} — ${error.message}; the request was aborted`);
|
|
1305
1652
|
}
|
|
1306
1653
|
};
|
|
1307
1654
|
await state.page.route(glob, handler);
|
|
1308
|
-
routes.set(glob, {
|
|
1309
|
-
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${
|
|
1655
|
+
routes.set(glob, { text: route.text, handler });
|
|
1656
|
+
out.log(`${previous ? 'Replaced' : 'Routed'}: ${glob} -> ${route.text}`);
|
|
1310
1657
|
},
|
|
1311
1658
|
|
|
1312
1659
|
async requests(args) {
|
|
@@ -1340,7 +1687,7 @@ const commands = {
|
|
|
1340
1687
|
const entry = (recentLogs.get(state.page) || []).find(e => e.id === id);
|
|
1341
1688
|
if (!entry) throw new Error(`No request #${id} on the selected tab; requests lists them`);
|
|
1342
1689
|
// A fake is marked answered before the browser hands over its response.
|
|
1343
|
-
for (let waited = 0; !entry.response && /faked$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1690
|
+
for (let waited = 0; !entry.response && /(?:faked|patched)$/.test(entry.status) && waited < 2000; waited += 50) {
|
|
1344
1691
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
1345
1692
|
}
|
|
1346
1693
|
if (!entry.response) throw new Error(`#${id} has no response${entry.status === 'pending' ? ' yet' : ` (${entry.status})`}`);
|
|
@@ -1511,6 +1858,7 @@ const commands = {
|
|
|
1511
1858
|
};
|
|
1512
1859
|
|
|
1513
1860
|
onShutdown(discardCapture);
|
|
1861
|
+
onShutdown(resetEmulations);
|
|
1514
1862
|
|
|
1515
1863
|
// Hooks every page needs from the moment the REPL sees it.
|
|
1516
1864
|
function watchPage(p) {
|
|
@@ -1533,7 +1881,9 @@ function complete(line) {
|
|
|
1533
1881
|
if (words.length === 2) {
|
|
1534
1882
|
if (command === 'help') return match(['--all', ...Object.keys(HELP.TOPICS), ...Object.keys(HELP.COMMANDS).sort()]);
|
|
1535
1883
|
if (command === 'tab') return match(['new', 'close']);
|
|
1536
|
-
if (command === 'network') return match(['on', 'off']);
|
|
1884
|
+
if (command === 'network') return match(['on', 'off', 'slow']);
|
|
1885
|
+
if (command === 'emulate') return match([...EMULATE_KINDS, 'off']);
|
|
1886
|
+
if (command === 'wait') return match(['text', 'request', 'load']);
|
|
1537
1887
|
if (command === 'modes') return match(['off']);
|
|
1538
1888
|
if (command === 'dialog') return match(['accept', 'dismiss']);
|
|
1539
1889
|
if (command === 'watch') return match(['on', 'off', 'new', '--all']);
|
|
@@ -1542,6 +1892,7 @@ function complete(line) {
|
|
|
1542
1892
|
}
|
|
1543
1893
|
if (words.length >= 3 && command === 'watch' && words[1] === 'on') return match(['--changes', '--live'].filter(f => !words.slice(2, -1).includes(f)));
|
|
1544
1894
|
if (words.length === 3 && command === 'capture' && words[1] === 'on') return match(['requests', 'console']);
|
|
1895
|
+
if (words.length === 3 && command === 'emulate' && EMULATE_KINDS.includes(words[1])) return match(['off']);
|
|
1545
1896
|
if (words.length === 3 && command === 'route' && words[1] === 'off') return match(['--all', ...(state.page ? routesFor(state.page).keys() : [])]);
|
|
1546
1897
|
return [[], current];
|
|
1547
1898
|
}
|