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
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bdg dom audit [check...]` - page-wide checks without a screenshot or
|
|
3
|
+
* `dom eval`: text below a WCAG contrast level, what makes the page scroll
|
|
4
|
+
* sideways and cut-off text and scaled images, fixed and sticky layers, and
|
|
5
|
+
* running animations.
|
|
6
|
+
*/
|
|
7
|
+
import { InvalidArgumentError } from 'commander';
|
|
8
|
+
import { runCommand } from '../shared/CommandRunner.js';
|
|
9
|
+
import { jsonOption } from '../shared/commonOptions.js';
|
|
10
|
+
import { integerOption } from '../shared/validation.js';
|
|
11
|
+
import { unknownAuditCheckMessage } from '../../errors/messages.js';
|
|
12
|
+
import { domAudit } from '../../ipc/client.js';
|
|
13
|
+
import { AUDIT_CHECKS } from '../../ipc/protocol/auditTypes.js';
|
|
14
|
+
import { formatAudit } from '../../ui/formatters/audit.js';
|
|
15
|
+
import { AUDIT_HELP_EXAMPLES } from '../../ui/messages/commands.js';
|
|
16
|
+
import { EXIT_CODES } from '../../utils/exitCodes.js';
|
|
17
|
+
import { findSimilar } from '../../utils/suggestions.js';
|
|
18
|
+
/**
|
|
19
|
+
* Register `bdg dom audit`.
|
|
20
|
+
*
|
|
21
|
+
* @param dom - The `dom` command group
|
|
22
|
+
*/
|
|
23
|
+
export function registerAuditCommand(dom) {
|
|
24
|
+
dom
|
|
25
|
+
.command('audit')
|
|
26
|
+
.description('Page-wide checks: text below WCAG contrast, sideways scroll and cut-off text, scaled images, fixed/sticky layers, animations')
|
|
27
|
+
.argument('[checks...]', `Checks to run: ${AUDIT_CHECKS.join(', ')} (default: all)`, checkList)
|
|
28
|
+
.option('--level <level>', 'WCAG level text must reach: AA or AAA (default: AA)', levelOption)
|
|
29
|
+
.option('--limit <n>', 'Findings listed per check (default: 20)', integerOption(1, 500))
|
|
30
|
+
.addOption(jsonOption())
|
|
31
|
+
.addHelpText('after', AUDIT_HELP_EXAMPLES)
|
|
32
|
+
.action(async (checks, options) => {
|
|
33
|
+
await runCommand(() => audit(checks, options), options, formatAudit);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse the checks, rejecting unknown names with the closest one.
|
|
38
|
+
*
|
|
39
|
+
* @param value - One check name
|
|
40
|
+
* @param previous - Checks so far
|
|
41
|
+
* @returns Checks
|
|
42
|
+
* @throws InvalidArgumentError for an unknown name
|
|
43
|
+
*/
|
|
44
|
+
function checkList(value, previous = []) {
|
|
45
|
+
const check = AUDIT_CHECKS.find((name) => name === value.trim().toLowerCase());
|
|
46
|
+
if (!check) {
|
|
47
|
+
throw new InvalidArgumentError(unknownAuditCheckMessage(value, findSimilar(value, [...AUDIT_CHECKS]), AUDIT_CHECKS));
|
|
48
|
+
}
|
|
49
|
+
return previous.includes(check) ? previous : [...previous, check];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Parse `--level`.
|
|
53
|
+
*
|
|
54
|
+
* @param value - Level
|
|
55
|
+
* @returns AA or AAA
|
|
56
|
+
* @throws InvalidArgumentError for another value
|
|
57
|
+
*/
|
|
58
|
+
function levelOption(value) {
|
|
59
|
+
const level = value.trim().toUpperCase();
|
|
60
|
+
if (level === 'AA' || level === 'AAA')
|
|
61
|
+
return level;
|
|
62
|
+
throw new InvalidArgumentError('Use AA or AAA');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Ask the daemon to audit the page.
|
|
66
|
+
*
|
|
67
|
+
* @param checks - Checks given (all when none)
|
|
68
|
+
* @param options - Level and limit
|
|
69
|
+
* @returns Command result
|
|
70
|
+
*/
|
|
71
|
+
async function audit(checks, options) {
|
|
72
|
+
const response = await domAudit({
|
|
73
|
+
checks: checks && checks.length > 0 ? checks : [...AUDIT_CHECKS],
|
|
74
|
+
...(options.level && { level: options.level }),
|
|
75
|
+
...(options.limit !== undefined && { limit: options.limit }),
|
|
76
|
+
});
|
|
77
|
+
if (response.status === 'error' || !response.data) {
|
|
78
|
+
return {
|
|
79
|
+
success: false,
|
|
80
|
+
error: response.error ?? 'Failed to audit the page',
|
|
81
|
+
exitCode: response.exitCode ?? EXIT_CODES.CDP_CONNECTION_FAILURE,
|
|
82
|
+
...(response.suggestion && { errorContext: { suggestion: response.suggestion } }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { success: true, data: response.data };
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=audit.js.map
|
|
@@ -12,12 +12,12 @@ import { runCommand } from '../shared/CommandRunner.js';
|
|
|
12
12
|
import { jsonOption, SELECTOR_OR_INDEX_ARGUMENT } from '../shared/commonOptions.js';
|
|
13
13
|
import { integerOption } from '../shared/validation.js';
|
|
14
14
|
import { CommandError } from '../../errors/index.js';
|
|
15
|
-
import { VIA_LABEL_SUFFIX, conflictingOptionsMessage, indexSourceText, internalError, scrollOptionsError, } from '../../errors/messages.js';
|
|
16
|
-
import { domClick, domFill, domPressKey, domScroll, domSubmit } from '../../ipc/client.js';
|
|
15
|
+
import { VIA_LABEL_SUFFIX, conflictingOptionsMessage, hoverOffWithTargetError, missingArgumentError, indexSourceText, internalError, scrollOptionsError, } from '../../errors/messages.js';
|
|
16
|
+
import { callCDP, domClick, domFill, domPressKey, domScroll, domSubmit } from '../../ipc/client.js';
|
|
17
17
|
import { findUnknownModifiers } from '../../runtime/dom/keyMapping.js';
|
|
18
18
|
import { formatTriggeredRequestLines, formatTriggeredRequestsTitle, } from '../../ui/formatters/triggeredRequests.js';
|
|
19
19
|
import { OutputFormatter } from '../../ui/formatting.js';
|
|
20
|
-
import { CLICK_RESULT_WAIT_HELP, POINTER_ACTION_DONE, POINTER_ACTION_NOUN, actionStatusLine, dialogConsoleText, newMessageText, pageNavigationText, shownElementText, stillChangingNote, } from '../../ui/messages/commands.js';
|
|
20
|
+
import { CLICK_RESULT_WAIT_HELP, HOVER_OFF_DONE, HOVER_USAGE, POINTER_ACTION_DONE, POINTER_ACTION_NOUN, actionStatusLine, dialogConsoleText, moreMessagesText, newMessageText, pageNavigationText, shownElementText, stillChangingNote, } from '../../ui/messages/commands.js';
|
|
21
21
|
import { sessionCommand } from '../../ui/messages/sessionCommand.js';
|
|
22
22
|
import { EXIT_CODES } from '../../utils/exitCodes.js';
|
|
23
23
|
/** Help of `--strict` on click and hover */
|
|
@@ -96,13 +96,18 @@ export function registerFormInteractionCommands(program) {
|
|
|
96
96
|
});
|
|
97
97
|
domCommand
|
|
98
98
|
.command('hover')
|
|
99
|
-
.description('Move the mouse over an element (shows hover menus and tooltips)')
|
|
100
|
-
.argument('
|
|
99
|
+
.description('Move the mouse over an element (shows hover menus and tooltips); --off moves it off the page')
|
|
100
|
+
.argument('[selectorOrIndex]', SELECTOR_OR_INDEX_ARGUMENT)
|
|
101
101
|
.option('--index <n>', 'Element index if selector matches multiple (0-based)', integerOption(0))
|
|
102
|
+
.option('--off', 'Move the mouse off the page instead (closes menus that open on hover)')
|
|
102
103
|
.option('--strict', STRICT_OPTION_HELP)
|
|
103
104
|
.option('--no-wait', 'Skip waiting for network stability after hovering')
|
|
104
105
|
.addOption(jsonOption())
|
|
105
106
|
.action(async (selectorOrIndex, options) => {
|
|
107
|
+
if (options.off || selectorOrIndex === undefined) {
|
|
108
|
+
await hoverOff(selectorOrIndex, options);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
106
111
|
await runPointerCommand(selectorOrIndex, options, 'hover');
|
|
107
112
|
});
|
|
108
113
|
domCommand
|
|
@@ -260,6 +265,28 @@ function scrollOptionsProblem(selector, options) {
|
|
|
260
265
|
* @param options - Command options
|
|
261
266
|
* @param action - Pointer action
|
|
262
267
|
*/
|
|
268
|
+
/**
|
|
269
|
+
* `bdg dom hover --off`: move the mouse off the page, so `mouseleave` and
|
|
270
|
+
* `:hover` end and menus that open on hover close.
|
|
271
|
+
*
|
|
272
|
+
* @param selectorOrIndex - Must be absent with --off
|
|
273
|
+
* @param options - Command options
|
|
274
|
+
*/
|
|
275
|
+
async function hoverOff(selectorOrIndex, options) {
|
|
276
|
+
await runCommand(async () => {
|
|
277
|
+
if (!options.off || selectorOrIndex !== undefined || options.index !== undefined) {
|
|
278
|
+
const err = options.off ? hoverOffWithTargetError() : missingArgumentError(HOVER_USAGE);
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
error: err.message,
|
|
282
|
+
exitCode: EXIT_CODES.INVALID_ARGUMENTS,
|
|
283
|
+
errorContext: { suggestion: err.suggestion },
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
await callCDP('Input.dispatchMouseEvent', { type: 'mouseMoved', x: -1, y: -1 });
|
|
287
|
+
return { success: true, data: { pointer: 'off' } };
|
|
288
|
+
}, options, () => HOVER_OFF_DONE);
|
|
289
|
+
}
|
|
263
290
|
async function runPointerCommand(selectorOrIndex, options, action) {
|
|
264
291
|
await runCommand(() => options.double && options.right
|
|
265
292
|
? Promise.resolve({
|
|
@@ -316,7 +343,10 @@ function formatActionOutput(done, details, result, options = {}) {
|
|
|
316
343
|
fmt.keyValueList(details, keyWidth);
|
|
317
344
|
if (result.navigation)
|
|
318
345
|
fmt.keyValue('Page', pageNavigationText(result.navigation), keyWidth);
|
|
319
|
-
listRows(fmt, 'New text',
|
|
346
|
+
listRows(fmt, 'New text', [
|
|
347
|
+
...(result.messages ?? []).map(newMessageText),
|
|
348
|
+
...(result.moreMessages ? [moreMessagesText(result.moreMessages)] : []),
|
|
349
|
+
], keyWidth);
|
|
320
350
|
listRows(fmt, 'Shown', (result.shown ?? []).map(shownElementText), keyWidth);
|
|
321
351
|
const omitted = result.triggeredRequestsOmitted;
|
|
322
352
|
const requests = formatTriggeredRequestLines(result.triggeredRequests ?? [], omitted);
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { ElementState, KeyAttributes } from '../../../types.js';
|
|
7
7
|
/**
|
|
8
|
-
* The key attributes of an element:
|
|
9
|
-
*
|
|
8
|
+
* The key attributes of an element: its shadow `part` name (how a page's CSS
|
|
9
|
+
* reaches it with `::part()`), the identifying attributes of its type that
|
|
10
|
+
* are set (empty ones left out) and the live state of a form control
|
|
10
11
|
* (type, current value, checked, selected options). Values arrive masked
|
|
11
12
|
* from the page (`ELEMENT_STATE_JS`); a hidden input's value is never read,
|
|
12
13
|
* and a sensitive field's value is masked here again in case it was not.
|
|
@@ -16,8 +16,9 @@ const ATTRIBUTES_BY_TAG = {
|
|
|
16
16
|
form: ['action', 'method'],
|
|
17
17
|
};
|
|
18
18
|
/**
|
|
19
|
-
* The key attributes of an element:
|
|
20
|
-
*
|
|
19
|
+
* The key attributes of an element: its shadow `part` name (how a page's CSS
|
|
20
|
+
* reaches it with `::part()`), the identifying attributes of its type that
|
|
21
|
+
* are set (empty ones left out) and the live state of a form control
|
|
21
22
|
* (type, current value, checked, selected options). Values arrive masked
|
|
22
23
|
* from the page (`ELEMENT_STATE_JS`); a hidden input's value is never read,
|
|
23
24
|
* and a sensitive field's value is masked here again in case it was not.
|
|
@@ -29,9 +30,10 @@ const ATTRIBUTES_BY_TAG = {
|
|
|
29
30
|
*/
|
|
30
31
|
export function keyAttributes(tag, attributes, state = {}) {
|
|
31
32
|
const names = ATTRIBUTES_BY_TAG[tag];
|
|
33
|
+
const part = attributes['part'];
|
|
32
34
|
if (!names)
|
|
33
|
-
return undefined;
|
|
34
|
-
const result = {};
|
|
35
|
+
return part ? { part } : undefined;
|
|
36
|
+
const result = part ? { part } : {};
|
|
35
37
|
const type = state.type ?? attributes['type'];
|
|
36
38
|
if (type)
|
|
37
39
|
result['type'] = type;
|
|
@@ -175,14 +175,22 @@ async function useUnitPixelRatio(devicePixelRatio, viewport) {
|
|
|
175
175
|
const sessionViewport = readSessionMetadata()?.viewport;
|
|
176
176
|
const size = sessionViewport ?? (await windowSize(viewport));
|
|
177
177
|
await callCDP('Emulation.setDeviceMetricsOverride', viewportOverride(size, 1));
|
|
178
|
-
return
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
return restoreSessionMetrics;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Put back the session's device metrics: its `--viewport` (and a phone's
|
|
182
|
+
* touch input, which a capture beyond the viewport turns off), else none.
|
|
183
|
+
*/
|
|
184
|
+
async function restoreSessionMetrics() {
|
|
185
|
+
const sessionViewport = readSessionMetadata()?.viewport;
|
|
186
|
+
if (!sessionViewport) {
|
|
187
|
+
await callCDP('Emulation.clearDeviceMetricsOverride', {});
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
await callCDP('Emulation.setDeviceMetricsOverride', viewportOverride(sessionViewport));
|
|
191
|
+
if (sessionViewport.mobile) {
|
|
192
|
+
await callCDP('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 5 });
|
|
193
|
+
}
|
|
186
194
|
}
|
|
187
195
|
/**
|
|
188
196
|
* Get the bounding box (border box, so padding and border are included) of an
|
|
@@ -334,13 +342,17 @@ export async function capturePageScreenshot(outputPath, options = {}) {
|
|
|
334
342
|
/** Descendants {@link CONTENT_OVERFLOW_JS} looks at, so a huge element stays cheap */
|
|
335
343
|
const OVERFLOW_SCAN_LIMIT = 2000;
|
|
336
344
|
/**
|
|
337
|
-
* Page-side distances (CSS px, never negative) by which an element
|
|
338
|
-
*
|
|
339
|
-
* absolutely positioned and transformed
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* element clips its
|
|
345
|
+
* Page-side distances (CSS px, never negative) by which what an element
|
|
346
|
+
* paints reaches beyond its border box on each side: its rendered
|
|
347
|
+
* descendants (uncleared floats, absolutely positioned and transformed
|
|
348
|
+
* children), its text (descenders past a tight line height, read from its
|
|
349
|
+
* scroll size beyond its client size, in transformed px, to the left on an
|
|
350
|
+
* RTL element), and its own outer box shadows and outline (a focus ring).
|
|
351
|
+
* Descendants of an element that clips its overflow (`overflow` other than
|
|
352
|
+
* `visible`) are cut off by it and not counted, nor are fixed ones (they
|
|
353
|
+
* belong to the viewport) or what lies outside the document (skip links at
|
|
354
|
+
* -9999px). Only the shadows and outline count when the element clips its
|
|
355
|
+
* own overflow.
|
|
344
356
|
*/
|
|
345
357
|
const CONTENT_OVERFLOW_JS = `function () {
|
|
346
358
|
const view = this.ownerDocument.defaultView;
|
|
@@ -365,8 +377,37 @@ const CONTENT_OVERFLOW_JS = `function () {
|
|
|
365
377
|
if (!clips(style)) walk(child);
|
|
366
378
|
}
|
|
367
379
|
};
|
|
368
|
-
|
|
369
|
-
|
|
380
|
+
const ownStyle = view.getComputedStyle(this);
|
|
381
|
+
if (!clips(ownStyle)) {
|
|
382
|
+
walk(this);
|
|
383
|
+
const scaleX = this.offsetWidth ? own.width / this.offsetWidth : 1;
|
|
384
|
+
const scaleY = this.offsetHeight ? own.height / this.offsetHeight : 1;
|
|
385
|
+
const wider = Math.max(0, this.scrollWidth - this.clientWidth) * scaleX;
|
|
386
|
+
const taller = Math.max(0, this.scrollHeight - this.clientHeight) * scaleY;
|
|
387
|
+
if (ownStyle.direction === 'rtl') reach.left = Math.min(reach.left, own.left - wider);
|
|
388
|
+
else reach.right = Math.max(reach.right, own.right + wider);
|
|
389
|
+
reach.bottom = Math.max(reach.bottom, own.bottom + taller);
|
|
390
|
+
}
|
|
391
|
+
const ink = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
392
|
+
const grow = (side, amount) => { ink[side] = Math.max(ink[side], amount); };
|
|
393
|
+
for (const layer of ownStyle.boxShadow === 'none' ? [] : ownStyle.boxShadow.split(/,(?![^(]*\\))/)) {
|
|
394
|
+
if (/\\binset\\b/.test(layer)) continue;
|
|
395
|
+
const [x = 0, y = 0, blur = 0, spread = 0] = (layer.replace(/(rgba?|hsla?|color|oklch|lab|lch)\\([^)]*\\)/g, '').match(/-?[\\d.]+px/g) || []).map(parseFloat);
|
|
396
|
+
grow('left', blur + spread - x);
|
|
397
|
+
grow('right', blur + spread + x);
|
|
398
|
+
grow('top', blur + spread - y);
|
|
399
|
+
grow('bottom', blur + spread + y);
|
|
400
|
+
}
|
|
401
|
+
if (ownStyle.outlineStyle !== 'none') {
|
|
402
|
+
const outline = parseFloat(ownStyle.outlineWidth) + parseFloat(ownStyle.outlineOffset);
|
|
403
|
+
['left', 'top', 'right', 'bottom'].forEach((side) => grow(side, outline));
|
|
404
|
+
}
|
|
405
|
+
return {
|
|
406
|
+
left: Math.max(own.left - reach.left, ink.left),
|
|
407
|
+
top: Math.max(own.top - reach.top, ink.top),
|
|
408
|
+
right: Math.max(reach.right - own.right, ink.right),
|
|
409
|
+
bottom: Math.max(reach.bottom - own.bottom, ink.bottom)
|
|
410
|
+
};
|
|
370
411
|
}`;
|
|
371
412
|
/**
|
|
372
413
|
* The visible viewport (without scrollbars) in CSS px.
|
|
@@ -393,27 +434,80 @@ function insideView(area, view) {
|
|
|
393
434
|
}
|
|
394
435
|
/**
|
|
395
436
|
* Measure the area to capture and, when it fits in the viewport but is not
|
|
396
|
-
* in view, scroll it to the middle first
|
|
397
|
-
* keeps the page as it is; one
|
|
398
|
-
* without its scrollbar,
|
|
399
|
-
*
|
|
437
|
+
* in view, scroll it to the middle first (the returned position puts the
|
|
438
|
+
* page back). A capture inside the viewport keeps the page as it is; one
|
|
439
|
+
* beyond it makes Chrome lay the page out without its scrollbar, so for an
|
|
440
|
+
* area larger than the viewport the scrollbars are hidden first and the
|
|
441
|
+
* area measured in that layout (centered content would else move by half
|
|
442
|
+
* the scrollbar's width).
|
|
400
443
|
*
|
|
401
444
|
* @param ref - Node reference
|
|
402
|
-
* @
|
|
445
|
+
* @param padding - Extra space around the area (CSS px)
|
|
446
|
+
* @returns Border box, area to capture (viewport coordinates), whether it is
|
|
447
|
+
* in view, and the scroll position to restore when it scrolled
|
|
403
448
|
*/
|
|
404
|
-
async function measureInView(ref) {
|
|
449
|
+
async function measureInView(ref, padding) {
|
|
405
450
|
const view = await visibleViewport();
|
|
406
451
|
let box = await getElementBounds(ref);
|
|
407
|
-
let bounds = await captureArea(ref, box);
|
|
452
|
+
let bounds = await captureArea(ref, box, padding);
|
|
408
453
|
const fits = bounds.width <= view.width && bounds.height <= view.height;
|
|
409
|
-
if (fits
|
|
410
|
-
|
|
411
|
-
const dy = bounds.y + bounds.height / 2 - view.height / 2;
|
|
412
|
-
await callCDP('Runtime.evaluate', { expression: `window.scrollBy(${dx}, ${dy})` });
|
|
454
|
+
if (!fits) {
|
|
455
|
+
await keepLayoutWithoutScrollbars(view);
|
|
413
456
|
box = await getElementBounds(ref);
|
|
414
|
-
bounds
|
|
457
|
+
return { box, bounds: await captureArea(ref, box, padding), inView: false };
|
|
415
458
|
}
|
|
416
|
-
|
|
459
|
+
if (insideView(bounds, view))
|
|
460
|
+
return { box, bounds, inView: true };
|
|
461
|
+
const scrolledFrom = await scrollPosition();
|
|
462
|
+
const dx = bounds.x + bounds.width / 2 - view.width / 2;
|
|
463
|
+
const dy = bounds.y + bounds.height / 2 - view.height / 2;
|
|
464
|
+
await callCDP('Runtime.evaluate', { expression: `window.scrollBy(${dx}, ${dy})` });
|
|
465
|
+
try {
|
|
466
|
+
box = await getElementBounds(ref);
|
|
467
|
+
bounds = await captureArea(ref, box, padding);
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
await restoreScrollPosition(scrolledFrom);
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
return { box, bounds, inView: insideView(bounds, view), scrolledFrom };
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Lay the page out at its current width without scrollbars: a capture
|
|
477
|
+
* beyond the viewport hides them, and without this the page would widen by
|
|
478
|
+
* the scrollbar and centered content move after it was measured. The
|
|
479
|
+
* viewport is overridden at the visible width (CSS px, pixel ratio 1; still
|
|
480
|
+
* a phone in a `--mobile` session) until
|
|
481
|
+
* {@link restoreViewport}.
|
|
482
|
+
*
|
|
483
|
+
* @param view - Visible viewport size
|
|
484
|
+
*/
|
|
485
|
+
async function keepLayoutWithoutScrollbars(view) {
|
|
486
|
+
const phone = readSessionMetadata()?.viewport?.mobile;
|
|
487
|
+
await callCDP('Emulation.setScrollbarsHidden', { hidden: true });
|
|
488
|
+
await callCDP('Emulation.setDeviceMetricsOverride', viewportOverride({ ...view, ...(phone && { mobile: true }) }, 1));
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Put back the viewport a capture changed: the session's `--viewport`, else
|
|
492
|
+
* none, with scrollbars shown.
|
|
493
|
+
*/
|
|
494
|
+
async function restoreViewport() {
|
|
495
|
+
await callCDP('Emulation.setScrollbarsHidden', { hidden: false });
|
|
496
|
+
await restoreSessionMetrics();
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The page's scroll position.
|
|
500
|
+
*
|
|
501
|
+
* @returns Scroll offsets in CSS px
|
|
502
|
+
*/
|
|
503
|
+
async function scrollPosition() {
|
|
504
|
+
const response = await callCDP('Runtime.evaluate', {
|
|
505
|
+
expression: '[window.scrollX, window.scrollY]',
|
|
506
|
+
returnByValue: true,
|
|
507
|
+
});
|
|
508
|
+
const value = response.data?.result?.result?.value;
|
|
509
|
+
const [x, y] = Array.isArray(value) ? value : [];
|
|
510
|
+
return { x: x ?? 0, y: y ?? 0 };
|
|
417
511
|
}
|
|
418
512
|
/** Overflow (px) below which the capture keeps to the border box (subpixel rounding) */
|
|
419
513
|
const OVERFLOW_SLACK = 1;
|
|
@@ -424,9 +518,30 @@ const OVERFLOW_SLACK = 1;
|
|
|
424
518
|
*
|
|
425
519
|
* @param ref - Node reference
|
|
426
520
|
* @param bounds - Border box (DOM.getBoxModel coordinates)
|
|
427
|
-
* @
|
|
521
|
+
* @param padding - Extra space around it (CSS px)
|
|
522
|
+
* @returns The area, or the border box (with the padding) when nothing
|
|
523
|
+
* overflows (or the page cannot be asked)
|
|
524
|
+
*/
|
|
525
|
+
async function captureArea(ref, bounds, padding) {
|
|
526
|
+
const area = await paintedArea(ref, bounds);
|
|
527
|
+
return padding > 0
|
|
528
|
+
? {
|
|
529
|
+
x: area.x - padding,
|
|
530
|
+
y: area.y - padding,
|
|
531
|
+
width: area.width + 2 * padding,
|
|
532
|
+
height: area.height + 2 * padding,
|
|
533
|
+
}
|
|
534
|
+
: area;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* The border box grown to what the element paints beyond it
|
|
538
|
+
* ({@link CONTENT_OVERFLOW_JS}).
|
|
539
|
+
*
|
|
540
|
+
* @param ref - Node reference
|
|
541
|
+
* @param bounds - Border box
|
|
542
|
+
* @returns The area
|
|
428
543
|
*/
|
|
429
|
-
async function
|
|
544
|
+
async function paintedArea(ref, bounds) {
|
|
430
545
|
const objectGroup = `bdg-shot-${process.pid}`;
|
|
431
546
|
try {
|
|
432
547
|
const resolved = await callCDP('DOM.resolveNode', { ...ref, objectGroup });
|
|
@@ -478,16 +593,20 @@ export async function captureElementScreenshot(outputPath, ref, options = {}) {
|
|
|
478
593
|
const devicePixelRatio = dprResponse.data?.result?.result?.value ?? 1;
|
|
479
594
|
const before = (await callCDP('Page.getLayoutMetrics', {})).data?.result;
|
|
480
595
|
const restoreMetrics = await useUnitPixelRatio(devicePixelRatio, before?.visualViewport ?? { clientWidth: 800, clientHeight: 600 });
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
596
|
+
const restore = async (wide, scrolledFrom) => {
|
|
597
|
+
await (wide ? restoreViewport() : restoreMetrics());
|
|
598
|
+
if (scrolledFrom)
|
|
599
|
+
await restoreScrollPosition(scrolledFrom);
|
|
600
|
+
};
|
|
601
|
+
let measured;
|
|
484
602
|
try {
|
|
485
|
-
|
|
603
|
+
measured = await measureInView(ref, options.padding ?? 0);
|
|
486
604
|
}
|
|
487
605
|
catch (error) {
|
|
488
|
-
await
|
|
606
|
+
await restoreViewport();
|
|
489
607
|
throw error;
|
|
490
608
|
}
|
|
609
|
+
const { box, bounds, inView, scrolledFrom } = measured;
|
|
491
610
|
const originalWidth = bounds.width;
|
|
492
611
|
const originalHeight = bounds.height;
|
|
493
612
|
const resized = shouldResize(originalWidth, originalHeight, noResize);
|
|
@@ -514,7 +633,7 @@ export async function captureElementScreenshot(outputPath, ref, options = {}) {
|
|
|
514
633
|
screenshotResult = screenshotResponse.data?.result;
|
|
515
634
|
}
|
|
516
635
|
finally {
|
|
517
|
-
await
|
|
636
|
+
await restore(!inView, scrolledFrom);
|
|
518
637
|
}
|
|
519
638
|
if (!screenshotResult?.data) {
|
|
520
639
|
throw new CDPConnectionError('No screenshot data returned', new Error('Empty response'));
|
|
@@ -532,6 +651,7 @@ export async function captureElementScreenshot(outputPath, ref, options = {}) {
|
|
|
532
651
|
element: {
|
|
533
652
|
bounds: roundBounds(onPage(box)),
|
|
534
653
|
...(bounds !== box && { captured: roundBounds(clip) }),
|
|
654
|
+
...(options.padding && { padding: options.padding }),
|
|
535
655
|
},
|
|
536
656
|
};
|
|
537
657
|
if (quality !== undefined) {
|
|
@@ -22,6 +22,7 @@ import { registerFormCommand } from './form.js';
|
|
|
22
22
|
import { handleDomFrames } from './frames.js';
|
|
23
23
|
import { DOM_GET_DEFAULT_SELECTOR, handleDomGet } from './get.js';
|
|
24
24
|
import { registerInspectCommand } from './inspect.js';
|
|
25
|
+
import { registerAuditCommand } from './audit.js';
|
|
25
26
|
import { registerLayoutCommand } from './layout.js';
|
|
26
27
|
import { registerListenersCommand } from './listeners.js';
|
|
27
28
|
import { handleDomQuery } from './query.js';
|
|
@@ -41,6 +42,7 @@ export function registerDomCommands(program) {
|
|
|
41
42
|
registerFormCommand(dom);
|
|
42
43
|
registerListenersCommand(dom);
|
|
43
44
|
registerLayoutCommand(dom);
|
|
45
|
+
registerAuditCommand(dom);
|
|
44
46
|
registerInspectCommand(dom);
|
|
45
47
|
registerWaitCommand(dom);
|
|
46
48
|
dom
|
|
@@ -97,7 +99,8 @@ export function registerDomCommands(program) {
|
|
|
97
99
|
.argument('<path>', 'Output file path, or directory for --follow mode')
|
|
98
100
|
.argument('[selector]', 'Element to capture: CSS selector or index from a query (same as --selector / --index)')
|
|
99
101
|
.option('--selector <selector>', 'CSS selector for element capture')
|
|
100
|
-
.option('--index <number>', 'Cached element index (0-based) from previous query', integerOption(0))
|
|
102
|
+
.option('--index <number>', 'Cached element index (0-based) from a previous query; with --selector, which match', integerOption(0))
|
|
103
|
+
.option('--padding <px>', 'Element capture: extra space around it (shadows and focus rings are included anyway)', integerOption(0, 500))
|
|
101
104
|
.option('--format <format>', 'Image format: png or jpeg/jpg (default: from the file extension, else png)', screenshotFormatOption)
|
|
102
105
|
.option('--quality <number>', 'JPEG quality 0-100 (default: 90)', integerOption(0, 100))
|
|
103
106
|
.option('--no-full-page', 'Capture viewport only (default: full page)')
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { extname } from 'path';
|
|
5
5
|
import { DomElementResolver } from './DomElementResolver.js';
|
|
6
|
-
import { capturePageScreenshot, captureElementScreenshot, resolveSelector, } from './helpers/index.js';
|
|
6
|
+
import { capturePageScreenshot, captureElementScreenshot, resolveSelector, selectMatch, } from './helpers/index.js';
|
|
7
7
|
import { runCommand } from '../shared/CommandRunner.js';
|
|
8
8
|
import { assertFilePath, outputPathError } from '../shared/outputFile.js';
|
|
9
9
|
import { positiveIntRule } from '../shared/validation.js';
|
|
10
10
|
import { CommandError } from '../../errors/index.js';
|
|
11
|
-
import {
|
|
11
|
+
import { conflictingTargetError, genericError } from '../../errors/messages.js';
|
|
12
12
|
import { missingArgumentError } from '../../errors/messages.js';
|
|
13
13
|
import { OutputBuilder, buildSuccessResponse } from '../../ui/OutputBuilder.js';
|
|
14
14
|
import { formatDomScreenshot } from '../../ui/formatters/dom.js';
|
|
@@ -60,12 +60,16 @@ function buildElementScreenshotOptions(options) {
|
|
|
60
60
|
format: options.format,
|
|
61
61
|
quality: options.quality,
|
|
62
62
|
noResize: options.resize === false,
|
|
63
|
+
padding: options.padding,
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
function hasElementTarget(options) {
|
|
66
67
|
return options.selector !== undefined || options.index !== undefined;
|
|
67
68
|
}
|
|
68
69
|
async function resolveElementNodeId(options) {
|
|
70
|
+
if (options.selector !== undefined && options.index !== undefined) {
|
|
71
|
+
return { backendNodeId: await selectMatch(options.selector, options.index) };
|
|
72
|
+
}
|
|
69
73
|
if (options.index !== undefined) {
|
|
70
74
|
const resolver = DomElementResolver.getInstance();
|
|
71
75
|
const node = await resolver.getNodeIdForIndex(options.index);
|
|
@@ -207,8 +211,8 @@ function reportSequenceError(error, captured, json) {
|
|
|
207
211
|
process.exit(exitCode);
|
|
208
212
|
}
|
|
209
213
|
/**
|
|
210
|
-
* Reject options that would be ignored: `--
|
|
211
|
-
*
|
|
214
|
+
* Reject options that would be ignored: `--quality` for a PNG, and
|
|
215
|
+
* `--padding` without an element.
|
|
212
216
|
*
|
|
213
217
|
* @param outputPath - File to write
|
|
214
218
|
* @param options - Command options
|
|
@@ -216,8 +220,8 @@ function reportSequenceError(error, captured, json) {
|
|
|
216
220
|
*/
|
|
217
221
|
function assertScreenshotOptions(outputPath, options) {
|
|
218
222
|
let message;
|
|
219
|
-
if (options.
|
|
220
|
-
message =
|
|
223
|
+
if (options.padding !== undefined && !hasElementTarget(options)) {
|
|
224
|
+
message = '--padding applies to element captures; name an element (selector or index)';
|
|
221
225
|
}
|
|
222
226
|
else if (options.quality !== undefined &&
|
|
223
227
|
!options.follow &&
|
|
@@ -24,7 +24,7 @@ export function registerWaitCommand(dom) {
|
|
|
24
24
|
dom
|
|
25
25
|
.command('wait')
|
|
26
26
|
.description('Wait until elements appear, become visible, contain a text or are gone (or the page loads)')
|
|
27
|
-
.argument('[selector]', 'CSS selector (:has-text, :visible allowed; shadow DOM and same-origin iframes searched);
|
|
27
|
+
.argument('[selector]', 'CSS selector (:has-text, :visible allowed; shadow DOM and same-origin iframes searched); without one, waits for the page to load (--load)')
|
|
28
28
|
.option('--text <text>', 'A match must contain this text (case-insensitive)')
|
|
29
29
|
.option('--visible', 'Only count visible matches')
|
|
30
30
|
.option('--gone', 'Wait until no element matches (none visible, with --visible)')
|
|
@@ -44,7 +44,9 @@ export function registerWaitCommand(dom) {
|
|
|
44
44
|
* @returns Command result
|
|
45
45
|
*/
|
|
46
46
|
async function waitFor(selector, options) {
|
|
47
|
-
const
|
|
47
|
+
const load = options.load === true ||
|
|
48
|
+
(selector === undefined && options.text === undefined && !options.gone && !options.visible);
|
|
49
|
+
const needsSelector = options.text !== undefined || options.gone === true || !load;
|
|
48
50
|
if (selector === undefined && needsSelector) {
|
|
49
51
|
const err = waitTargetRequiredError();
|
|
50
52
|
return {
|
|
@@ -58,7 +60,7 @@ async function waitFor(selector, options) {
|
|
|
58
60
|
...filterDefined({ selector, text: options.text }),
|
|
59
61
|
...(options.gone && { gone: true }),
|
|
60
62
|
...(options.visible && { visible: true }),
|
|
61
|
-
...(
|
|
63
|
+
...(load && { load: true }),
|
|
62
64
|
timeout: options.timeout,
|
|
63
65
|
});
|
|
64
66
|
if (response.status === 'error' || !response.data) {
|
|
@@ -97,7 +97,7 @@ function convertCommand(command) {
|
|
|
97
97
|
function generateRuntimeState() {
|
|
98
98
|
const sessionActive = readLiveDaemonPid() !== null;
|
|
99
99
|
const availableCommands = sessionActive
|
|
100
|
-
? ['peek', '
|
|
100
|
+
? ['peek', 'details', 'dom', 'network', 'console', 'cdp', 'status', 'sessions', 'stop']
|
|
101
101
|
: ['bdg <url>', 'sessions', 'cleanup', '--help', '--version'];
|
|
102
102
|
return {
|
|
103
103
|
sessionActive,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import type { InstalledSkill, SkillTarget } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Copy the bdg skill into each target's skill directory, overwriting an
|
|
5
|
+
* older copy.
|
|
6
|
+
*
|
|
7
|
+
* @param targets - Agents to install for
|
|
8
|
+
* @param home - Home directory the skill roots are relative to
|
|
9
|
+
* @param source - SKILL.md to copy
|
|
10
|
+
* @returns One entry per target, in the given order
|
|
11
|
+
* @throws CommandError when the source is missing (83) or a write fails (82)
|
|
12
|
+
*/
|
|
13
|
+
export declare function installSkill(targets: SkillTarget[], home?: string, source?: string): InstalledSkill[];
|
|
14
|
+
/**
|
|
15
|
+
* Register the install-skill command.
|
|
16
|
+
*
|
|
17
|
+
* @param program - Commander.js Command instance to register commands on
|
|
18
|
+
*/
|
|
19
|
+
export declare function registerInstallSkillCommand(program: Command): void;
|
|
20
|
+
//# sourceMappingURL=installSkill.d.ts.map
|