browser-debugger-cli 0.11.0 → 0.12.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.
Files changed (98) hide show
  1. package/README.md +142 -79
  2. package/dist/commands/css.d.ts +13 -0
  3. package/dist/commands/css.js +53 -0
  4. package/dist/commands/dom/audit.d.ts +14 -0
  5. package/dist/commands/dom/audit.js +87 -0
  6. package/dist/commands/dom/formInteraction.js +36 -6
  7. package/dist/commands/dom/helpers/keyAttributes.d.ts +3 -2
  8. package/dist/commands/dom/helpers/keyAttributes.js +6 -4
  9. package/dist/commands/dom/helpers/screenshot.d.ts +1 -0
  10. package/dist/commands/dom/helpers/screenshot.js +158 -38
  11. package/dist/commands/dom/index.js +4 -1
  12. package/dist/commands/dom/screenshot.js +10 -6
  13. package/dist/commands/dom/wait.js +5 -3
  14. package/dist/commands/helpJson.js +1 -1
  15. package/dist/commands/optionBehaviors.js +21 -6
  16. package/dist/commands/page.js +7 -4
  17. package/dist/commands/peek.d.ts +7 -0
  18. package/dist/commands/peek.js +65 -23
  19. package/dist/commands/shared/optionTypes.d.ts +5 -1
  20. package/dist/commands/start.d.ts +13 -0
  21. package/dist/commands/start.js +19 -2
  22. package/dist/commands/tail.d.ts +7 -1
  23. package/dist/commands/tail.js +13 -62
  24. package/dist/commands.js +2 -0
  25. package/dist/daemon/session/commandRegistry.js +7 -1
  26. package/dist/daemon/session/plugins.js +4 -52
  27. package/dist/daemon.js +7986 -6848
  28. package/dist/errors/messages.d.ts +34 -4
  29. package/dist/errors/messages.js +68 -5
  30. package/dist/index.js +610 -186
  31. package/dist/ipc/client.d.ts +4 -0
  32. package/dist/ipc/client.js +8 -0
  33. package/dist/ipc/protocol/auditTypes.d.ts +129 -0
  34. package/dist/ipc/protocol/auditTypes.js +6 -0
  35. package/dist/ipc/protocol/commands.d.ts +23 -0
  36. package/dist/ipc/protocol/commands.js +2 -0
  37. package/dist/ipc/protocol/domTypes.d.ts +4 -0
  38. package/dist/ipc/protocol/inspectTypes.d.ts +71 -8
  39. package/dist/runtime/css/search.d.ts +39 -0
  40. package/dist/runtime/css/search.js +122 -0
  41. package/dist/runtime/dom/actionEffects.d.ts +4 -1
  42. package/dist/runtime/dom/actionEffects.js +8 -4
  43. package/dist/runtime/dom/audit.d.ts +19 -0
  44. package/dist/runtime/dom/audit.js +36 -0
  45. package/dist/runtime/dom/auditModel.d.ts +45 -0
  46. package/dist/runtime/dom/auditModel.js +215 -0
  47. package/dist/runtime/dom/auditScripts.d.ts +107 -0
  48. package/dist/runtime/dom/auditScripts.js +112 -0
  49. package/dist/runtime/dom/elementGeometry.d.ts +8 -2
  50. package/dist/runtime/dom/elementGeometry.js +24 -8
  51. package/dist/runtime/dom/elementInfo.d.ts +3 -2
  52. package/dist/runtime/dom/elementInfo.js +8 -2
  53. package/dist/runtime/dom/formFillHelpers/fill.js +2 -2
  54. package/dist/runtime/dom/inspect.d.ts +7 -0
  55. package/dist/runtime/dom/inspect.js +88 -23
  56. package/dist/runtime/dom/inspectAllStyles.d.ts +16 -4
  57. package/dist/runtime/dom/inspectAllStyles.js +89 -7
  58. package/dist/runtime/dom/inspectCascade.d.ts +19 -2
  59. package/dist/runtime/dom/inspectCascade.js +214 -44
  60. package/dist/runtime/dom/inspectCascadeModel.d.ts +8 -0
  61. package/dist/runtime/dom/inspectCascadeModel.js +108 -34
  62. package/dist/runtime/dom/inspectHints.d.ts +26 -3
  63. package/dist/runtime/dom/inspectHints.js +125 -9
  64. package/dist/runtime/dom/inspectModel.d.ts +3 -0
  65. package/dist/runtime/dom/inspectModel.js +30 -7
  66. package/dist/runtime/dom/inspectPaintModel.d.ts +48 -22
  67. package/dist/runtime/dom/inspectPaintModel.js +180 -68
  68. package/dist/runtime/dom/inspectRules.d.ts +19 -0
  69. package/dist/runtime/dom/inspectRules.js +21 -5
  70. package/dist/runtime/dom/inspectScripts.d.ts +85 -12
  71. package/dist/runtime/dom/inspectScripts.js +314 -28
  72. package/dist/runtime/dom/inspectTree.js +10 -2
  73. package/dist/runtime/dom/inspectWhyModel.d.ts +2 -1
  74. package/dist/runtime/dom/inspectWhyModel.js +52 -10
  75. package/dist/runtime/dom/layout.js +31 -9
  76. package/dist/runtime/dom/reactEventHelpers.d.ts +7 -0
  77. package/dist/runtime/dom/reactEventHelpers.js +27 -9
  78. package/dist/runtime/page/emulation.d.ts +13 -4
  79. package/dist/runtime/page/emulation.js +69 -4
  80. package/dist/runtime/page/userAgent.d.ts +17 -0
  81. package/dist/runtime/page/userAgent.js +57 -0
  82. package/dist/types.d.ts +4 -0
  83. package/dist/ui/formatters/audit.d.ts +19 -0
  84. package/dist/ui/formatters/audit.js +106 -0
  85. package/dist/ui/formatters/dom.d.ts +1 -1
  86. package/dist/ui/formatters/dom.js +6 -3
  87. package/dist/ui/formatters/inspect.js +42 -15
  88. package/dist/ui/formatters/status.js +1 -1
  89. package/dist/ui/messages/commands.d.ts +44 -7
  90. package/dist/ui/messages/commands.js +83 -11
  91. package/dist/ui/messages/preview.d.ts +6 -0
  92. package/dist/ui/messages/preview.js +9 -1
  93. package/dist/utils/cssValues.js +36 -4
  94. package/dist/utils/decisionTrees.js +0 -5
  95. package/dist/utils/suggestions.d.ts +4 -2
  96. package/dist/utils/suggestions.js +7 -5
  97. package/dist/utils/taskMappings.js +1 -1
  98. package/package.json +3 -2
