browser-debugger-cli 0.10.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.
- package/.claude/skills/bdg/SKILL.md +268 -0
- package/README.md +148 -74
- package/dist/commands/css.d.ts +13 -0
- package/dist/commands/css.js +53 -0
- package/dist/commands/dom/audit.d.ts +14 -0
- package/dist/commands/dom/audit.js +87 -0
- package/dist/commands/dom/formInteraction.js +36 -6
- package/dist/commands/dom/helpers/keyAttributes.d.ts +3 -2
- package/dist/commands/dom/helpers/keyAttributes.js +6 -4
- package/dist/commands/dom/helpers/screenshot.d.ts +1 -0
- package/dist/commands/dom/helpers/screenshot.js +158 -38
- package/dist/commands/dom/index.js +4 -1
- package/dist/commands/dom/screenshot.js +10 -6
- package/dist/commands/dom/wait.js +5 -3
- package/dist/commands/helpJson.js +1 -1
- package/dist/commands/installSkill.d.ts +20 -0
- package/dist/commands/installSkill.js +87 -0
- package/dist/commands/optionBehaviors.js +21 -6
- package/dist/commands/page.js +7 -4
- package/dist/commands/peek.d.ts +7 -0
- package/dist/commands/peek.js +65 -23
- package/dist/commands/shared/optionTypes.d.ts +5 -1
- package/dist/commands/start.d.ts +13 -0
- package/dist/commands/start.js +19 -2
- package/dist/commands/tail.d.ts +7 -1
- package/dist/commands/tail.js +13 -62
- package/dist/commands.js +5 -0
- package/dist/daemon/session/commandRegistry.js +7 -1
- package/dist/daemon/session/plugins.js +4 -52
- package/dist/daemon.js +7986 -6848
- package/dist/errors/messages.d.ts +50 -4
- package/dist/errors/messages.js +94 -5
- package/dist/index.js +709 -190
- package/dist/ipc/client.d.ts +4 -0
- package/dist/ipc/client.js +8 -0
- package/dist/ipc/protocol/auditTypes.d.ts +129 -0
- package/dist/ipc/protocol/auditTypes.js +6 -0
- package/dist/ipc/protocol/commands.d.ts +23 -0
- package/dist/ipc/protocol/commands.js +2 -0
- package/dist/ipc/protocol/domTypes.d.ts +4 -0
- package/dist/ipc/protocol/inspectTypes.d.ts +71 -8
- package/dist/runtime/css/search.d.ts +39 -0
- package/dist/runtime/css/search.js +122 -0
- package/dist/runtime/dom/actionEffects.d.ts +4 -1
- package/dist/runtime/dom/actionEffects.js +8 -4
- package/dist/runtime/dom/audit.d.ts +19 -0
- package/dist/runtime/dom/audit.js +36 -0
- package/dist/runtime/dom/auditModel.d.ts +45 -0
- package/dist/runtime/dom/auditModel.js +215 -0
- package/dist/runtime/dom/auditScripts.d.ts +107 -0
- package/dist/runtime/dom/auditScripts.js +112 -0
- package/dist/runtime/dom/elementGeometry.d.ts +8 -2
- package/dist/runtime/dom/elementGeometry.js +24 -8
- package/dist/runtime/dom/elementInfo.d.ts +3 -2
- package/dist/runtime/dom/elementInfo.js +8 -2
- package/dist/runtime/dom/formFillHelpers/fill.js +2 -2
- package/dist/runtime/dom/inspect.d.ts +7 -0
- package/dist/runtime/dom/inspect.js +88 -23
- package/dist/runtime/dom/inspectAllStyles.d.ts +16 -4
- package/dist/runtime/dom/inspectAllStyles.js +89 -7
- package/dist/runtime/dom/inspectCascade.d.ts +19 -2
- package/dist/runtime/dom/inspectCascade.js +214 -44
- package/dist/runtime/dom/inspectCascadeModel.d.ts +8 -0
- package/dist/runtime/dom/inspectCascadeModel.js +108 -34
- package/dist/runtime/dom/inspectHints.d.ts +26 -3
- package/dist/runtime/dom/inspectHints.js +125 -9
- package/dist/runtime/dom/inspectModel.d.ts +3 -0
- package/dist/runtime/dom/inspectModel.js +30 -7
- package/dist/runtime/dom/inspectPaintModel.d.ts +48 -22
- package/dist/runtime/dom/inspectPaintModel.js +180 -68
- package/dist/runtime/dom/inspectRules.d.ts +19 -0
- package/dist/runtime/dom/inspectRules.js +21 -5
- package/dist/runtime/dom/inspectScripts.d.ts +85 -12
- package/dist/runtime/dom/inspectScripts.js +314 -28
- package/dist/runtime/dom/inspectTree.js +10 -2
- package/dist/runtime/dom/inspectWhyModel.d.ts +2 -1
- package/dist/runtime/dom/inspectWhyModel.js +52 -10
- package/dist/runtime/dom/layout.js +31 -9
- package/dist/runtime/dom/reactEventHelpers.d.ts +7 -0
- package/dist/runtime/dom/reactEventHelpers.js +27 -9
- package/dist/runtime/page/emulation.d.ts +13 -4
- package/dist/runtime/page/emulation.js +69 -4
- package/dist/runtime/page/userAgent.d.ts +17 -0
- package/dist/runtime/page/userAgent.js +57 -0
- package/dist/types.d.ts +12 -0
- package/dist/ui/formatters/audit.d.ts +19 -0
- package/dist/ui/formatters/audit.js +106 -0
- package/dist/ui/formatters/dom.d.ts +1 -1
- package/dist/ui/formatters/dom.js +6 -3
- package/dist/ui/formatters/inspect.js +42 -15
- package/dist/ui/formatters/installSkill.d.ts +11 -0
- package/dist/ui/formatters/installSkill.js +31 -0
- package/dist/ui/formatters/status.js +1 -1
- package/dist/ui/messages/commands.d.ts +44 -7
- package/dist/ui/messages/commands.js +83 -11
- package/dist/ui/messages/preview.d.ts +6 -0
- package/dist/ui/messages/preview.js +9 -1
- package/dist/utils/cssValues.js +36 -4
- package/dist/utils/decisionTrees.js +0 -5
- package/dist/utils/suggestions.d.ts +4 -2
- package/dist/utils/suggestions.js +7 -5
- package/dist/utils/taskMappings.js +1 -1
- package/package.json +4 -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
|
|
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.
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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';
|
|
@@ -668,4 +672,12 @@ export interface FormDiscoveryResult {
|
|
|
668
672
|
}>;
|
|
669
673
|
brief?: boolean | undefined;
|
|
670
674
|
}
|
|
675
|
+
/** Agent whose skill directory receives the bdg skill (`bdg install-skill`). */
|
|
676
|
+
export type SkillTarget = 'claude' | 'agents';
|
|
677
|
+
/** What `bdg install-skill` did to the skill file in one agent's skill directory. */
|
|
678
|
+
export interface InstalledSkill {
|
|
679
|
+
target: SkillTarget;
|
|
680
|
+
path: string;
|
|
681
|
+
status: 'installed' | 'updated' | 'unchanged';
|
|
682
|
+
}
|
|
671
683
|
//# sourceMappingURL=types.d.ts.map
|
|
@@ -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
|
|
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
|
|
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
|
}
|