pw-repl 0.9.0 → 0.11.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 +2 -1
- package/README.md +23 -16
- package/bin/pw-repl.js +12 -1
- package/lib/background.js +14 -4
- package/lib/cdp.js +6 -0
- package/lib/cli-names.js +1 -1
- package/lib/client.js +3 -2
- package/lib/commands.js +3 -0
- package/lib/cursor.js +36 -50
- package/lib/elements.js +133 -18
- package/lib/emulation.js +13 -1
- package/lib/help.js +108 -29
- package/lib/inspect.js +65 -6
- package/lib/modes.js +11 -2
- package/lib/output.js +1 -1
- package/lib/overlay.js +79 -0
- package/lib/record.js +15 -11
- package/lib/runner.js +16 -9
- package/lib/send.js +27 -4
- package/lib/server.js +12 -4
- package/lib/skill.js +1 -1
- package/lib/start.js +4 -1
- package/lib/syntax.js +30 -1
- package/lib/tabs.js +24 -8
- package/lib/toast.js +166 -0
- package/lib/watch.js +210 -29
- package/package.json +1 -1
- package/skill/SKILL.md +73 -56
package/lib/elements.js
CHANGED
|
@@ -9,6 +9,7 @@ const { numbers, hints } = require('./util');
|
|
|
9
9
|
const { tabState } = require('./tabstate');
|
|
10
10
|
const { cursorOf, aiming, aimAt, glideTo, showClick, showSelect } = require('./cursor');
|
|
11
11
|
const { recordingOf, stepTarget } = require('./record');
|
|
12
|
+
const { settled } = require('./emulation');
|
|
12
13
|
|
|
13
14
|
// The type a page sees for a file it is given, from its extension.
|
|
14
15
|
const MIME_TYPES = {
|
|
@@ -196,10 +197,16 @@ async function pointerAction(selector, act, options = {}, clicks = 0) {
|
|
|
196
197
|
return '';
|
|
197
198
|
}
|
|
198
199
|
try {
|
|
200
|
+
// The first of several is named, as it may not be the one meant (a heading with a button's text);
|
|
201
|
+
// before acting, since a click may take the page away.
|
|
202
|
+
const what = chosen.position === 1
|
|
203
|
+
? await chosen.handle.evaluate(el => `<${el.tagName.toLowerCase()}> ${(el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 60)}`).catch(() => null)
|
|
204
|
+
: null;
|
|
199
205
|
const point = await aimAt(state.page, chosen.handle, left());
|
|
200
206
|
await onElement(selector, () => act(chosen.handle, null, { ...options, timeout: left() }));
|
|
201
207
|
await landed(point, clicks);
|
|
202
|
-
|
|
208
|
+
if (chosen.position !== 1) return ` (match ${chosen.position} of ${chosen.count}; the ones before it are hidden or covered)`;
|
|
209
|
+
return ` (the first of ${chosen.count} matches${what ? `: ${what}` : ''})`;
|
|
203
210
|
} catch (error) {
|
|
204
211
|
if (!/not attached/i.test(error.message) || Date.now() >= deadline) throw error;
|
|
205
212
|
} finally {
|
|
@@ -282,7 +289,7 @@ async function hideHighlights(p, mine = false) {
|
|
|
282
289
|
if (!highlights?.size) return 0;
|
|
283
290
|
const hidden = [...highlights].filter(([, h]) => !mine || (h.owner ?? null) === (state.client ?? null));
|
|
284
291
|
if (hidden.length === highlights.size) await p.hideHighlight();
|
|
285
|
-
else for (const [selector] of hidden) await p.locator(selector).hideHighlight();
|
|
292
|
+
else for (const [selector, h] of hidden) await (h.drawn || p.locator(selector)).hideHighlight();
|
|
286
293
|
for (const [selector] of hidden) highlights.delete(selector);
|
|
287
294
|
return hidden.length;
|
|
288
295
|
}
|
|
@@ -293,6 +300,85 @@ onShutdown(async () => {
|
|
|
293
300
|
await Promise.all(pages.map(p => hideHighlights(p).catch(() => {})));
|
|
294
301
|
});
|
|
295
302
|
|
|
303
|
+
// Playwright's highlight labels each box with its locator, which covers what is below it, and has
|
|
304
|
+
// no option not to; highlights are drawn without it unless highlight --labels on. Its overlay is an
|
|
305
|
+
// x-pw-glass element with a closed shadow root, which only CDP reaches: a stylesheet adopted there
|
|
306
|
+
// hides the labels, under any CSP, and stays as Playwright redraws them. Playwright removes the
|
|
307
|
+
// overlay with its last highlight, and the sheet with it, so it is applied again after each one.
|
|
308
|
+
const LABELS_OFF = 'x-pw-tooltip:not([data-pw-repl])';
|
|
309
|
+
// The clients told that the labels could not be hidden, each once.
|
|
310
|
+
const labelsWarned = new Set();
|
|
311
|
+
|
|
312
|
+
// Adds or removes the sheet in the main document's overlay, and says whether it worked: false when
|
|
313
|
+
// there is an overlay but its labels are not hidden. Null when there is no overlay.
|
|
314
|
+
async function applyLabels(p, hide) {
|
|
315
|
+
const session = await p.context().newCDPSession(p);
|
|
316
|
+
try {
|
|
317
|
+
const { root } = await session.send('DOM.getDocument', { depth: 0 });
|
|
318
|
+
const { nodeId } = await session.send('DOM.querySelector', { nodeId: root.nodeId, selector: 'x-pw-glass' });
|
|
319
|
+
if (!nodeId) return null;
|
|
320
|
+
const { node } = await session.send('DOM.describeNode', { nodeId, pierce: true });
|
|
321
|
+
const shadow = node.shadowRoots?.[0];
|
|
322
|
+
if (!shadow) return false;
|
|
323
|
+
const { object } = await session.send('DOM.resolveNode', { backendNodeId: shadow.backendNodeId });
|
|
324
|
+
const call = (fn, args) => session.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration: fn.toString(), arguments: args.map(value => ({ value })), returnByValue: true });
|
|
325
|
+
await call(function (rule, hidden) {
|
|
326
|
+
const sheets = [...this.adoptedStyleSheets].filter(s => ![...s.cssRules].some(r => r.selectorText === rule));
|
|
327
|
+
if (hidden) {
|
|
328
|
+
const sheet = new CSSStyleSheet();
|
|
329
|
+
sheet.replaceSync(`${rule} { display: none !important; }`);
|
|
330
|
+
sheets.push(sheet);
|
|
331
|
+
}
|
|
332
|
+
this.adoptedStyleSheets = sheets;
|
|
333
|
+
}, [LABELS_OFF, hide]);
|
|
334
|
+
if (!hide) return true;
|
|
335
|
+
// Playwright draws its labels on a later frame.
|
|
336
|
+
await settled(p);
|
|
337
|
+
const { result } = await call(function (rule) {
|
|
338
|
+
const ours = [...this.adoptedStyleSheets].some(s => [...s.cssRules].some(r => r.selectorText === rule));
|
|
339
|
+
return ours && [...this.querySelectorAll('x-pw-tooltip')].every(t => getComputedStyle(t).display === 'none');
|
|
340
|
+
}, [LABELS_OFF]);
|
|
341
|
+
return result.value === true;
|
|
342
|
+
} finally {
|
|
343
|
+
await session.detach().catch(() => {});
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Hides the tab's labels unless they are on; says so once if that failed, and the highlight is drawn
|
|
348
|
+
// with its label as Playwright draws it. A CDP call that fails, as one does when the tab navigates or
|
|
349
|
+
// closes, says nothing of Playwright's overlay: only the check finding the labels shown does.
|
|
350
|
+
async function keepLabels(p) {
|
|
351
|
+
if (tabState(p).labelsOn) return;
|
|
352
|
+
const worked = await applyLabels(p, true).catch(() => null);
|
|
353
|
+
const client = state.client ?? null;
|
|
354
|
+
if (worked !== false || labelsWarned.has(client)) return;
|
|
355
|
+
labelsWarned.add(client);
|
|
356
|
+
out.log(`highlight: could not hide the labels; Playwright's highlight has changed (playwright-core ${require('playwright-core/package.json').version}). The boxes are drawn with their labels.`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function setLabels(p, on) {
|
|
360
|
+
if (on) tabState(p).labelsOn = { owner: state.client }; else delete tabState(p).labelsOn;
|
|
361
|
+
if (on) await applyLabels(p, false); else await keepLabels(p);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// A date or time input takes keys part by part, in the order it shows them (10/02/2026 in en-US), so
|
|
365
|
+
// its ISO form, typed, comes out empty or mangled; fill sets it from that.
|
|
366
|
+
const DATE_INPUTS = {
|
|
367
|
+
date: [/^\d{4,}-\d\d-\d\d$/, '2026-10-02'],
|
|
368
|
+
time: [/^\d\d:\d\d(:\d\d(\.\d+)?)?$/, '14:30'],
|
|
369
|
+
'datetime-local': [/^\d{4,}-\d\d-\d\d[T ]\d\d:\d\d(:\d\d(\.\d+)?)?$/, '2026-10-02T14:30'],
|
|
370
|
+
month: [/^\d{4,}-\d\d$/, '2026-10'],
|
|
371
|
+
week: [/^\d{4,}-W\d\d$/, '2026-W40'],
|
|
372
|
+
};
|
|
373
|
+
function refuseIsoDate(kind, text, what, selector) {
|
|
374
|
+
if (DATE_INPUTS[kind]?.[0].test(text)) throw new Error(`${what} is a ${kind} input: typed, ${text} comes out mangled, since it takes keys part by part in the order it shows them. fill ${selector} ${text} sets it`);
|
|
375
|
+
}
|
|
376
|
+
function dateInputHolds(kind, value, what, selector) {
|
|
377
|
+
if (!DATE_INPUTS[kind]) return '';
|
|
378
|
+
if (!value) throw new Error(`${what}, a ${kind} input, is still empty: it takes keys part by part, in the order it shows them. fill sets it from its ISO form: fill ${selector} ${DATE_INPUTS[kind][1]}`);
|
|
379
|
+
return `, a ${kind} input, which now holds ${value}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
296
382
|
const commands = {
|
|
297
383
|
async click(args) {
|
|
298
384
|
const { selector, shown, options } = clickArgs(args, 'click <selector> [left|right|middle] [--modifiers=<key>[,<key>]]');
|
|
@@ -313,15 +399,27 @@ const commands = {
|
|
|
313
399
|
},
|
|
314
400
|
|
|
315
401
|
async highlight(args) {
|
|
316
|
-
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | off]';
|
|
317
|
-
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag' });
|
|
402
|
+
const usage = 'Usage: highlight [<selector> [--style=<css>] | --hide [<selector>] | --labels off|on | off]';
|
|
403
|
+
const { found, rest } = takeOptions((args || '').trim(), { style: 'value', hide: 'flag', labels: 'value' });
|
|
318
404
|
const style = found.find(o => o.name === 'style');
|
|
319
405
|
const hide = found.some(o => o.name === 'hide');
|
|
406
|
+
const labels = found.find(o => o.name === 'labels');
|
|
320
407
|
const highlights = highlightsOf(state.page);
|
|
408
|
+
// For the whole tab, with a highlight to draw or on its own.
|
|
409
|
+
if (labels) {
|
|
410
|
+
if (hide || rest === 'off' || !['off', 'on'].includes(labels.value)) throw new Error(usage);
|
|
411
|
+
await setLabels(state.page, labels.value === 'on');
|
|
412
|
+
out.log(labels.value === 'on'
|
|
413
|
+
? 'Labels on in the selected tab: each highlight is labelled with its locator; highlight --labels off hides them'
|
|
414
|
+
: 'Labels off in the selected tab');
|
|
415
|
+
if (!rest) return;
|
|
416
|
+
}
|
|
321
417
|
if (!rest && !found.length) {
|
|
322
418
|
if (!highlights.size) { out.log(`Nothing is highlighted in the selected tab.\n${hints([['highlight <selector>', 'draw a box around it']])}`); return; }
|
|
323
|
-
for (const [selector, h] of highlights) out.log(`${selector}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
324
|
-
|
|
419
|
+
for (const [selector, h] of highlights) out.log(`${selector}${h.drawn?.toString() !== state.page.locator(selector).toString() ? ` as ${h.drawn}` : ''}${h.style ? ` --style=${JSON.stringify(h.style)}` : ''}${h.owner ? ` (by ${h.owner})` : ''}`);
|
|
420
|
+
const on = tabState(state.page).labelsOn;
|
|
421
|
+
if (on) out.log(`Labels are on${on.owner ? ` (by ${on.owner})` : ''}.`);
|
|
422
|
+
out.log(hints([['highlight --hide [<selector>]', 'hide one, or all'], on ? ['highlight --labels off', 'hide their labels'] : ['highlight --labels on', 'label each with its locator']]));
|
|
325
423
|
return;
|
|
326
424
|
}
|
|
327
425
|
if (rest === 'off' && !found.length || hide && !rest) {
|
|
@@ -334,7 +432,7 @@ const commands = {
|
|
|
334
432
|
const locator = state.page.locator(selector);
|
|
335
433
|
if (hide) {
|
|
336
434
|
if (!highlights.has(selector)) throw new Error(`${rest} is not highlighted; highlight lists what is`);
|
|
337
|
-
await locator.hideHighlight();
|
|
435
|
+
await (highlights.get(selector).drawn || locator).hideHighlight();
|
|
338
436
|
highlights.delete(selector);
|
|
339
437
|
out.log(`Hid the highlight on ${rest}`);
|
|
340
438
|
return;
|
|
@@ -342,9 +440,13 @@ const commands = {
|
|
|
342
440
|
const count = await locator.count();
|
|
343
441
|
if (!count) throw new Error(`No element matches ${rest}; nothing was highlighted`);
|
|
344
442
|
// Drawn again, with its new style, rather than twice.
|
|
345
|
-
if (highlights.has(selector)) await locator.hideHighlight();
|
|
346
|
-
|
|
347
|
-
|
|
443
|
+
if (highlights.has(selector)) await (highlights.get(selector).drawn || locator).hideHighlight();
|
|
444
|
+
// Playwright labels a box with the locator it is given, and a snapshot ref's (aria-ref=e5) finds
|
|
445
|
+
// nothing in the page's code: one is drawn with the locator Playwright makes for its element.
|
|
446
|
+
const drawn = REF.test(rest) ? await locator.normalize().catch(() => locator) : locator;
|
|
447
|
+
await drawn.highlight(style ? { style: style.value } : {});
|
|
448
|
+
await keepLabels(state.page);
|
|
449
|
+
highlights.set(selector, { owner: state.client, style: style?.value || null, drawn });
|
|
348
450
|
out.log(`Highlighted ${rest}${count > 1 ? ` (all ${count} matches)` : ''}; highlight --hide ${rest} hides it`);
|
|
349
451
|
},
|
|
350
452
|
|
|
@@ -404,25 +506,34 @@ const commands = {
|
|
|
404
506
|
if (sole && sole.rest === undefined) {
|
|
405
507
|
// Named, since it may not be the element meant; one that takes no text is refused.
|
|
406
508
|
const focused = await state.page.evaluate(() => {
|
|
407
|
-
|
|
509
|
+
let el = document.activeElement;
|
|
510
|
+
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
|
408
511
|
if (!el || el === document.body || el === document.documentElement) return null;
|
|
409
512
|
const editable = el.isContentEditable || el.matches('textarea, input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=file],[type=range],[type=color],[type=hidden])');
|
|
410
513
|
const id = el.id ? `#${el.id}` : el.name ? `[name=${el.name}]` : el.placeholder ? `[placeholder="${el.placeholder}"]` : '';
|
|
411
|
-
return { editable, name: `${el.tagName.toLowerCase()}${id}
|
|
514
|
+
return { editable, name: `${el.tagName.toLowerCase()}${id}`, type: el.type };
|
|
412
515
|
});
|
|
413
516
|
const usage = 'type <selector> <text> types into an element, e.g. type #search garden hose';
|
|
414
517
|
if (!focused) throw new Error(`Nothing on the page has focus: ${usage}`);
|
|
415
518
|
if (!focused.editable) throw new Error(`The focused element, ${focused.name}, takes no text: ${usage}`);
|
|
519
|
+
refuseIsoDate(focused.type, sole.word, `The focused ${focused.name}`, '<selector>');
|
|
416
520
|
await state.page.keyboard.type(sole.word, { delay: delay() });
|
|
521
|
+
const held = DATE_INPUTS[focused.type] ? dateInputHolds(focused.type, await state.page.evaluate(() => {
|
|
522
|
+
let el = document.activeElement;
|
|
523
|
+
while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
|
|
524
|
+
return el?.value;
|
|
525
|
+
}), `The focused ${focused.name}`, '<selector>') : '';
|
|
417
526
|
if (submit) await state.page.keyboard.press('Enter');
|
|
418
|
-
out.log(`Typed into the focused ${focused.name}${submit ? ', and submitted' : ''}`);
|
|
527
|
+
out.log(`Typed into the focused ${focused.name}${held}${submit ? ', and submitted' : ''}`);
|
|
419
528
|
return;
|
|
420
529
|
}
|
|
421
530
|
const { selector, value, shown } = selectorAndValue(args, 'type [<selector>] <text> [--submit]', 'type #search garden hose');
|
|
422
531
|
const timeout = await aimFor(selector);
|
|
423
532
|
// Typed after what the field already holds, as someone clicking into it
|
|
424
533
|
// at the end would; focusing it alone leaves the caret at the start.
|
|
425
|
-
await onElement(selector, () => state.page.locator(selector).first().evaluate(el => {
|
|
534
|
+
const kind = await onElement(selector, () => state.page.locator(selector).first().evaluate((el, dateKinds) => {
|
|
535
|
+
// Focused again, so a date input already focused takes keys from its first part, not where it was left.
|
|
536
|
+
if (dateKinds.includes(el.type) && el.matches(':focus')) el.blur();
|
|
426
537
|
el.focus();
|
|
427
538
|
if (typeof el.value === 'string' && typeof el.setSelectionRange === 'function') {
|
|
428
539
|
try { el.setSelectionRange(el.value.length, el.value.length); } catch {}
|
|
@@ -433,10 +544,14 @@ const commands = {
|
|
|
433
544
|
getSelection().removeAllRanges();
|
|
434
545
|
getSelection().addRange(range);
|
|
435
546
|
}
|
|
436
|
-
|
|
547
|
+
return el.type;
|
|
548
|
+
}, Object.keys(DATE_INPUTS), { timeout }));
|
|
549
|
+
const quoted = /\s/.test(shown) ? `"${shown}"` : shown;
|
|
550
|
+
refuseIsoDate(kind, value, shown, quoted);
|
|
437
551
|
await state.page.keyboard.type(value, { delay: delay() });
|
|
552
|
+
const held = DATE_INPUTS[kind] ? dateInputHolds(kind, await onElement(selector, () => state.page.locator(selector).first().evaluate(el => el.value, null, { timeout: 5000 })), shown, quoted) : '';
|
|
438
553
|
if (submit) await state.page.keyboard.press('Enter');
|
|
439
|
-
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}`);
|
|
554
|
+
out.log(`Typed into${submit ? ' and submitted' : ''}: ${shown}${held}`);
|
|
440
555
|
},
|
|
441
556
|
|
|
442
557
|
async press(args) {
|
|
@@ -483,7 +598,7 @@ const commands = {
|
|
|
483
598
|
async upload(args) {
|
|
484
599
|
const parsed = splitSelector(args);
|
|
485
600
|
// playwright-cli's upload <file> answers a file picker a click opened. Here
|
|
486
|
-
// the input is named instead: catching pickers would catch the
|
|
601
|
+
// the input is named instead: catching pickers would catch the user's too.
|
|
487
602
|
const usage = 'Usage: upload <selector> <file>..., naming the file input (or the button that opens it) first, e.g. upload e12 ./doc.pdf; quote a path with spaces';
|
|
488
603
|
if (!parsed || parsed.rest === undefined) throw new Error(usage);
|
|
489
604
|
// A path, not a selector; // starts an XPath.
|
|
@@ -511,4 +626,4 @@ const commands = {
|
|
|
511
626
|
},
|
|
512
627
|
};
|
|
513
628
|
|
|
514
|
-
module.exports = { commands, soleSelector, onElement, hideHighlights };
|
|
629
|
+
module.exports = { commands, soleSelector, onElement, hideHighlights, setLabels };
|
package/lib/emulation.js
CHANGED
|
@@ -66,6 +66,18 @@ function settled(p) {
|
|
|
66
66
|
]);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Bringing a tab to the front can leave Chrome capturing it at the window's size after its next
|
|
70
|
+
// navigation, although the page is still laid out at its viewport's or phone's: sending the same
|
|
71
|
+
// metrics again, on the kept session, has it draw at them.
|
|
72
|
+
async function reapplyMetrics(p) {
|
|
73
|
+
const tab = tabState(p);
|
|
74
|
+
if (!tab.viewport && !tab.emulation?.device) return;
|
|
75
|
+
const session = await keptSession(p);
|
|
76
|
+
if (tab.viewport) await viewportMetrics(session, tab.viewport);
|
|
77
|
+
else if (tab.emulation?.device) await deviceMetrics(session, tab.emulation.device);
|
|
78
|
+
await settled(p);
|
|
79
|
+
}
|
|
80
|
+
|
|
69
81
|
async function setViewport(p, size) {
|
|
70
82
|
const session = await keptSession(p);
|
|
71
83
|
if (size) {
|
|
@@ -220,4 +232,4 @@ const commands = {
|
|
|
220
232
|
},
|
|
221
233
|
};
|
|
222
234
|
|
|
223
|
-
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled };
|
|
235
|
+
module.exports = { commands, viewportText, EMULATE_KINDS, emulatedKinds, deviceMetrics, viewportMetrics, setEmulation, setViewport, settled, reapplyMetrics };
|
package/lib/help.js
CHANGED
|
@@ -23,7 +23,7 @@ function wrap(text, width = WIDTH) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const OVERVIEW = wrap(`playwright-repl drives Chromium over CDP. Commands act on the selected tab (\`tab\` shows which).
|
|
26
|
-
The browser may be a shared session, with other
|
|
26
|
+
The browser may be a shared session, with other users' tabs open in it.
|
|
27
27
|
|
|
28
28
|
Common tasks:
|
|
29
29
|
where am I? tab, info
|
|
@@ -85,8 +85,9 @@ the selectors it needs (#id, text=..., role=...), not snapshot refs: the take st
|
|
|
85
85
|
page, where the refs are stale.
|
|
86
86
|
|
|
87
87
|
2. Write the take, one command per line, then run it: pw-repl send --file take.txt (pw-repl help, outside
|
|
88
|
-
the REPL, has the rest). Its first
|
|
89
|
-
so the take does not start from the rehearsal's end.
|
|
88
|
+
the REPL, has the rest). Its first lines put the page back as it was before the rehearsal (goto <url>),
|
|
89
|
+
so the take does not start from the rehearsal's end. A toast, the cursor and highlights stay up across
|
|
90
|
+
page loads: turn off any the rehearsal left (toast off, cursor off, highlight off) before record on.
|
|
90
91
|
|
|
91
92
|
goto https://shop.example/support/new
|
|
92
93
|
record on clip.webm
|
|
@@ -100,7 +101,8 @@ so the take does not start from the rehearsal's end.
|
|
|
100
101
|
3. While it records, pw-repl paces it for a viewer (help record): type types at a readable pace (use it,
|
|
101
102
|
not fill, for text the viewer should see typed), each action is followed by a pause, an element out of
|
|
102
103
|
view is scrolled to smoothly, and the video starts and ends on a still page. highlight <selector> draws a
|
|
103
|
-
box to point at something
|
|
104
|
+
box to point at something, and toast <text> a line of text saying what is happening, up for as long as
|
|
105
|
+
the viewer needs to read it. After a step that loads a page, wait for text only the result shows (wait
|
|
104
106
|
text ...), not a heading the page always has, and not wait load: a single-page app loads nothing.
|
|
105
107
|
|
|
106
108
|
record on --steps also saves when and where each step ran, in the video's seconds and pixels, for
|
|
@@ -109,7 +111,7 @@ editing it afterwards.
|
|
|
109
111
|
The user's own screen, with their real pointer, takes a screen recorder they run (OBS, ffmpeg -f
|
|
110
112
|
x11grab, macOS screencapture -v): ask first. playwright-cli itself is a separate tool that records a
|
|
111
113
|
clean browser of its own (here, its video-start is record on).`,
|
|
112
|
-
commands: ['record', 'cursor'],
|
|
114
|
+
commands: ['record', 'cursor', 'toast'],
|
|
113
115
|
},
|
|
114
116
|
network: {
|
|
115
117
|
intro: `requests and console record all the time; a capture records only while it runs.`,
|
|
@@ -150,14 +152,21 @@ const COMMANDS = {
|
|
|
150
152
|
usage: 'tab [<index>|<url-part>|new [url]|close [url-part]]',
|
|
151
153
|
summary: 'list tabs (* is selected); select, open or close one',
|
|
152
154
|
args: [
|
|
153
|
-
['(none)', 'lists the tabs: * marks the selected one, and each says who opened it with tab new.'],
|
|
155
|
+
['(none)', 'lists the tabs: * marks the selected one, (in front) the one the browser shows, and each says who opened it with tab new.'],
|
|
154
156
|
['<index>', 'selects by the number in your latest listing (each client has its own); numbers change when tabs open or close.'],
|
|
155
157
|
['<url-part>', 'selects the one tab whose URL contains it; refused if none or several do.'],
|
|
156
158
|
['new [url]', 'opens a tab behind the one in front, so it does not take the window from whoever is using it, and selects it. The URL is read as goto reads it.'],
|
|
157
159
|
['close [url-part]', 'closes the selected tab, or the one matching url-part.'],
|
|
158
160
|
],
|
|
159
161
|
detail: `Closing the selected tab goes back to the previous tab if tab new opened it; otherwise no tab is
|
|
160
|
-
selected, and commands that need one refuse until one is
|
|
162
|
+
selected, and commands that need one refuse until one is.
|
|
163
|
+
|
|
164
|
+
In front: (in front) marks the tab the browser shows in its window, the one the user sees; (in front,
|
|
165
|
+
focused) when its window has the keyboard too, and it is not in the address bar. A tab a client has
|
|
166
|
+
selected or opened is never marked: pw-repl tells it that it is in front, so that it keeps drawing and
|
|
167
|
+
clicks in it work while it is behind another, and so cannot ask it. The user's other tabs are told
|
|
168
|
+
nothing, and say truly whether they are seen. Each window has a tab in front, so with several windows
|
|
169
|
+
(an incognito one, say) several tabs are marked; focused is the one with the keyboard.`,
|
|
161
170
|
},
|
|
162
171
|
goto: {
|
|
163
172
|
usage: 'goto <url>',
|
|
@@ -190,7 +199,8 @@ afresh.`,
|
|
|
190
199
|
['--modifiers=<keys>', 'keys held down meanwhile, comma-separated: Alt, Control, ControlOrMeta, Meta, Shift.'],
|
|
191
200
|
],
|
|
192
201
|
detail: `Several matches: hidden ones and ones covered by something else (a dialog's overlay) are passed over,
|
|
193
|
-
and it says which it used: Clicked: text=Buy (match 3 of 4; ...)
|
|
202
|
+
and it says which it used: Clicked: text=Buy (match 3 of 4; ...), or names the first when it was the
|
|
203
|
+
one: (the first of 2 matches: <h1> Buy), as it may not be the one meant. If none can be clicked within 5s, it
|
|
194
204
|
says why for each and does nothing. A ref, or a single match, is clicked as it is, waiting up to 5s for
|
|
195
205
|
it to be ready.
|
|
196
206
|
|
|
@@ -252,7 +262,11 @@ once, even while recording: type shows it being typed.`,
|
|
|
252
262
|
The whole text takes its length times the gap, and send waits 20s by default: about 300 characters at
|
|
253
263
|
60ms need a longer send -t, or a shorter --delay. --delay is a gap between keys, not a wait before
|
|
254
264
|
starting like screenshot's. A page that reacts to each key, such as search as you type, may react to
|
|
255
|
-
each one
|
|
265
|
+
each one.
|
|
266
|
+
|
|
267
|
+
Date and time inputs: they take keys part by part, in the order they show them (10/02/2026 in en-US),
|
|
268
|
+
from the first part with a selector, or from the part a click landed on. type says what the input then
|
|
269
|
+
holds. Their ISO form (2026-10-02) is refused, since typed it comes out mangled: fill sets it.`,
|
|
256
270
|
},
|
|
257
271
|
press: { usage: 'press <key> | press <selector> <key>', summary: 'press a key (Enter, Escape, Control+A; any case), maybe on an element' },
|
|
258
272
|
select: {
|
|
@@ -299,21 +313,28 @@ new snapshot after it does.
|
|
|
299
313
|
ref of the element holding it, so snapshot <that ref> shows what sits beside it, such as the value next
|
|
300
314
|
to the label.
|
|
301
315
|
|
|
316
|
+
Passwords: a password field's value is shown as (a password: not shown), --full too, as is that of a
|
|
317
|
+
field whose autocomplete is for a password, a one-time code or a card number; html and attrs show its
|
|
318
|
+
value attribute so too. A field a Show password button made plain text, with no such autocomplete,
|
|
319
|
+
shows its value.
|
|
320
|
+
|
|
302
321
|
Output over 60 lines ends with a line count.`,
|
|
303
322
|
},
|
|
304
323
|
watch: {
|
|
305
|
-
usage: 'watch on [
|
|
306
|
-
summary: 'record what happens in the tab; show
|
|
324
|
+
usage: 'watch on [options] | off | [n|new] | save <file>',
|
|
325
|
+
summary: 'record what happens in the tab; show or save the steps',
|
|
307
326
|
args: [
|
|
308
327
|
['on', 'starts recording the tab\'s steps; after watch off, a new recording.'],
|
|
309
328
|
['off', 'stops it; anyone\'s watch off stops it, since a watch belongs to the tab.'],
|
|
310
329
|
['(none) | <n>', 'says whether it is on, and shows the last 20 steps, or the last n.'],
|
|
311
330
|
['new', 'only the steps not shown yet, and requests that have since arrived for the last one.'],
|
|
331
|
+
['save <file>', 'writes the steps as commands, a file that pw-repl send --file <file> runs again; it refuses a file that exists.'],
|
|
312
332
|
],
|
|
313
333
|
optionsFor: 'watch on',
|
|
314
334
|
options: [
|
|
315
335
|
['--changes', 'also what each step changed on the page once it settled, in up to 5 lines: + added, - removed, ~ changed.'],
|
|
316
336
|
['--live', 'also prints each step in the REPL window once it settles (never in a command\'s answer), marked [watch], or [watch <url>] for a tab that is not the selected one.'],
|
|
337
|
+
['--no-values', 'records that something was typed, not what.'],
|
|
317
338
|
['--next-tab [part]', 'returns at once, then watches the next tab someone opens, or the next whose URL contains part, from its first page, and selects it for you. tab new, a client\'s own, does not count.'],
|
|
318
339
|
],
|
|
319
340
|
detail: `What a step is: a click, typing (once it pauses), Enter or Escape, a form change, a submit or a
|
|
@@ -322,11 +343,22 @@ each with its status as in requests (body <#> for one), leaving out scripts and
|
|
|
322
343
|
REPL's own fill shows as type. A double-click shows as two clicks; hovering and moving the mouse are not
|
|
323
344
|
recorded.
|
|
324
345
|
|
|
325
|
-
Privacy:
|
|
326
|
-
|
|
346
|
+
Privacy: what is typed is recorded, and watch and watch save show it, except in a password field: one
|
|
347
|
+
that was ever type=password, or whose autocomplete is for a password, a one-time code or a card number.
|
|
348
|
+
--no-values leaves out every value. --changes leaves out field values, but what the page itself shows
|
|
349
|
+
(e.g. "Hello <name>") is shown. watch save's file can be read by its owner only.
|
|
350
|
+
|
|
351
|
+
Saving (watch save): a goto to where the watch started, then each step as a command, under a # line
|
|
352
|
+
describing it: click, fill, select, check, press, and wait load after a step that loaded a page. An
|
|
353
|
+
element is found by its id, data-testid or name, its text, or else its place in the page, which a change
|
|
354
|
+
to the page can break. A select step chooses the option by its value. What was typed into a password
|
|
355
|
+
field (or any field, with --no-values) is a variable named after the field, e.g. fill '#password'
|
|
356
|
+
"{{ PW_PASSWORD }}", which pw-repl send --file fills in from its environment: PW_PASSWORD=... pw-repl
|
|
357
|
+
send --file login.txt. A step that cannot be a command (a value with a line break, an element in a frame, or
|
|
358
|
+
one no selector finds alone) stays a comment saying why, and the save says how many there are.
|
|
327
359
|
|
|
328
360
|
Shared: every client sees the same steps, and watch new's place is shared.`,
|
|
329
|
-
moreAbout: 'bodies kept, --live timing, what --changes misses,
|
|
361
|
+
moreAbout: 'bodies kept, --live timing, what --changes misses, one --next-tab at a time, and what watch save leaves out',
|
|
330
362
|
more: `From its first watch on, a tab keeps response bodies of up to 100 KB, so body <#> still shows one
|
|
331
363
|
after the tab navigates, even when the page navigates as soon as the request answers.
|
|
332
364
|
|
|
@@ -336,13 +368,17 @@ under the next step.
|
|
|
336
368
|
--changes: each element once, with the first names inside it. Not for navigations: what a page changes
|
|
337
369
|
on its own after a step settles (data arriving later) shows under the next step.
|
|
338
370
|
|
|
339
|
-
--next-tab: it takes --changes and --
|
|
371
|
+
--next-tab: it takes --changes, --live and --no-values too; watch off stops waiting, and modes lists it. One tab is
|
|
340
372
|
waited for at a time: while one client waits, another's watch on --next-tab is refused, and its watch
|
|
341
|
-
off leaves the wait on
|
|
373
|
+
off leaves the wait on.
|
|
374
|
+
|
|
375
|
+
watch save: a submit is left out, since the click or Enter that caused it is there. So is a URL the app
|
|
376
|
+
changed without loading a page (history.pushState), as a comment: the step before it changes it again.
|
|
377
|
+
In an open shadow root, an element's selector is its host's, then its own; a closed one is out of reach.`,
|
|
342
378
|
},
|
|
343
379
|
text: { usage: 'text [--all] <selector>', summary: 'visible text of the first match' },
|
|
344
|
-
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match' },
|
|
345
|
-
attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match' },
|
|
380
|
+
html: { usage: 'html [--all] <selector>', summary: 'outer HTML of the first match; no password values' },
|
|
381
|
+
attrs: { usage: 'attrs [--all] <selector>', summary: 'attributes of the first match; no password values' },
|
|
346
382
|
listeners: {
|
|
347
383
|
usage: 'listeners <selector>|document|window',
|
|
348
384
|
summary: 'the page\'s event listeners on an element',
|
|
@@ -359,26 +395,37 @@ Playwright adds to window as it acts are left out, and it says how many.`,
|
|
|
359
395
|
links: { usage: 'links [--all]', summary: 'links on the page (text and href)' },
|
|
360
396
|
inputs: { usage: 'inputs [--all]', summary: 'form controls on the page; values are omitted' },
|
|
361
397
|
highlight: {
|
|
362
|
-
usage: 'highlight [<selector>
|
|
398
|
+
usage: 'highlight [<selector> | off] [options]',
|
|
363
399
|
summary: 'draw a box over elements, for the user or a screenshot',
|
|
364
400
|
args: [
|
|
365
|
-
['<selector>', 'draws Playwright\'s highlight over every match, as playwright-cli\'s does: a tinted box
|
|
401
|
+
['<selector>', 'draws Playwright\'s highlight over every match, as playwright-cli\'s does: a tinted box that follows the element as the page scrolls or changes, and is in screenshots.'],
|
|
366
402
|
['(none)', 'lists the selected tab\'s highlights.'],
|
|
367
403
|
['off', 'hides them all, as highlight --hide does.'],
|
|
368
404
|
],
|
|
369
405
|
options: [
|
|
370
406
|
['--style=<css>', 'CSS added to the box, quoted if it has spaces: highlight e5 --style="outline: 3px solid blue; border-radius: 50%" circles e5 in blue.'],
|
|
371
407
|
['--hide <selector>', 'hides one.'],
|
|
408
|
+
['--labels on|off', 'labels every box in the selected tab with its locator, as Playwright does, or not: those drawn already too. With a selector or on its own (highlight --labels on). Default: off.'],
|
|
372
409
|
],
|
|
373
410
|
detail: `A highlight is a mode of its tab: modes shows it, modes off hides it, and the REPL hides every one as
|
|
374
411
|
it stops. Loading a new page drops them; a page that changes its route without loading keeps them.
|
|
375
412
|
|
|
376
413
|
The box is drawn over the page, in an element of Playwright's own added to it (x-pw-glass); the page's
|
|
377
|
-
own elements are not changed, and clicks and typing reach them as before.
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
414
|
+
own elements are not changed, and clicks and typing reach them as before.
|
|
415
|
+
|
|
416
|
+
Labels (--labels): Playwright labels each box with its locator, which covers what is just below it and
|
|
417
|
+
shows selectors in a screenshot or a video, so the REPL hides the labels unless they are on. On is a mode
|
|
418
|
+
of the tab, until --labels off or modes off. A box is labelled with the selector it was drawn from, e.g.
|
|
419
|
+
locator('role=button[name="Pay"]'); one drawn from a snapshot ref, with the locator Playwright makes for
|
|
420
|
+
its element, e.g. getByRole('button', { name: 'Pay' }), as the ref's own is not in the page's code.`,
|
|
421
|
+
moreAbout: 'labels inside a frame, and when they cannot be hidden',
|
|
422
|
+
more: `A highlight on an element inside an iframe is drawn in the frame's own document, and keeps its label.
|
|
423
|
+
|
|
424
|
+
The REPL hides the labels by reaching into Playwright's overlay, which a new Playwright could change: if
|
|
425
|
+
it cannot, it tells each client once, and the boxes are drawn with their labels.
|
|
426
|
+
|
|
427
|
+
For a screenshot without one then, eval can outline the element itself (with border-radius: 50% for a
|
|
428
|
+
circle). That changes the page, so put it back afterwards, as it was if it had a style of its own:
|
|
382
429
|
eval document.querySelector("#rate-3").style.outline = "3px solid blue"
|
|
383
430
|
eval document.querySelector("#rate-3").style.outline = ""`,
|
|
384
431
|
},
|
|
@@ -399,14 +446,45 @@ else in /tmp.
|
|
|
399
446
|
|
|
400
447
|
It brings the tab to the front of its window first: Chrome draws only the tab in front. The image is
|
|
401
448
|
at CSS pixel size, one image pixel per CSS pixel. While emulate mobile is on, it is what the phone
|
|
402
|
-
shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit
|
|
449
|
+
shows: a page laid out wider than the phone (no viewport meta tag) comes out shrunk to fit. An element bigger
|
|
450
|
+
than the viewport is taken with the page's scrollbar hidden for a moment, so that it does not move over.`,
|
|
451
|
+
},
|
|
452
|
+
toast: {
|
|
453
|
+
usage: 'toast <text> [options] | toast off',
|
|
454
|
+
summary: 'show a line of text over the page, to narrate a video',
|
|
455
|
+
args: [
|
|
456
|
+
['<text>', 'the rest of the line, quotes around all of it removed; it wraps at 80% of the page\'s width.'],
|
|
457
|
+
['off', 'fades it out.'],
|
|
458
|
+
['(none)', 'says whether one is up, and what it says.'],
|
|
459
|
+
],
|
|
460
|
+
options: [
|
|
461
|
+
['--duration=<time>', 'hides it after that long: 3s, 1.5s, or milliseconds (1500). Default: stays until toast off or the next toast.'],
|
|
462
|
+
['--read-time=<time>', 'while the tab records, the least time it stays up (below). Default: 300ms a word, at least 1.5s; 0 none.'],
|
|
463
|
+
['--position=<where>', 'top, center or bottom. Default: bottom.'],
|
|
464
|
+
['--size=<px>', 'the text\'s size. Default: 24.'],
|
|
465
|
+
['--align=<how>', 'left, center or right, for text over more than one line. Default: center.'],
|
|
466
|
+
['--color=<css>', 'the text\'s color. Default: #fff.'],
|
|
467
|
+
['--background=<css>', 'its box\'s color. Default: rgba(20, 20, 20, .85).'],
|
|
468
|
+
['--opacity=<0-1>', 'of the whole toast. Default: 1.'],
|
|
469
|
+
],
|
|
470
|
+
detail: `It stays up until toast off or the next toast, which replaces it: the old one fades out, then the new
|
|
471
|
+
one fades in. It returns once it has faded in, so a screenshot right after it shows it whole. The
|
|
472
|
+
commands after it run while it is up, so it can say what they are doing.
|
|
473
|
+
|
|
474
|
+
Read time (--read-time): while the tab records, a toast stays up for its read time before the next
|
|
475
|
+
toast, toast off or record off takes it down; each waits out what is left of it, and says so. toast
|
|
476
|
+
says what its read time is, and past 15s, the send -t the command that takes it down needs: send waits
|
|
477
|
+
20s by default. A toast with --duration has none: it ends by itself. Not recording, nothing waits.
|
|
478
|
+
|
|
479
|
+
It is a mode of its tab: modes lists it and modes off turns it off. It stays up across page loads, and
|
|
480
|
+
shows in screenshots and recordings. Selectors, snapshot and text don't see it, and it takes no clicks.`,
|
|
403
481
|
},
|
|
404
482
|
cursor: {
|
|
405
483
|
usage: 'cursor [on [<selector> | <x> <y>] | off]',
|
|
406
484
|
summary: 'draw a pointer where the REPL acts, for videos',
|
|
407
485
|
args: [
|
|
408
486
|
['on', 'fades it in where the mouse last went, or the middle.'],
|
|
409
|
-
['on <selector>', 'fades it in on the element (a video\'s first, say)
|
|
487
|
+
['on <selector>', 'fades it in on the element (a video\'s first, say); when it is on already, glides it there. An element partly in view is pointed at where it shows, without scrolling, so one the page cuts off still looks cut off; one out of view is scrolled to.'],
|
|
410
488
|
['on <x> <y>', 'the same, at a point in the viewport.'],
|
|
411
489
|
['off', 'fades it out.'],
|
|
412
490
|
['(none)', 'says whether it is on, and where.'],
|
|
@@ -511,7 +589,8 @@ goes on recording. There is no --mp4 or --webm: the file's ending picks the form
|
|
|
511
589
|
['(none)', 'shows what is on.'],
|
|
512
590
|
],
|
|
513
591
|
detail: `Per tab, until turned off or the REPL exits; emulate mobile <device> while another is on switches to
|
|
514
|
-
it.
|
|
592
|
+
it. Mobile and viewport both set the screen size, so each refuses while the other is on: viewport off before
|
|
593
|
+
emulate mobile, and emulate mobile off before viewport.
|
|
515
594
|
|
|
516
595
|
The page sees the user agent, touch and navigator.languages from its next load: reload after turning
|
|
517
596
|
mobile or locale on or off. Touch means the page sees a touch screen; click still clicks with the
|
|
@@ -793,7 +872,7 @@ Names both have (goto, click, fill, snapshot, eval, ...) take playwright-cli's f
|
|
|
793
872
|
Its other options are refused, never read as part of a selector or a value.
|
|
794
873
|
|
|
795
874
|
Different on purpose, since the browser may be someone else's too:
|
|
796
|
-
routes, network and emulation per tab, not the whole browser, so other
|
|
875
|
+
routes, network and emulation per tab, not the whole browser, so other users' tabs are untouched
|
|
797
876
|
close turns off your modes; tabs stay open (tab close <url-part> closes one)
|
|
798
877
|
upload <selector> <file> names the input: file pickers are not caught, as the user's would be
|
|
799
878
|
no snapshot after each command the user reads the pane too; snapshot when you need one
|