@@ -81,7 +81,7 @@ const SAME_CLICK_TARGET_JS = `(node, hit) => {
81
81
  /**
82
82
  * Page function: layout of the matches in `found` (all up to `limit`, or the
83
83
  * one at `index`) and of the top-level page. An element covers another when
84
- * it is the topmost element at the center of the largest visible part of the
84
+ * it is painted above it at the center of the largest visible part of the
85
85
  * other's boxes (a wrapped link has one per line; the strip where overlay
86
86
  * scrollbars show is avoided when possible, {@link CLEAR_OF_SCROLLBAR_JS}) and does not lie inside it,
87
87
  * nor part of the element's own click target ({@link SAME_CLICK_TARGET_JS}: the link, button
@@ -91,7 +91,12 @@ const SAME_CLICK_TARGET_JS = `(node, hit) => {
91
91
  * `z-index`), as `dom click` finds: the element is in the hit-test stack below
92
92
  * the ancestor. Hit-testing goes up through the iframes, so an overlay over
93
93
  * an iframe covers the elements in it. Elements hit-testing skips
94
- * (`pointer-events: none`, also through an iframe) get no cover.
94
+ * (`pointer-events: none`, also through an iframe) get no cover. The cover
95
+ * named is the first element above that paints there (a sticky header's
96
+ * background, not the transparent logo on it; inside a shadow host, what its
97
+ * shadow root paints), else the topmost one, marked transparent; named by
98
+ * its outermost fixed or sticky ancestor that does not hold the element (the
99
+ * fixed banner, not a span inside it).
95
100
  */
96
101
  const LAYOUT_JS = `function (found, index, limit) {
97
102
  const geometryOf = ${ELEMENT_GEOMETRY_JS};
@@ -121,13 +126,21 @@ const LAYOUT_JS = `function (found, index, limit) {
121
126
  return false;
122
127
  };
123
128
  const sameTarget = ${SAME_CLICK_TARGET_JS};
129
+ const paintsAt = (n, x, y) => {
130
+ if (!paintsNothing(n)) return true;
131
+ if (!n.shadowRoot) return false;
132
+ return n.shadowRoot.elementsFromPoint(x, y).some((e) => e !== n && encloses(n, e) && !paintsNothing(e));
133
+ };
124
134
  const coverAt = (node, x, y) => {
125
135
  const root = node.getRootNode();
126
- const scope = typeof root.elementFromPoint === 'function' ? root : node.ownerDocument;
127
- const hit = scope.elementFromPoint(x, y);
128
- if (!hit || encloses(node, hit) || sameTarget(node, hit)) return null;
129
- if (!encloses(hit, node)) return hit;
130
- return scope.elementsFromPoint(x, y).indexOf(node) > 0 ? hit : null;
136
+ const scope = typeof root.elementsFromPoint === 'function' ? root : node.ownerDocument;
137
+ const stack = scope.elementsFromPoint(x, y);
138
+ const at = stack.indexOf(node);
139
+ const above = (at < 0 ? stack.slice(0, 1) : stack.slice(0, at)).filter((hit) =>
140
+ !encloses(node, hit) && !sameTarget(node, hit) && (at >= 0 || !encloses(hit, node)));
141
+ if (above.length === 0) return null;
142
+ const painter = above.find((hit) => paintsAt(hit, x, y));
143
+ return { element: overlayOf(painter || above[0], node), transparent: !painter };
131
144
  };
132
145
  const visibleCenter = (el, g) => {
133
146
  const bounds = [{ x: 0, y: 0, width: page.viewport.width, height: page.viewport.height }].concat(g.clip ? [g.clip] : []);
@@ -154,7 +167,15 @@ const LAYOUT_JS = `function (found, index, limit) {
154
167
  const s = node.ownerDocument.defaultView.getComputedStyle(node);
155
168
  const clearColor = (c) => c === 'transparent' || /^rgba\\(.*,\\s*0\\)$/.test(c);
156
169
  const ownText = Array.from(node.childNodes).some((n) => n.nodeType === 3 && n.data.trim() !== '');
157
- return clearColor(s.backgroundColor) && s.backgroundImage === 'none' && s.boxShadow === 'none' && !ownText;
170
+ const replaced = /^(img|video|canvas|iframe|embed|object|input|textarea|select)$/.test(node.localName);
171
+ return !replaced && clearColor(s.backgroundColor) && s.backgroundImage === 'none' && s.boxShadow === 'none' && !ownText;
172
+ };
173
+ const overlayOf = (hit, node) => {
174
+ let overlay = hit;
175
+ for (let p = hit.parentElement || (hit.parentNode && hit.parentNode.host); p && p !== p.ownerDocument.body && !encloses(p, node); p = p.parentElement || (p.parentNode && p.parentNode.host)) {
176
+ if (/^(fixed|sticky)$/.test(p.ownerDocument.defaultView.getComputedStyle(p).position)) overlay = p;
177
+ }
178
+ return overlay;
158
179
  };
159
180
  const hitTestable = (node) => node.ownerDocument.defaultView.getComputedStyle(node).pointerEvents !== 'none';
160
181
  const coveredBy = (el, g) => {
@@ -170,7 +191,7 @@ const LAYOUT_JS = `function (found, index, limit) {
170
191
  y += offset.y;
171
192
  cover = coverAt(view.frameElement, x, y);
172
193
  }
173
- return cover ? { element: describe(cover), transparent: paintsNothing(cover) } : null;
194
+ return cover ? { element: describe(cover.element), transparent: cover.transparent } : null;
174
195
  };
175
196
  const elements = picked.map(([i, el]) => {
176
197
  const style = el.ownerDocument.defaultView.getComputedStyle(el);
@@ -334,6 +355,7 @@ function elementLayout(raw, page) {
334
355
  ...(inView && raw.cover?.transparent && { coverTransparent: true }),
335
356
  ...(raw.geometry.invisible && { invisible: raw.geometry.invisible }),
336
357
  ...(raw.geometry.inert && { inert: true }),
358
+ ...(raw.geometry.fixed && { fixed: true }),
337
359
  computed: raw.computed,
338
360
  };
339
361
  }
@@ -34,6 +34,13 @@ export declare const FIELD_VALUES_JS = "(field) => {\n const fields = field.for
34
34
  * fillable kind of element. Messages are {@link FILL_REFUSALS}.
35
35
  */
36
36
  export declare const FILL_REFUSAL_JS: string;
37
+ /**
38
+ * Page-side: the one text field, select or editable element in an element's
39
+ * open shadow root (a web component such as `<sl-input>` wrapping a native
40
+ * input), which `dom fill` fills in its place; null when the element is a
41
+ * field itself or its shadow root has none or several.
42
+ */
43
+ export declare const SHADOW_FIELD_JS = "(el) => {\n if (!el.shadowRoot || /^(input|textarea|select)$/.test(el.localName) || el.isContentEditable) return null;\n const fields = el.shadowRoot.querySelectorAll('input:not([type=\"hidden\"]), textarea, select, [contenteditable]:not([contenteditable=\"false\"])');\n return fields.length === 1 ? fields[0] : null;\n}";
37
44
  /**
38
45
  * JavaScript function to fill an input element in a React-compatible way.
39
46
  *
@@ -94,8 +94,23 @@ export const FILL_REFUSAL_JS = `(el) => {
94
94
  if (el.closest('[inert]')) return refuse('inert', 'inside an inert element');
95
95
  if (el.getAttribute('aria-readonly') === 'true') return refuse('readOnly', 'aria-readonly="true"');
96
96
  if (el.getAttribute('aria-disabled') === 'true') return refuse('disabled', 'aria-disabled="true"');
97
+ if (el.shadowRoot) {
98
+ const fields = el.shadowRoot.querySelectorAll('input:not([type="hidden"]), textarea, select, [contenteditable]:not([contenteditable="false"])').length;
99
+ return refuse('notFillable', '<' + tag + '> has ' + (fields === 0 ? 'no field' : fields + ' fields') + ' in its shadow root; select the one to fill, e.g. its [part~=…] or class');
100
+ }
97
101
  return refuse('notFillable', '<' + tag + '> is not an input, textarea, select or contenteditable element');
98
102
  }`;
103
+ /**
104
+ * Page-side: the one text field, select or editable element in an element's
105
+ * open shadow root (a web component such as `<sl-input>` wrapping a native
106
+ * input), which `dom fill` fills in its place; null when the element is a
107
+ * field itself or its shadow root has none or several.
108
+ */
109
+ export const SHADOW_FIELD_JS = `(el) => {
110
+ if (!el.shadowRoot || /^(input|textarea|select)$/.test(el.localName) || el.isContentEditable) return null;
111
+ const fields = el.shadowRoot.querySelectorAll('input:not([type="hidden"]), textarea, select, [contenteditable]:not([contenteditable="false"])');
112
+ return fields.length === 1 ? fields[0] : null;
113
+ }`;
99
114
  /**
100
115
  * JavaScript function to fill an input element in a React-compatible way.
101
116
  *
@@ -199,6 +214,9 @@ export const REACT_FILL_SCRIPT = `
199
214
  }
200
215
  const viaLabel = labelControl ? ${JSON.stringify(VIA_LABEL_SUFFIX)} : '';
201
216
  if (labelControl) el = labelControl;
217
+ const shadowField = (${SHADOW_FIELD_JS})(el);
218
+ const viaShadow = shadowField ? ' in the shadow root of <' + el.localName + '>' : '';
219
+ if (shadowField) el = shadowField;
202
220
 
203
221
  const tagName = el.tagName.toLowerCase();
204
222
  const inputType = el.type?.toLowerCase();
@@ -208,7 +226,7 @@ export const REACT_FILL_SCRIPT = `
208
226
  return {
209
227
  success: false,
210
228
  error: refusal.error,
211
- elementType: tagName + viaLabel,
229
+ elementType: tagName + viaLabel + viaShadow,
212
230
  suggestion: refusal.suggestion,
213
231
  unsuitableElement: refusal.kind === 'notFillable'
214
232
  };
@@ -237,7 +255,7 @@ export const REACT_FILL_SCRIPT = `
237
255
  success: false,
238
256
  error: 'Option not found: ' + missing,
239
257
  exitCode: 81,
240
- elementType: tagName + viaLabel,
258
+ elementType: tagName + viaLabel + viaShadow,
241
259
  suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')
242
260
  };
243
261
  }
@@ -256,7 +274,7 @@ export const REACT_FILL_SCRIPT = `
256
274
  success: false,
257
275
  error: 'Option not found: ' + value,
258
276
  exitCode: 81,
259
- elementType: tagName + viaLabel,
277
+ elementType: tagName + viaLabel + viaShadow,
260
278
  suggestion: 'Available options: ' + options.slice(0, 10).map((o) => o.value || o.text.trim()).join(', ')
261
279
  };
262
280
  }
@@ -274,7 +292,7 @@ export const REACT_FILL_SCRIPT = `
274
292
  return {
275
293
  success: false,
276
294
  error: 'Expected true or false for a ' + inputType + ', got "' + value + '"',
277
- elementType: tagName + viaLabel,
295
+ elementType: tagName + viaLabel + viaShadow,
278
296
  inputType: inputType,
279
297
  suggestion: 'Use true/false (also yes/no, on/off, 1/0)'
280
298
  };
@@ -284,7 +302,7 @@ export const REACT_FILL_SCRIPT = `
284
302
  return {
285
303
  success: false,
286
304
  error: 'A radio button cannot be unchecked',
287
- elementType: tagName + viaLabel,
305
+ elementType: tagName + viaLabel + viaShadow,
288
306
  inputType: inputType,
289
307
  suggestion: 'Select another option in the same group instead'
290
308
  };
@@ -297,7 +315,7 @@ export const REACT_FILL_SCRIPT = `
297
315
  return {
298
316
  success: false,
299
317
  fileInput: true,
300
- elementType: tagName + viaLabel,
318
+ elementType: tagName + viaLabel + viaShadow,
301
319
  inputType: inputType,
302
320
  error: 'File input'
303
321
  };
@@ -323,7 +341,7 @@ export const REACT_FILL_SCRIPT = `
323
341
  return {
324
342
  success: false,
325
343
  error: 'Value is ' + value.length + ' characters; the field accepts at most ' + el.maxLength,
326
- elementType: tagName + viaLabel,
344
+ elementType: tagName + viaLabel + viaShadow,
327
345
  inputType: inputType || null,
328
346
  suggestion: 'Shorten the value (a user could not type more than maxlength characters)'
329
347
  };
@@ -339,7 +357,7 @@ export const REACT_FILL_SCRIPT = `
339
357
  return {
340
358
  success: false,
341
359
  error: rejection.error,
342
- elementType: tagName + viaLabel,
360
+ elementType: tagName + viaLabel + viaShadow,
343
361
  inputType: inputType,
344
362
  suggestion: rejection.suggestion
345
363
  };
@@ -368,7 +386,7 @@ export const REACT_FILL_SCRIPT = `
368
386
  ? Array.from(el.selectedOptions).map((o) => o.value).join(', ')
369
387
  : el.value,
370
388
  element: (${ELEMENT_IDENTITY_JS})(el),
371
- elementType: tagName + viaLabel,
389
+ elementType: tagName + viaLabel + viaShadow,
372
390
  inputType: inputType || null,
373
391
  checked: inputType === 'checkbox' || inputType === 'radio' ? el.checked : undefined,
374
392
  matchCount: allMatches.length,
@@ -10,17 +10,18 @@ import type { CDPConnection } from '../../connection/cdp.js';
10
10
  import type { ColorScheme, ViewportSize } from '../../types.js';
11
11
  /**
12
12
  * `Emulation.setDeviceMetricsOverride` parameters for a session viewport: a
13
- * desktop viewport of that size at the display's own pixel ratio.
13
+ * desktop viewport of that size at the display's own pixel ratio, or a
14
+ * phone's (mobile layout: meta viewport, overlay scrollbars; pixel ratio 3).
14
15
  *
15
- * @param viewport - Viewport size in CSS px
16
- * @param deviceScaleFactor - Pixel ratio (0 keeps the display's)
16
+ * @param viewport - Viewport size in CSS px, `mobile` for a phone
17
+ * @param deviceScaleFactor - Pixel ratio (0 keeps the display's, or a phone's)
17
18
  * @returns CDP parameters
18
19
  */
19
20
  export declare function viewportOverride(viewport: ViewportSize, deviceScaleFactor?: number): {
20
21
  width: number;
21
22
  height: number;
22
23
  deviceScaleFactor: number;
23
- mobile: false;
24
+ mobile: boolean;
24
25
  };
25
26
  /**
26
27
  * Apply the session's emulation to its page.
@@ -32,6 +33,14 @@ export declare function applySessionEmulation(cdp: CDPConnection, emulation: {
32
33
  viewport?: ViewportSize;
33
34
  colorScheme?: ColorScheme;
34
35
  }): Promise<void>;
36
+ /**
37
+ * A mobile user agent from a desktop one: an Android platform, `Mobile`
38
+ * before `Safari`, and `Chrome` for `HeadlessChrome`.
39
+ *
40
+ * @param desktop - The browser's user agent
41
+ * @returns Mobile user agent
42
+ */
43
+ export declare function mobileUserAgent(desktop: string): string;
35
44
  /** Page emulation of a session: what `--viewport` and `--color-scheme` set */
36
45
  export interface SessionEmulation {
37
46
  viewport?: ViewportSize;
@@ -7,19 +7,29 @@
7
7
  * an attached Chrome (`--chrome-ws-url`) gets its own settings back.
8
8
  */
9
9
  import { VIEWPORT_SIZE_JS } from '../dom/elementGeometry.js';
10
+ import { hideHeadlessUserAgent } from './userAgent.js';
10
11
  import { createLogger } from '../../ui/logging/index.js';
11
12
  import { getErrorMessage } from '../../utils/errors.js';
12
13
  const log = createLogger('session');
14
+ /** Pixel ratio of an emulated phone (a common one) */
15
+ const MOBILE_PIXEL_RATIO = 3;
13
16
  /**
14
17
  * `Emulation.setDeviceMetricsOverride` parameters for a session viewport: a
15
- * desktop viewport of that size at the display's own pixel ratio.
18
+ * desktop viewport of that size at the display's own pixel ratio, or a
19
+ * phone's (mobile layout: meta viewport, overlay scrollbars; pixel ratio 3).
16
20
  *
17
- * @param viewport - Viewport size in CSS px
18
- * @param deviceScaleFactor - Pixel ratio (0 keeps the display's)
21
+ * @param viewport - Viewport size in CSS px, `mobile` for a phone
22
+ * @param deviceScaleFactor - Pixel ratio (0 keeps the display's, or a phone's)
19
23
  * @returns CDP parameters
20
24
  */
21
25
  export function viewportOverride(viewport, deviceScaleFactor = 0) {
22
- return { width: viewport.width, height: viewport.height, deviceScaleFactor, mobile: false };
26
+ const mobile = viewport.mobile === true;
27
+ return {
28
+ width: viewport.width,
29
+ height: viewport.height,
30
+ deviceScaleFactor: deviceScaleFactor || (mobile ? MOBILE_PIXEL_RATIO : 0),
31
+ mobile,
32
+ };
23
33
  }
24
34
  /**
25
35
  * Apply the session's emulation to its page.
@@ -30,6 +40,8 @@ export function viewportOverride(viewport, deviceScaleFactor = 0) {
30
40
  export async function applySessionEmulation(cdp, emulation) {
31
41
  if (emulation.viewport) {
32
42
  await cdp.send('Emulation.setDeviceMetricsOverride', viewportOverride(emulation.viewport));
43
+ if (emulation.viewport.mobile)
44
+ await emulatePhone(cdp, true);
33
45
  }
34
46
  if (emulation.colorScheme) {
35
47
  await cdp.send('Emulation.setEmulatedMedia', {
@@ -37,6 +49,55 @@ export async function applySessionEmulation(cdp, emulation) {
37
49
  });
38
50
  }
39
51
  }
52
+ /**
53
+ * Turn the rest of a phone's emulation on or off: touch input (and
54
+ * `pointer: coarse`) and a mobile user agent derived from the browser's own
55
+ * (an Android one), or the browser's own back: the override cleared, or in
56
+ * headless Chrome its regular-Chrome identity ({@link hideHeadlessUserAgent}).
57
+ *
58
+ * @param cdp - Session connection
59
+ * @param on - Emulate a phone
60
+ */
61
+ async function emulatePhone(cdp, on) {
62
+ await cdp.send('Emulation.setTouchEmulationEnabled', { enabled: on, maxTouchPoints: on ? 5 : 1 });
63
+ const { userAgent } = (await cdp.send('Browser.getVersion', {}));
64
+ if (!on) {
65
+ const headless = userAgent.includes('HeadlessChrome');
66
+ await cdp.send('Emulation.setUserAgentOverride', { userAgent: headless ? userAgent : '' });
67
+ if (headless)
68
+ await hideHeadlessUserAgent(cdp, log);
69
+ return;
70
+ }
71
+ const major = /Chrome\/(\d+)/.exec(userAgent)?.[1] ?? '';
72
+ await cdp.send('Emulation.setUserAgentOverride', {
73
+ userAgent: mobileUserAgent(userAgent.replace('HeadlessChrome/', 'Chrome/')),
74
+ platform: 'Android',
75
+ userAgentMetadata: {
76
+ brands: [
77
+ { brand: 'Google Chrome', version: major },
78
+ { brand: 'Chromium', version: major },
79
+ ],
80
+ platform: 'Android',
81
+ platformVersion: '10.0.0',
82
+ architecture: '',
83
+ model: 'K',
84
+ mobile: true,
85
+ },
86
+ });
87
+ }
88
+ /**
89
+ * A mobile user agent from a desktop one: an Android platform, `Mobile`
90
+ * before `Safari`, and `Chrome` for `HeadlessChrome`.
91
+ *
92
+ * @param desktop - The browser's user agent
93
+ * @returns Mobile user agent
94
+ */
95
+ export function mobileUserAgent(desktop) {
96
+ return desktop
97
+ .replace(/\([^)]*\)/, '(Linux; Android 10; K)')
98
+ .replace('HeadlessChrome/', 'Chrome/')
99
+ .replace(/ (Mobile )?Safari\//, ' Mobile Safari/');
100
+ }
40
101
  /**
41
102
  * Change the page emulation mid-session (`bdg page emulate`): set the
42
103
  * viewport or the color scheme, or clear both (back to the browser window
@@ -58,6 +119,8 @@ export async function emulatePage(cdp, current, change, record) {
58
119
  };
59
120
  const { viewport: _viewport, colorScheme, ...rest } = state;
60
121
  if (change.reset || change.viewport) {
122
+ const wasPhone = current.viewport?.mobile === true;
123
+ const isPhone = change.viewport?.mobile === true;
61
124
  await step(() => change.viewport
62
125
  ? cdp.send('Emulation.setDeviceMetricsOverride', viewportOverride(change.viewport))
63
126
  : cdp.send('Emulation.clearDeviceMetricsOverride', {}), {
@@ -65,6 +128,8 @@ export async function emulatePage(cdp, current, change, record) {
65
128
  ...(colorScheme && { colorScheme }),
66
129
  ...(change.viewport && { viewport: change.viewport }),
67
130
  });
131
+ if (wasPhone !== isPhone)
132
+ await step(() => emulatePhone(cdp, isPhone), state);
68
133
  }
69
134
  if (change.reset || change.colorScheme) {
70
135
  const { colorScheme: _scheme, ...withoutScheme } = state;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The session's user agent in headless Chrome: regular Chrome's string and
3
+ * client hints, so sites serve the page a user sees.
4
+ */
5
+ import type { CDPConnection } from '../../connection/cdp.js';
6
+ import type { Logger } from '../../ui/logging/index.js';
7
+ /**
8
+ * Send the user agent and client hints of regular Chrome from headless
9
+ * Chrome: sites serve "HeadlessChrome" a different page (or a bot
10
+ * challenge), so the page would not be the one a user sees. Not for a
11
+ * session emulating a phone, whose emulation sets a mobile user agent.
12
+ *
13
+ * @param cdp - CDP connection
14
+ * @param logger - Logger for failures (the session works without it)
15
+ */
16
+ export declare function hideHeadlessUserAgent(cdp: CDPConnection, logger: Logger): Promise<void>;
17
+ //# sourceMappingURL=userAgent.d.ts.map
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The session's user agent in headless Chrome: regular Chrome's string and
3
+ * client hints, so sites serve the page a user sees.
4
+ */
5
+ import { getErrorMessage } from '../../utils/errors.js';
6
+ /** Reads the client hints Chrome reports, renaming the HeadlessChrome brand */
7
+ const USER_AGENT_METADATA_SCRIPT = `(async () => {
8
+ const data = navigator.userAgentData;
9
+ if (!data) return null;
10
+ const values = await data.getHighEntropyValues(
11
+ ['architecture', 'bitness', 'model', 'platformVersion', 'fullVersionList', 'wow64']
12
+ );
13
+ const rename = (list) => (list || []).map((entry) => ({
14
+ brand: entry.brand.replace('HeadlessChrome', 'Google Chrome'),
15
+ version: entry.version,
16
+ }));
17
+ return {
18
+ brands: rename(values.brands),
19
+ fullVersionList: rename(values.fullVersionList),
20
+ platform: values.platform,
21
+ platformVersion: values.platformVersion,
22
+ architecture: values.architecture,
23
+ model: values.model,
24
+ mobile: values.mobile,
25
+ bitness: values.bitness,
26
+ wow64: values.wow64,
27
+ };
28
+ })()`;
29
+ /**
30
+ * Send the user agent and client hints of regular Chrome from headless
31
+ * Chrome: sites serve "HeadlessChrome" a different page (or a bot
32
+ * challenge), so the page would not be the one a user sees. Not for a
33
+ * session emulating a phone, whose emulation sets a mobile user agent.
34
+ *
35
+ * @param cdp - CDP connection
36
+ * @param logger - Logger for failures (the session works without it)
37
+ */
38
+ export async function hideHeadlessUserAgent(cdp, logger) {
39
+ try {
40
+ const { userAgent } = (await cdp.send('Browser.getVersion'));
41
+ if (!userAgent.includes('HeadlessChrome'))
42
+ return;
43
+ const metadata = (await cdp.send('Runtime.evaluate', {
44
+ expression: USER_AGENT_METADATA_SCRIPT,
45
+ awaitPromise: true,
46
+ returnByValue: true,
47
+ }));
48
+ await cdp.send('Emulation.setUserAgentOverride', {
49
+ userAgent: userAgent.replace('HeadlessChrome', 'Chrome'),
50
+ ...(metadata.result?.value ? { userAgentMetadata: metadata.result.value } : {}),
51
+ });
52
+ }
53
+ catch (error) {
54
+ logger.debug(`User agent left as is: ${getErrorMessage(error)}`);
55
+ }
56
+ }
57
+ //# sourceMappingURL=userAgent.js.map
package/dist/types.d.ts CHANGED
@@ -370,6 +370,8 @@ export type ColorScheme = 'light' | 'dark';
370
370
  export interface ViewportSize {
371
371
  width: number;
372
372
  height: number;
373
+ /** A phone: mobile viewport (meta viewport, overlay scrollbars), touch and a mobile user agent */
374
+ mobile?: true;
373
375
  }
374
376
  /**
375
377
  * The page (document) request an action sent, as far as it got: still
@@ -483,6 +485,8 @@ export interface ScreenshotResult {
483
485
  * descendants) made it larger than the border box
484
486
  */
485
487
  captured?: ElementBounds;
488
+ /** `--padding` given: CSS px of page added around the captured area */
489
+ padding?: number;
486
490
  };
487
491
  /** Capture mode used */
488
492
  captureMode?: 'full_page' | 'viewport';
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Human output of `bdg dom audit` and `bdg css search`.
3
+ */
4
+ import type { AuditResult, CssSearchResult } from '../../ipc/protocol/auditTypes.js';
5
+ /**
6
+ * Format `bdg dom audit`.
7
+ *
8
+ * @param data - Audit result
9
+ * @returns Output
10
+ */
11
+ export declare function formatAudit(data: AuditResult): string;
12
+ /**
13
+ * Format `bdg css search`.
14
+ *
15
+ * @param data - Search result
16
+ * @returns Output
17
+ */
18
+ export declare function formatCssSearch(data: CssSearchResult): string;
19
+ //# sourceMappingURL=audit.d.ts.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Human output of `bdg dom audit` and `bdg css search`.
3
+ */
4
+ import { truncateByLength } from '../../utils/strings.js';
5
+ /** Width of the text quoted in a finding */
6
+ const TEXT_WIDTH = 50;
7
+ /**
8
+ * Format `bdg dom audit`.
9
+ *
10
+ * @param data - Audit result
11
+ * @returns Output
12
+ */
13
+ export function formatAudit(data) {
14
+ const sections = [
15
+ data.contrast && contrastSection(data.contrast),
16
+ data.overflow && overflowSection(data.overflow),
17
+ data.layers && layersSection(data.layers),
18
+ data.animations && animationsSection(data.animations),
19
+ ].filter((section) => section !== undefined);
20
+ const capped = data.capped ? [`(stopped after ${data.walked} elements; the page has more)`] : [];
21
+ return [...sections.flatMap((lines) => [...lines, '']), ...capped].join('\n').trimEnd();
22
+ }
23
+ /**
24
+ * The contrast section.
25
+ *
26
+ * @param contrast - Contrast findings
27
+ * @returns Lines
28
+ */
29
+ function contrastSection(contrast) {
30
+ const head = `Contrast (${contrast.level}): ${contrast.failing} of ${contrast.checked} text elements below`;
31
+ const rows = contrast.items.map((item) => ` ${item.ratio.toFixed(2).padStart(5)} ${item.color} on ${item.background} ${item.element} "${truncateByLength(item.text, TEXT_WIDTH)}" ${item.size}px${item.weight >= 700 ? ' bold' : ''}${item.inView ? '' : ' (out of view)'}${item.approximate ? ` (approximate: ${item.approximate.join(', ')})` : ''}`);
32
+ const more = contrast.failing - contrast.items.length;
33
+ return [head, ...rows, ...(more > 0 ? [` (+${more} more; --limit to list them)`] : [])];
34
+ }
35
+ /**
36
+ * The overflow section.
37
+ *
38
+ * @param overflow - Overflow findings
39
+ * @returns Lines
40
+ */
41
+ function overflowSection(overflow) {
42
+ const head = overflow.scrollsSideways
43
+ ? `Overflow: the page is ${overflow.pageWidth} wide in a ${overflow.viewportWidth} viewport (it scrolls sideways)`
44
+ : `Overflow: nothing makes the page scroll sideways (${overflow.viewportWidth} wide)`;
45
+ return [
46
+ head,
47
+ ...overflow.wide.map((item) => ` past the right edge: ${item.element} (to ${item.right}, ${item.width} wide)`),
48
+ ...overflow.truncated.map((item) => ` cut off (${item.kind}): ${item.element}${times(item.count)} "${truncateByLength(item.text, TEXT_WIDTH)}"`),
49
+ ...overflow.scrollers.map((item) => ` scrolls sideways inside: ${item.element} (${item.scrollWidth} of content in ${item.width})`),
50
+ ...overflow.images.map((image) => ` image ${[image.upscaled && `upscaled ${image.scale}x${overflow.pixelRatio > 1 ? ` at pixel ratio ${overflow.pixelRatio}` : ''}`, image.distorted && 'distorted'].filter(Boolean).join(', ')}: ${image.element}${times(image.count)} ${image.natural.w}x${image.natural.h} pixels drawn at ${image.rendered.w}x${image.rendered.h} CSS px`),
51
+ ];
52
+ }
53
+ /**
54
+ * How many identical findings a row stands for.
55
+ *
56
+ * @param count - Count, when 2 or more
57
+ * @returns e.g. ` ×5`, or empty
58
+ */
59
+ function times(count) {
60
+ return count ? ` ×${count}` : '';
61
+ }
62
+ /**
63
+ * The layers section.
64
+ *
65
+ * @param layers - Fixed and sticky elements
66
+ * @returns Lines
67
+ */
68
+ function layersSection(layers) {
69
+ if (layers.length === 0)
70
+ return ['Layers: no fixed or sticky elements'];
71
+ return [
72
+ `Layers: ${layers.length} fixed or sticky`,
73
+ ...layers.map((layer) => ` ${layer.position} z ${layer.zIndex} ${layer.element} ${layer.rect.w}x${layer.rect.h} at ${layer.rect.x},${layer.rect.y} of the viewport${layer.inView ? '' : ' (not in view now)'}`),
74
+ ];
75
+ }
76
+ /**
77
+ * The animations section.
78
+ *
79
+ * @param animations - Running animations
80
+ * @returns Lines
81
+ */
82
+ function animationsSection(animations) {
83
+ if (animations.length === 0)
84
+ return ['Animations: none running'];
85
+ return [
86
+ `Animations: ${animations.length} running`,
87
+ ...animations.map((animation) => ` ${animation.name} on ${animation.element}${times(animation.count)} (${animation.type}, ${typeof animation.duration === 'number' ? `${animation.duration}ms` : animation.duration}, ${animation.iterations === 'infinite' ? 'infinite' : `${animation.iterations}x`}${animation.scrollDriven ? ', scroll-driven' : ''})`),
88
+ ];
89
+ }
90
+ /**
91
+ * Format `bdg css search`.
92
+ *
93
+ * @param data - Search result
94
+ * @returns Output
95
+ */
96
+ export function formatCssSearch(data) {
97
+ if (data.total === 0)
98
+ return `"${data.query}" is not in the page's ${data.sheets} stylesheets`;
99
+ const more = data.total - data.matches.length;
100
+ return [
101
+ `"${data.query}": ${data.total} matches in ${data.sheets} stylesheets`,
102
+ ...data.matches.flatMap((match) => [` ${match.source}`, ` ${match.text}`]),
103
+ ...(more > 0 ? [` (+${more} more; --limit to list them)`] : []),
104
+ ].join('\n');
105
+ }
106
+ //# sourceMappingURL=audit.js.map
@@ -127,7 +127,7 @@ export declare function formatDomFrames(data: {
127
127
  * // Output: Screenshot saved to ./page.png (viewport only - page too tall)
128
128
  *
129
129
  * // An element whose floated children overflow it
130
- * // Output: Screenshot saved to ./el.png (grown from 940×37 to 940×285 to include content overflowing the element)
130
+ * // Output: Screenshot saved to ./el.png (grown from 940×37 to 940×285 to include what it paints outside its box (…))
131
131
  * ```
132
132
  */
133
133
  export declare function formatDomScreenshot(data: ScreenshotResult): string;
@@ -1,6 +1,6 @@
1
1
  import { keyAttributeItems } from './keyAttributes.js';
2
2
  import { OutputFormatter } from '../formatting.js';
3
- import { frameLabel, moreMatchesNote, framesStillLoadingNote, noFramesMessage, queryNextSteps, screenshotGrownNote, viewportPositionHint, } from '../messages/commands.js';
3
+ import { frameLabel, moreMatchesNote, framesStillLoadingNote, noFramesMessage, queryNextSteps, screenshotGrownNote, screenshotScaledNote, viewportPositionHint, } from '../messages/commands.js';
4
4
  /** Matches listed in human output (JSON has all of them) */
5
5
  const QUERY_DISPLAY_LIMIT = 50;
6
6
  /**
@@ -212,7 +212,7 @@ export function formatDomFrames(data) {
212
212
  * // Output: Screenshot saved to ./page.png (viewport only - page too tall)
213
213
  *
214
214
  * // An element whose floated children overflow it
215
- * // Output: Screenshot saved to ./el.png (grown from 940×37 to 940×285 to include content overflowing the element)
215
+ * // Output: Screenshot saved to ./el.png (grown from 940×37 to 940×285 to include what it paints outside its box (…))
216
216
  * ```
217
217
  */
218
218
  export function formatDomScreenshot(data) {
@@ -221,7 +221,10 @@ export function formatDomScreenshot(data) {
221
221
  output += ' (viewport only - page too tall)';
222
222
  }
223
223
  if (data.element?.captured) {
224
- output += ` (${screenshotGrownNote(data.element.bounds, data.element.captured)})`;
224
+ output += ` (${screenshotGrownNote(data.element.bounds, data.element.captured, data.element.padding)})`;
225
+ }
226
+ if (data.resized && data.originalWidth !== undefined && data.originalHeight !== undefined) {
227
+ output += ` (${screenshotScaledNote(data.originalWidth, data.originalHeight, data.width, data.height)})`;
225
228
  }
226
229
  return output;
227
230
  }