browser-debugger-cli 0.13.0 → 0.14.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 +100 -186
- package/README.md +4 -4
- package/dist/commands/console.js +5 -1
- package/dist/commands/dom/a11y.d.ts +1 -1
- package/dist/commands/dom/a11y.js +20 -20
- package/dist/commands/dom/eval.d.ts +2 -1
- package/dist/commands/dom/eval.js +21 -3
- package/dist/commands/dom/formInteraction.js +1 -1
- package/dist/commands/dom/get.js +25 -7
- package/dist/commands/dom/index.js +7 -2
- package/dist/commands/dom/query.d.ts +2 -1
- package/dist/commands/dom/query.js +5 -3
- package/dist/commands/dom/screenshot.js +1 -0
- package/dist/commands/helpJson.js +1 -1
- package/dist/commands/network/list.js +46 -3
- package/dist/commands/optionBehaviors.d.ts +25 -2
- package/dist/commands/optionBehaviors.js +55 -42
- package/dist/commands/peek.js +3 -0
- package/dist/commands/shared/CommandRunner.js +13 -13
- package/dist/commands/shared/daemonErrorHandler.js +2 -2
- package/dist/commands/shared/dataFetcher.d.ts +4 -2
- package/dist/commands/shared/dataFetcher.js +11 -3
- package/dist/commands/shared/handleValidationError.js +3 -3
- package/dist/commands/shared/optionTypes.d.ts +14 -3
- package/dist/commands/shared/startHelpers.js +3 -3
- package/dist/connection/chromeIdentity.d.ts +8 -2
- package/dist/connection/chromeIdentity.js +85 -13
- package/dist/constants.d.ts +29 -1
- package/dist/constants.js +35 -1
- package/dist/daemon/SessionController.js +2 -0
- package/dist/daemon/session/Session.d.ts +2 -1
- package/dist/daemon/session/Session.js +10 -2
- package/dist/daemon/session/TelemetryStore.d.ts +7 -0
- package/dist/daemon/session/TelemetryStore.js +6 -0
- package/dist/daemon/session/commandRegistry.js +23 -5
- package/dist/daemon/session/matchedStylesReset.d.ts +26 -0
- package/dist/daemon/session/matchedStylesReset.js +46 -0
- package/dist/daemon/session/plugins.js +1 -0
- package/dist/daemon/session/triggeredRequests.d.ts +0 -5
- package/dist/daemon/session/triggeredRequests.js +13 -7
- package/dist/daemon.js +742 -460
- package/dist/errors/messages.d.ts +8 -0
- package/dist/errors/messages.js +10 -0
- package/dist/index.js +710 -518
- package/dist/ipc/protocol/commands.d.ts +4 -0
- package/dist/ipc/protocol/inspectTypes.d.ts +5 -2
- package/dist/ipc/session/types.d.ts +5 -1
- package/dist/program.d.ts +14 -0
- package/dist/program.js +53 -0
- package/dist/runtime/dom/elementGeometry.d.ts +23 -0
- package/dist/runtime/dom/elementGeometry.js +17 -15
- package/dist/runtime/dom/elementInfo.d.ts +6 -4
- package/dist/runtime/dom/elementInfo.js +7 -4
- package/dist/runtime/dom/frameScopedConnection.d.ts +7 -0
- package/dist/runtime/dom/frameScopedConnection.js +2 -2
- package/dist/runtime/dom/inspect.d.ts +17 -3
- package/dist/runtime/dom/inspect.js +40 -26
- package/dist/runtime/dom/inspectModel.d.ts +3 -3
- package/dist/runtime/dom/inspectRules.d.ts +29 -3
- package/dist/runtime/dom/inspectRules.js +205 -11
- package/dist/runtime/dom/layout.d.ts +0 -2
- package/dist/runtime/dom/layout.js +1 -2
- package/dist/runtime/dom/reactEventHelpers.d.ts +4 -1
- package/dist/runtime/dom/reactEventHelpers.js +9 -2
- package/dist/runtime/dom/targetNode.d.ts +10 -6
- package/dist/runtime/dom/targetNode.js +15 -8
- package/dist/telemetry/a11y.d.ts +15 -1
- package/dist/telemetry/a11y.js +83 -0
- package/dist/telemetry/har/builder.js +1 -1
- package/dist/telemetry/network.d.ts +13 -16
- package/dist/telemetry/network.js +30 -52
- package/dist/telemetry/networkRetention.d.ts +83 -0
- package/dist/telemetry/networkRetention.js +117 -0
- package/dist/types.d.ts +26 -0
- package/dist/ui/OutputBuilder.d.ts +10 -0
- package/dist/ui/OutputBuilder.js +12 -0
- package/dist/ui/formatters/a11y.d.ts +5 -7
- package/dist/ui/formatters/a11y.js +7 -61
- package/dist/ui/formatters/console/chronological.js +4 -4
- package/dist/ui/formatters/console/follow.d.ts +4 -2
- package/dist/ui/formatters/console/follow.js +6 -3
- package/dist/ui/formatters/console/json.d.ts +3 -6
- package/dist/ui/formatters/console/json.js +9 -13
- package/dist/ui/formatters/console/shared.d.ts +17 -2
- package/dist/ui/formatters/console/shared.js +17 -0
- package/dist/ui/formatters/console/summarize.d.ts +2 -2
- package/dist/ui/formatters/console/summarize.js +22 -7
- package/dist/ui/formatters/console.d.ts +1 -1
- package/dist/ui/formatters/console.js +1 -5
- package/dist/ui/formatters/details.js +1 -1
- package/dist/ui/formatters/dom.d.ts +13 -4
- package/dist/ui/formatters/dom.js +25 -7
- package/dist/ui/formatters/layout.js +2 -1
- package/dist/ui/formatters/longValues.d.ts +14 -0
- package/dist/ui/formatters/longValues.js +23 -0
- package/dist/ui/formatters/networkList.d.ts +8 -2
- package/dist/ui/formatters/networkList.js +11 -2
- package/dist/ui/formatters/preview.d.ts +4 -1
- package/dist/ui/formatters/preview.js +55 -13
- package/dist/ui/formatters/status.js +7 -0
- package/dist/ui/formatters/triggeredRequests.js +2 -1
- package/dist/ui/messages/chrome.d.ts +20 -1
- package/dist/ui/messages/chrome.js +29 -3
- package/dist/ui/messages/commands.d.ts +29 -8
- package/dist/ui/messages/commands.js +36 -8
- package/dist/ui/messages/networkMessages.d.ts +24 -0
- package/dist/ui/messages/networkMessages.js +45 -0
- package/dist/utils/http.d.ts +9 -2
- package/dist/utils/http.js +4 -3
- package/dist/utils/strings.d.ts +19 -0
- package/dist/utils/strings.js +16 -0
- package/package.json +2 -2
|
@@ -37,24 +37,218 @@ export function trackStyleSheets(cdp) {
|
|
|
37
37
|
export function styleSheetHeaders(cdp) {
|
|
38
38
|
return headersByConnection.get(cdp)?.values() ?? [];
|
|
39
39
|
}
|
|
40
|
+
/** Time allowed for the matched rules behind the default hints; a read within it clears the slow mark */
|
|
41
|
+
export const HINTS_BUDGET_MS = 1000;
|
|
42
|
+
/** Time allowed for them with --rules or --why */
|
|
43
|
+
export const RULES_BUDGET_MS = 5000;
|
|
44
|
+
/** Answers that came faster are not kept: reading again is cheap and always current */
|
|
45
|
+
const KEEP_ANSWERS_SLOWER_THAN_MS = 300;
|
|
46
|
+
/**
|
|
47
|
+
* How long a kept answer is reused. Page state such as `:checked`, `:hover`
|
|
48
|
+
* or `:focus` changes without any CDP event, so answers are kept only briefly
|
|
49
|
+
* (and dropped by every command that may change the page, see {@link resetMatchedStyles}).
|
|
50
|
+
*/
|
|
51
|
+
const KEPT_ANSWER_TTL_MS = 5000;
|
|
52
|
+
/** Answers kept per document (one can be several MB on CSS-heavy pages) */
|
|
53
|
+
const MAX_KEPT_ANSWERS = 4;
|
|
54
|
+
/** Stylesheet events: kept answers are dropped and the slow mark cleared */
|
|
55
|
+
const STYLESHEET_EVENTS = ['CSS.styleSheetAdded', 'CSS.styleSheetChanged', 'CSS.styleSheetRemoved'];
|
|
56
|
+
/** Other events after which kept answers may be stale */
|
|
57
|
+
const STYLE_CHANGE_EVENTS = [
|
|
58
|
+
'CSS.mediaQueryResultChanged',
|
|
59
|
+
'DOM.attributeModified',
|
|
60
|
+
'DOM.attributeRemoved',
|
|
61
|
+
'DOM.inlineStyleInvalidated',
|
|
62
|
+
'DOM.childNodeInserted',
|
|
63
|
+
'DOM.childNodeRemoved',
|
|
64
|
+
'DOM.childNodeCountUpdated',
|
|
65
|
+
'DOM.pseudoElementAdded',
|
|
66
|
+
'DOM.pseudoElementRemoved',
|
|
67
|
+
];
|
|
68
|
+
/** Matched-styles state per connection */
|
|
69
|
+
const documentStylesByConnection = new WeakMap();
|
|
70
|
+
/**
|
|
71
|
+
* The matched-styles state of a connection, tracked from its first use: a
|
|
72
|
+
* new document (`DOM.documentUpdated`) starts afresh (a request still running
|
|
73
|
+
* for the old one no longer holds back new ones), stylesheet changes drop the
|
|
74
|
+
* kept answers and the slow mark, style and DOM changes the kept answers.
|
|
75
|
+
* Events of other sessions (iframes) are ignored.
|
|
76
|
+
*
|
|
77
|
+
* @param cdp - CDP connection
|
|
78
|
+
* @returns State
|
|
79
|
+
*/
|
|
80
|
+
function documentStyles(cdp) {
|
|
81
|
+
const existing = documentStylesByConnection.get(cdp);
|
|
82
|
+
if (existing)
|
|
83
|
+
return existing;
|
|
84
|
+
const state = { requests: new Map(), slow: false, document: 0 };
|
|
85
|
+
documentStylesByConnection.set(cdp, state);
|
|
86
|
+
cdp.on('DOM.documentUpdated', (_params, sessionId) => {
|
|
87
|
+
if (sessionId)
|
|
88
|
+
return;
|
|
89
|
+
state.document++;
|
|
90
|
+
state.requests.clear();
|
|
91
|
+
state.running = undefined;
|
|
92
|
+
state.slow = false;
|
|
93
|
+
});
|
|
94
|
+
for (const event of STYLESHEET_EVENTS) {
|
|
95
|
+
cdp.on(event, (_params, sessionId) => {
|
|
96
|
+
if (sessionId)
|
|
97
|
+
return;
|
|
98
|
+
state.requests.clear();
|
|
99
|
+
state.slow = false;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
for (const event of STYLE_CHANGE_EVENTS) {
|
|
103
|
+
cdp.on(event, (_params, sessionId) => {
|
|
104
|
+
if (!sessionId)
|
|
105
|
+
state.requests.clear();
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return state;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Forget the kept answers of a connection (requests still running stay
|
|
112
|
+
* shared). For commands that may change the page in ways CDP reports no
|
|
113
|
+
* event for: clicks, typing, hovering, scripts, emulation.
|
|
114
|
+
*
|
|
115
|
+
* @param cdp - CDP connection
|
|
116
|
+
*/
|
|
117
|
+
export function resetMatchedStyles(cdp) {
|
|
118
|
+
documentStylesByConnection.get(cdp)?.requests.clear();
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Record an answer: a fast one clears the slow mark and is dropped, a slow
|
|
122
|
+
* one is kept for {@link KEPT_ANSWER_TTL_MS}, a failed one is dropped.
|
|
123
|
+
*
|
|
124
|
+
* @param state - Document state
|
|
125
|
+
* @param nodeId - Node id of the element
|
|
126
|
+
* @param request - The request
|
|
127
|
+
* @param tookMs - How long Chrome took
|
|
128
|
+
*/
|
|
129
|
+
function settleRequest(state, nodeId, request, tookMs) {
|
|
130
|
+
if (state.running === request.promise)
|
|
131
|
+
state.running = undefined;
|
|
132
|
+
if (request.document !== state.document)
|
|
133
|
+
return;
|
|
134
|
+
const answered = request.answer !== 'failed';
|
|
135
|
+
if (answered && tookMs <= HINTS_BUDGET_MS)
|
|
136
|
+
state.slow = false;
|
|
137
|
+
if (answered && tookMs > KEEP_ANSWERS_SLOWER_THAN_MS) {
|
|
138
|
+
request.expiresAt = Date.now() + KEPT_ANSWER_TTL_MS;
|
|
139
|
+
}
|
|
140
|
+
else if (state.requests.get(nodeId) === request) {
|
|
141
|
+
state.requests.delete(nodeId);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Send a matched-styles request and share it while it runs, dropping the
|
|
146
|
+
* least recently used entries beyond {@link MAX_KEPT_ANSWERS}.
|
|
147
|
+
*
|
|
148
|
+
* @param cdp - CDP connection
|
|
149
|
+
* @param state - Document state
|
|
150
|
+
* @param nodeId - Node id of the element
|
|
151
|
+
* @returns The request
|
|
152
|
+
*/
|
|
153
|
+
function sendMatchedRequest(cdp, state, nodeId) {
|
|
154
|
+
const sentAt = Date.now();
|
|
155
|
+
const request = {
|
|
156
|
+
document: state.document,
|
|
157
|
+
promise: cdp
|
|
158
|
+
.send('CSS.getMatchedStylesForNode', { nodeId })
|
|
159
|
+
.then((response) => response)
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
log.debug(`CSS.getMatchedStylesForNode failed: ${String(error)}`);
|
|
162
|
+
return 'failed';
|
|
163
|
+
}),
|
|
164
|
+
};
|
|
165
|
+
state.running = request.promise;
|
|
166
|
+
void request.promise.then((answer) => {
|
|
167
|
+
request.answer = answer;
|
|
168
|
+
settleRequest(state, nodeId, request, Date.now() - sentAt);
|
|
169
|
+
});
|
|
170
|
+
state.requests.set(nodeId, request);
|
|
171
|
+
for (const oldest of state.requests.keys()) {
|
|
172
|
+
if (state.requests.size <= MAX_KEPT_ANSWERS)
|
|
173
|
+
break;
|
|
174
|
+
state.requests.delete(oldest);
|
|
175
|
+
}
|
|
176
|
+
return request;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The element's request still running or kept answer, unless expired;
|
|
180
|
+
* marked as the most recently used.
|
|
181
|
+
*
|
|
182
|
+
* @param state - Document state
|
|
183
|
+
* @param nodeId - Node id of the element
|
|
184
|
+
* @returns The request, or undefined
|
|
185
|
+
*/
|
|
186
|
+
function keptRequest(state, nodeId) {
|
|
187
|
+
const kept = state.requests.get(nodeId);
|
|
188
|
+
if (!kept)
|
|
189
|
+
return undefined;
|
|
190
|
+
state.requests.delete(nodeId);
|
|
191
|
+
if (kept.expiresAt !== undefined && kept.expiresAt <= Date.now())
|
|
192
|
+
return undefined;
|
|
193
|
+
state.requests.set(nodeId, kept);
|
|
194
|
+
return kept;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The element's request, sent once Chrome has answered the one it is
|
|
198
|
+
* working on (another element's, or this one's sent by a concurrent call).
|
|
199
|
+
*
|
|
200
|
+
* @param cdp - CDP connection
|
|
201
|
+
* @param state - Document state
|
|
202
|
+
* @param nodeId - Node id of the element
|
|
203
|
+
* @param deadline - When to give up waiting
|
|
204
|
+
* @returns The request, or undefined when the running one outlasted the deadline
|
|
205
|
+
*/
|
|
206
|
+
async function requestWhenFree(cdp, state, nodeId, deadline) {
|
|
207
|
+
while (state.running) {
|
|
208
|
+
const kept = state.requests.get(nodeId);
|
|
209
|
+
if (kept)
|
|
210
|
+
return kept;
|
|
211
|
+
if ((await raceTimeout(state.running, deadline - Date.now())) === undefined)
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
return state.requests.get(nodeId) ?? sendMatchedRequest(cdp, state, nodeId);
|
|
215
|
+
}
|
|
40
216
|
/**
|
|
41
217
|
* The rules that match an element, with its inline style and what its
|
|
42
|
-
* ancestors pass down, or why they are missing.
|
|
218
|
+
* ancestors pass down, or why they are missing. A request still running for
|
|
219
|
+
* the element is shared; a slow answer (over {@link KEEP_ANSWERS_SLOWER_THAN_MS})
|
|
220
|
+
* is reused for {@link KEPT_ANSWER_TTL_MS} unless the document, its
|
|
221
|
+
* stylesheets or its DOM change or a command may have changed the page.
|
|
222
|
+
* Another element's request is sent only after the one Chrome is working on,
|
|
223
|
+
* within the budget. When the request this call sent or shared outlasts the
|
|
224
|
+
* budget, the document is marked slow: `skipWhenSlow` reads then return
|
|
225
|
+
* `skipped` at once (unless the answer is kept) until a read is fast again,
|
|
226
|
+
* a stylesheet changes or the page navigates.
|
|
43
227
|
*
|
|
44
228
|
* @param cdp - CDP connection
|
|
45
229
|
* @param nodeId - Node id of the element
|
|
46
230
|
* @param budgetMs - Time allowed
|
|
47
|
-
* @
|
|
231
|
+
* @param options - `skipWhenSlow`: do not wait on a document marked slow (the default hints)
|
|
232
|
+
* @returns Matched styles, `timeout` (longer than the budget), `failed` (CDP error)
|
|
233
|
+
* or `skipped` (slow document)
|
|
48
234
|
*/
|
|
49
|
-
export async function matchedStyles(cdp, nodeId, budgetMs) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
|
|
55
|
-
return '
|
|
56
|
-
|
|
57
|
-
|
|
235
|
+
export async function matchedStyles(cdp, nodeId, budgetMs, options = {}) {
|
|
236
|
+
const state = documentStyles(cdp);
|
|
237
|
+
const kept = keptRequest(state, nodeId);
|
|
238
|
+
if (kept?.answer)
|
|
239
|
+
return kept.answer;
|
|
240
|
+
if (options.skipWhenSlow && state.slow)
|
|
241
|
+
return 'skipped';
|
|
242
|
+
const deadline = Date.now() + budgetMs;
|
|
243
|
+
const request = kept ?? (await requestWhenFree(cdp, state, nodeId, deadline));
|
|
244
|
+
if (!request)
|
|
245
|
+
return 'timeout';
|
|
246
|
+
const answer = await raceTimeout(request.promise, deadline - Date.now());
|
|
247
|
+
if (answer)
|
|
248
|
+
return answer;
|
|
249
|
+
if (request.document === state.document)
|
|
250
|
+
state.slow = true;
|
|
251
|
+
return 'timeout';
|
|
58
252
|
}
|
|
59
253
|
/**
|
|
60
254
|
* File name of a URL, without query or hash; the host for a site's root.
|
|
@@ -11,8 +11,6 @@ import type { CDPConnection } from '../../connection/cdp.js';
|
|
|
11
11
|
import type { DomLayoutCommand } from '../../ipc/protocol/commands.js';
|
|
12
12
|
import type { LayoutComputedStyle, LayoutResult, PageLayout } from '../../ipc/protocol/domTypes.js';
|
|
13
13
|
import { type ElementGeometry } from './elementGeometry.js';
|
|
14
|
-
/** Matches measured per command (the rest are counted as omitted) */
|
|
15
|
-
export declare const LAYOUT_ELEMENT_LIMIT = 100;
|
|
16
14
|
/**
|
|
17
15
|
* Page-side end of a visible span kept clear of an overlay scrollbar along
|
|
18
16
|
* an edge (16 CSS px wide): overlay scrollbars (macOS, mobile) show for about a
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* shows. The measurements are classified outside the page
|
|
8
8
|
* ({@link classifyViewportPosition}).
|
|
9
9
|
*/
|
|
10
|
+
import { LAYOUT_ELEMENT_LIMIT } from '../../constants.js';
|
|
10
11
|
import { CommandError } from '../../errors/index.js';
|
|
11
12
|
import { operationFailedError } from '../../errors/messages.js';
|
|
12
13
|
import { ELEMENT_GEOMETRY_JS, FRAME_OFFSET_JS, VIEWPORT_SIZE_JS, classifyViewportPosition, } from './elementGeometry.js';
|
|
@@ -20,8 +21,6 @@ import { createLogger } from '../../ui/logging/index.js';
|
|
|
20
21
|
import { getErrorMessage } from '../../utils/errors.js';
|
|
21
22
|
import { EXIT_CODES } from '../../utils/exitCodes.js';
|
|
22
23
|
const log = createLogger('dom');
|
|
23
|
-
/** Matches measured per command (the rest are counted as omitted) */
|
|
24
|
-
export const LAYOUT_ELEMENT_LIMIT = 100;
|
|
25
24
|
/**
|
|
26
25
|
* Page-side end of a visible span kept clear of an overlay scrollbar along
|
|
27
26
|
* an edge (16 CSS px wide): overlay scrollbars (macOS, mobile) show for about a
|
|
@@ -107,7 +107,10 @@ export declare const FILL_READ_BACK_SCRIPT: string;
|
|
|
107
107
|
* dialog or bubble can swallow input while the page looks normal.
|
|
108
108
|
* Slotted text hit-tests as its shadow host, so an element in a shadow root
|
|
109
109
|
* that shows slotted content (a button labelled through a `<slot>`) is
|
|
110
|
-
* topmost where its host is hit.
|
|
110
|
+
* topmost where its host is hit. An element that is not topmost because an
|
|
111
|
+
* ancestor in the flat tree clips it away (a collapsed
|
|
112
|
+
* `height: 0; overflow: hidden` accordion, {@link ANCESTOR_CLIP_JS}) is
|
|
113
|
+
* reported as hidden by it, not as covered.
|
|
111
114
|
*/
|
|
112
115
|
export declare const CLICK_ELEMENT_SCRIPT: string;
|
|
113
116
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { FILL_REFUSALS, LABEL_WITHOUT_CONTROL, NAME_QUERY_PLACEHOLDER, VIA_LABEL_SUFFIX, } from '../../errors/messages.js';
|
|
9
9
|
import { REVEAL_SNAPSHOT_JS } from './actionEffectsScripts.js';
|
|
10
|
+
import { ANCESTOR_CLIP_JS } from './elementGeometry.js';
|
|
10
11
|
import { DISABLED_CAUSE_JS, ELEMENT_DESCRIPTION_JS, ELEMENT_IDENTITY_JS, } from './elementInfo.js';
|
|
11
12
|
import { FIND_ELEMENTS_JS, LABEL_CONTROL_JS } from './targetNode.js';
|
|
12
13
|
/**
|
|
@@ -484,7 +485,10 @@ export const FILL_READ_BACK_SCRIPT = `(() => {
|
|
|
484
485
|
* dialog or bubble can swallow input while the page looks normal.
|
|
485
486
|
* Slotted text hit-tests as its shadow host, so an element in a shadow root
|
|
486
487
|
* that shows slotted content (a button labelled through a `<slot>`) is
|
|
487
|
-
* topmost where its host is hit.
|
|
488
|
+
* topmost where its host is hit. An element that is not topmost because an
|
|
489
|
+
* ancestor in the flat tree clips it away (a collapsed
|
|
490
|
+
* `height: 0; overflow: hidden` accordion, {@link ANCESTOR_CLIP_JS}) is
|
|
491
|
+
* reported as hidden by it, not as covered.
|
|
488
492
|
*/
|
|
489
493
|
export const CLICK_ELEMENT_SCRIPT = `
|
|
490
494
|
(function(selector, parts, index, action) {
|
|
@@ -639,7 +643,10 @@ export const CLICK_ELEMENT_SCRIPT = `
|
|
|
639
643
|
else if (el.closest('[inert]')) obstruction = 'inert (the page made it non-interactive)';
|
|
640
644
|
else if (style.pointerEvents === 'none') obstruction = 'not clickable (pointer-events: none)';
|
|
641
645
|
else if (!hasSize) obstruction = 'zero-size';
|
|
642
|
-
else if (!hittable)
|
|
646
|
+
else if (!hittable) {
|
|
647
|
+
const collapsed = (${ANCESTOR_CLIP_JS})(el, rect, describe).collapsed;
|
|
648
|
+
obstruction = collapsed ? 'hidden (' + collapsed + ')' : 'covered by another element' + coveredBy();
|
|
649
|
+
}
|
|
643
650
|
|
|
644
651
|
if (action === 'hover') (${REVEAL_SNAPSHOT_JS})(el);
|
|
645
652
|
|
|
@@ -26,16 +26,20 @@ export declare const UNBIND_TARGET_SCRIPT = "delete window.__bdgTarget; delete w
|
|
|
26
26
|
* the text nodes of hidden ones (display or visibility; not those of
|
|
27
27
|
* `<script>`, `<style>` or `<noscript>`), so text filters match hidden
|
|
28
28
|
* elements like Playwright's and `:visible` decides visibility; button inputs
|
|
29
|
-
* use their value.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* use their value. Text is read in the flat tree, as `bdg dom query` shows
|
|
30
|
+
* it: the text a web component's open shadow root renders, slotted content
|
|
31
|
+
* in place of its slots. A visible component, slot or element holding either
|
|
32
|
+
* ({@link COMPOSED_JS}) is read with {@link FLAT_TEXT_JS}, including the
|
|
33
|
+
* selects and editable regions `innerText` reads. Whitespace is collapsed;
|
|
34
|
+
* filter texts arrive normalized (`has-text` lowercased). `:visible` is
|
|
35
|
+
* checked before the text filters, which read the text.
|
|
32
36
|
*
|
|
33
37
|
* Steps and `:has()` match CSS relative to an element (`:scope > css`). A
|
|
34
38
|
* descendant step also searches the open shadow roots under the element (the
|
|
35
39
|
* whole step CSS inside one shadow tree); a child step stays in the
|
|
36
40
|
* element's own tree.
|
|
37
41
|
*/
|
|
38
|
-
export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const
|
|
42
|
+
export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n}";
|
|
39
43
|
/**
|
|
40
44
|
* Page-side selector search that also reaches into open shadow roots and
|
|
41
45
|
* same-origin iframes (recursively), like a user sees the page.
|
|
@@ -49,7 +53,7 @@ export declare const FILTER_MATCHING_JS = "(shadowRoots) => {\n const skipped =
|
|
|
49
53
|
* reached. Cross-origin iframes are separate processes and cannot be
|
|
50
54
|
* searched. Throws a `SyntaxError` for an invalid selector.
|
|
51
55
|
*/
|
|
52
|
-
export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const
|
|
56
|
+
export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n}";
|
|
53
57
|
/**
|
|
54
58
|
* Page-side element lookup shared by the interaction scripts.
|
|
55
59
|
*
|
|
@@ -58,7 +62,7 @@ export declare const DEEP_QUERY_JS = "function (selector, parts) {\n const css
|
|
|
58
62
|
* built-ins ({@link bindMatches}), otherwise all matches of the selector
|
|
59
63
|
* ({@link DEEP_QUERY_JS}).
|
|
60
64
|
*/
|
|
61
|
-
export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n const bound = window.__bdgMatches;\n if (bound && bound.selector === selector) {\n const connected = [];\n for (let i = 0; i < bound.nodes.length; i++) {\n if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];\n }\n return connected;\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const
|
|
65
|
+
export declare const FIND_ELEMENTS_JS = "function (selector, parts) {\n if (selector === '__bdg_bound_target__') {\n const el = window.__bdgTarget;\n return el && el.isConnected ? [el] : [];\n }\n const bound = window.__bdgMatches;\n if (bound && bound.selector === selector) {\n const connected = [];\n for (let i = 0; i < bound.nodes.length; i++) {\n if (bound.nodes[i] && bound.nodes[i].isConnected) connected[connected.length] = bound.nodes[i];\n }\n return connected;\n }\n return (function (selector, parts) {\n const css = parts ? parts.map((part) => part.css).join(', ') : selector;\n const found = [];\n const roots = [];\n const visit = (root) => {\n roots.push(root);\n for (const match of root.querySelectorAll(css)) found.push(match);\n for (const el of root.querySelectorAll('*')) {\n if (el.shadowRoot) visit(el.shadowRoot);\n if (el.tagName === 'IFRAME' || el.tagName === 'FRAME') {\n let frameDocument = null;\n try { frameDocument = el.contentDocument; } catch (e) { frameDocument = null; }\n if (frameDocument) visit(frameDocument);\n }\n }\n };\n visit(document);\n if (!parts) return found;\n const shadowRoots = roots.filter((root) => root.host);\n const { passesAll, descend } = ((shadowRoots) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const flatText = (el, limit, fields) => {\n const composed = (node) => {\n const composes = (n) => Boolean(n.shadowRoot) || n.localName === 'slot';\n if (composes(node)) return true;\n const walker = node.ownerDocument.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);\n while (walker.nextNode()) if (composes(walker.currentNode)) return true;\n return false;\n};\n const view = el.ownerDocument.defaultView;\n let text = '';\n const nodesOf = (node) =>\n node.localName === 'slot' ? node.assignedNodes({ flatten: true }) : Array.from((node.shadowRoot || node).childNodes);\n const read = (node, visible) => {\n if (text.length >= limit) return;\n if (node.nodeType === 3 && visible) text += node.data.replace(/\\s+/g, ' ');\n if (node.nodeType !== 1) return;\n if (/^(input|textarea)$/.test(node.localName)) return;\n if (!fields && (node.localName === 'select' || node.isContentEditable)) return;\n const style = view.getComputedStyle(node);\n if (node.checkVisibility && !node.checkVisibility() && style.display !== 'contents') return;\n if (node.localName === 'br') text += '\\n';\n const apart = style.display.startsWith('inline') || style.display === 'contents' ? '' : '\\n';\n text += apart;\n const own = node.textContent || '';\n if (composed(node)) nodesOf(node).forEach((child) => read(child, style.visibility === 'visible'));\n else if (own.length > limit - text.length) text += own.slice(0, limit - text.length);\n else text += typeof node.innerText === 'string' ? node.innerText : own;\n text += apart;\n };\n const visible = view.getComputedStyle(el).visibility === 'visible';\n nodesOf(el).forEach((child) => read(child, visible));\n return text.slice(0, limit);\n};\n const skipped = /^(script|style|noscript|template)$/;\n const hiddenText = (el) => {\n const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;\n let text = '';\n for (const node of nodes) {\n if (node.nodeType === 3) text += node.data;\n else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);\n }\n return text;\n };\n const textOf = (el) => {\n if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;\n const rendered = typeof el.innerText === 'string' &&\n (typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));\n const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;\n return text.replace(/\\s+/g, ' ').trim();\n };\n const passes = (el, filter) => {\n if (filter.kind === 'visible') {\n if (typeof el.checkVisibility === 'function' && !el.checkVisibility()) return false;\n const rect = el.getBoundingClientRect();\n return rect.width > 0 && rect.height > 0 && el.ownerDocument.defaultView.getComputedStyle(el).visibility === 'visible';\n }\n if (filter.kind === 'has') return filter.selectors.some((chain) => descend(el, chain).length > 0);\n const text = textOf(el);\n return filter.kind === 'text-is' ? text === filter.text : text.toLowerCase().includes(filter.text);\n };\n const passesAll = (el, filters) =>\n filters.every((filter) => filter.kind !== 'visible' || passes(el, filter)) &&\n filters.every((filter) => filter.kind === 'visible' || passes(el, filter));\n const isUnder = (scope, node) => {\n for (let current = node; current; current = current.getRootNode().host) {\n if (scope === current || scope.contains(current)) return true;\n }\n return false;\n };\n const stepMatches = (scope, step) => {\n const matches = [...scope.querySelectorAll(':scope ' + step.combinator + ' ' + step.css)];\n if (step.combinator !== ' ') return matches;\n for (const root of shadowRoots) {\n if (isUnder(scope, root.host)) matches.push(...root.querySelectorAll(step.css));\n }\n return matches;\n };\n const descend = (scope, steps) => {\n let current = [scope];\n for (const step of steps) {\n const next = new Set();\n for (const el of current) {\n for (const match of stepMatches(el, step)) {\n if (passesAll(match, step.filters)) next.add(match);\n }\n }\n current = [...next];\n }\n return current;\n };\n return { passesAll: passesAll, descend: descend };\n})(shadowRoots);\n const result = new Set();\n for (const el of found) {\n for (const part of parts) {\n if (!el.matches(part.css) || !passesAll(el, part.filters)) continue;\n for (const match of descend(el, part.steps || [])) result.add(match);\n }\n }\n const rootOrder = new Map(roots.map((root, i) => [root, i]));\n const rootIndex = (el) => rootOrder.get(el.getRootNode()) ?? roots.length;\n return [...result].sort((a, b) =>\n rootIndex(a) - rootIndex(b) || (a.compareDocumentPosition(b) & 4 ? -1 : a === b ? 0 : 1)\n );\n})(selector, parts);\n}";
|
|
62
66
|
/**
|
|
63
67
|
* Page-side check of what selectors cannot search in the top document:
|
|
64
68
|
* iframes whose document cannot be read (cross-origin) and `<object>`/`<embed>`
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { CommandError } from '../../errors/index.js';
|
|
12
12
|
import { actionBrokenByPageError, actionScriptFailedError, emptySelectorError, indexOutOfRangeError, noNodesFoundError, staleNodeError, } from '../../errors/messages.js';
|
|
13
|
+
import { COMPOSED_JS, FLAT_TEXT_JS } from './elementInfo.js';
|
|
13
14
|
import { ActionScriptError, throwIfInvalidSelector } from './formFillHelpers/shared.js';
|
|
14
15
|
import { frameScopedConnection } from './frameScopedConnection.js';
|
|
15
16
|
import { evaluateInBdgWorld } from '../page/bdgWorld.js';
|
|
@@ -102,9 +103,13 @@ const log = createLogger('dom');
|
|
|
102
103
|
* the text nodes of hidden ones (display or visibility; not those of
|
|
103
104
|
* `<script>`, `<style>` or `<noscript>`), so text filters match hidden
|
|
104
105
|
* elements like Playwright's and `:visible` decides visibility; button inputs
|
|
105
|
-
* use their value.
|
|
106
|
-
*
|
|
107
|
-
*
|
|
106
|
+
* use their value. Text is read in the flat tree, as `bdg dom query` shows
|
|
107
|
+
* it: the text a web component's open shadow root renders, slotted content
|
|
108
|
+
* in place of its slots. A visible component, slot or element holding either
|
|
109
|
+
* ({@link COMPOSED_JS}) is read with {@link FLAT_TEXT_JS}, including the
|
|
110
|
+
* selects and editable regions `innerText` reads. Whitespace is collapsed;
|
|
111
|
+
* filter texts arrive normalized (`has-text` lowercased). `:visible` is
|
|
112
|
+
* checked before the text filters, which read the text.
|
|
108
113
|
*
|
|
109
114
|
* Steps and `:has()` match CSS relative to an element (`:scope > css`). A
|
|
110
115
|
* descendant step also searches the open shadow roots under the element (the
|
|
@@ -112,14 +117,15 @@ const log = createLogger('dom');
|
|
|
112
117
|
* element's own tree.
|
|
113
118
|
*/
|
|
114
119
|
export const FILTER_MATCHING_JS = `(shadowRoots) => {
|
|
120
|
+
const composed = ${COMPOSED_JS};
|
|
121
|
+
const flatText = ${FLAT_TEXT_JS};
|
|
115
122
|
const skipped = /^(script|style|noscript|template)$/;
|
|
116
123
|
const hiddenText = (el) => {
|
|
117
|
-
const
|
|
118
|
-
acceptNode: (node) => (node.nodeType === 1 && skipped.test(node.localName) ? 2 : 1)
|
|
119
|
-
});
|
|
124
|
+
const nodes = el.localName === 'slot' ? el.assignedNodes({ flatten: true }) : (el.shadowRoot || el).childNodes;
|
|
120
125
|
let text = '';
|
|
121
|
-
for (
|
|
126
|
+
for (const node of nodes) {
|
|
122
127
|
if (node.nodeType === 3) text += node.data;
|
|
128
|
+
else if (node.nodeType === 1 && !skipped.test(node.localName)) text += hiddenText(node);
|
|
123
129
|
}
|
|
124
130
|
return text;
|
|
125
131
|
};
|
|
@@ -127,7 +133,8 @@ export const FILTER_MATCHING_JS = `(shadowRoots) => {
|
|
|
127
133
|
if (el.localName === 'input' && /^(submit|button|reset)$/i.test(el.type)) return el.value;
|
|
128
134
|
const rendered = typeof el.innerText === 'string' &&
|
|
129
135
|
(typeof el.checkVisibility !== 'function' || el.checkVisibility({ visibilityProperty: true }));
|
|
130
|
-
|
|
136
|
+
const text = !rendered ? hiddenText(el) : composed(el) ? flatText(el, Infinity, true) : el.innerText;
|
|
137
|
+
return text.replace(/\\s+/g, ' ').trim();
|
|
131
138
|
};
|
|
132
139
|
const passes = (el, filter) => {
|
|
133
140
|
if (filter.kind === 'visible') {
|
package/dist/telemetry/a11y.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Protocol } from '../connection/typed-cdp.js';
|
|
2
|
-
import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, NodeRef } from '../types.js';
|
|
2
|
+
import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, ListedA11yTree, NodeRef } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* Builds accessibility tree from raw CDP nodes.
|
|
5
5
|
*
|
|
@@ -14,6 +14,20 @@ import type { A11yNode, A11yTree, A11yQueryPattern, A11yQueryResult, NodeRef } f
|
|
|
14
14
|
* @throws Error if no root node found
|
|
15
15
|
*/
|
|
16
16
|
export declare function buildTreeFromRawNodes(rawNodes: Protocol.Accessibility.AXNode[]): A11yTree;
|
|
17
|
+
/**
|
|
18
|
+
* The nodes `dom a11y tree` lists, depth-first from the root (then nodes not
|
|
19
|
+
* under it, such as frame content): the first `limit` (0 = all) no deeper
|
|
20
|
+
* than `maxDepth`. Noise ({@link isTreeNoise}) is counted as skipped and its
|
|
21
|
+
* children move up a level, so the budget goes to meaningful nodes; nodes cut
|
|
22
|
+
* by the limit or depth are counted as omitted. Every node of the tree is
|
|
23
|
+
* listed, omitted or skipped.
|
|
24
|
+
*
|
|
25
|
+
* @param tree - Accessibility tree
|
|
26
|
+
* @param limit - Nodes to list (0 = all listable ones)
|
|
27
|
+
* @param maxDepth - Deepest level to list (0 = root only; undefined = all)
|
|
28
|
+
* @returns Listed nodes with their depth, the tree size, and how many were cut or skipped
|
|
29
|
+
*/
|
|
30
|
+
export declare function listA11yTree(tree: A11yTree, limit: number, maxDepth?: number): ListedA11yTree;
|
|
17
31
|
/**
|
|
18
32
|
* Collects the full accessibility tree from the page via IPC.
|
|
19
33
|
*
|
package/dist/telemetry/a11y.js
CHANGED
|
@@ -47,6 +47,89 @@ export function buildTreeFromRawNodes(rawNodes) {
|
|
|
47
47
|
const root = nodes.get(rootRaw.nodeId);
|
|
48
48
|
return { root, nodes, count: nodes.size };
|
|
49
49
|
}
|
|
50
|
+
/** Roles that only lay out their children and say nothing themselves */
|
|
51
|
+
const LAYOUT_ROLES = new Set([
|
|
52
|
+
'generic',
|
|
53
|
+
'none',
|
|
54
|
+
'presentation',
|
|
55
|
+
'LayoutTable',
|
|
56
|
+
'LayoutTableRow',
|
|
57
|
+
'LayoutTableCell',
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Whether a node is left out of `dom a11y tree`: text boxes, blank text,
|
|
61
|
+
* text that repeats its parent's name, and nameless layout wrappers.
|
|
62
|
+
*
|
|
63
|
+
* @param node - Accessibility node
|
|
64
|
+
* @param parentName - Name of the nearest named ancestor
|
|
65
|
+
* @returns True when the node says nothing of its own
|
|
66
|
+
*/
|
|
67
|
+
function isTreeNoise(node, parentName) {
|
|
68
|
+
return (node.role === 'InlineTextBox' ||
|
|
69
|
+
(node.role === 'StaticText' && (node.name === parentName || !node.name?.trim())) ||
|
|
70
|
+
(LAYOUT_ROLES.has(node.role) && !node.name));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The nodes `dom a11y tree` lists, depth-first from the root (then nodes not
|
|
74
|
+
* under it, such as frame content): the first `limit` (0 = all) no deeper
|
|
75
|
+
* than `maxDepth`. Noise ({@link isTreeNoise}) is counted as skipped and its
|
|
76
|
+
* children move up a level, so the budget goes to meaningful nodes; nodes cut
|
|
77
|
+
* by the limit or depth are counted as omitted. Every node of the tree is
|
|
78
|
+
* listed, omitted or skipped.
|
|
79
|
+
*
|
|
80
|
+
* @param tree - Accessibility tree
|
|
81
|
+
* @param limit - Nodes to list (0 = all listable ones)
|
|
82
|
+
* @param maxDepth - Deepest level to list (0 = root only; undefined = all)
|
|
83
|
+
* @returns Listed nodes with their depth, the tree size, and how many were cut or skipped
|
|
84
|
+
*/
|
|
85
|
+
export function listA11yTree(tree, limit, maxDepth) {
|
|
86
|
+
const nodes = [];
|
|
87
|
+
const visited = new Set();
|
|
88
|
+
let omitted = 0;
|
|
89
|
+
let skipped = 0;
|
|
90
|
+
const visit = (node, depth, parentName) => {
|
|
91
|
+
if (visited.has(node.nodeId))
|
|
92
|
+
return;
|
|
93
|
+
visited.add(node.nodeId);
|
|
94
|
+
const listable = !isTreeNoise(node, parentName);
|
|
95
|
+
if (listable) {
|
|
96
|
+
const fits = (limit === 0 || nodes.length < limit) && (maxDepth ?? depth) >= depth;
|
|
97
|
+
if (fits)
|
|
98
|
+
nodes.push(listedNode(node, depth));
|
|
99
|
+
else
|
|
100
|
+
omitted++;
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
skipped++;
|
|
104
|
+
}
|
|
105
|
+
for (const childId of node.childIds ?? []) {
|
|
106
|
+
const child = tree.nodes.get(childId);
|
|
107
|
+
if (child)
|
|
108
|
+
visit(child, listable ? depth + 1 : depth, node.name ?? parentName);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
visit(tree.root, 0, undefined);
|
|
112
|
+
for (const node of tree.nodes.values())
|
|
113
|
+
visit(node, 0, undefined);
|
|
114
|
+
return {
|
|
115
|
+
nodes,
|
|
116
|
+
count: tree.count,
|
|
117
|
+
...(omitted > 0 && { omitted }),
|
|
118
|
+
...(skipped > 0 && { skipped }),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A node as the tree lists it: its depth instead of child ids (its children
|
|
123
|
+
* follow it, one level deeper).
|
|
124
|
+
*
|
|
125
|
+
* @param node - Accessibility node
|
|
126
|
+
* @param depth - Its level in the listing
|
|
127
|
+
* @returns Listed node
|
|
128
|
+
*/
|
|
129
|
+
function listedNode(node, depth) {
|
|
130
|
+
const { childIds: _childIds, ...rest } = node;
|
|
131
|
+
return { ...rest, depth };
|
|
132
|
+
}
|
|
50
133
|
/**
|
|
51
134
|
* Ids of a node's children as shown: ignored children are replaced by their
|
|
52
135
|
* own non-ignored descendants; ids missing from the tree are dropped.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* HAR (HTTP Archive) builder for transforming network telemetry to HAR 1.2 format.
|
|
3
3
|
*/
|
|
4
4
|
import { createRequire } from 'node:module';
|
|
5
|
-
import { skippedBodyReason } from '../
|
|
5
|
+
import { skippedBodyReason } from '../networkRetention.js';
|
|
6
6
|
/**
|
|
7
7
|
* Loads Node builtins on first use: importing `node:http` in an ES module
|
|
8
8
|
* reads all its exports, which loads undici and zlib (about 8 ms of every CLI
|
|
@@ -1,19 +1,6 @@
|
|
|
1
1
|
import type { CDPConnection } from '../connection/cdp.js';
|
|
2
2
|
import type { NetworkRequest, WebSocketConnection, CleanupFunction } from '../types.js';
|
|
3
|
-
|
|
4
|
-
* Placeholder stored instead of a response body that was not fetched.
|
|
5
|
-
*
|
|
6
|
-
* @param reason - Why the body was skipped
|
|
7
|
-
* @returns Placeholder text shown by `bdg details`
|
|
8
|
-
*/
|
|
9
|
-
export declare function skippedBodyPlaceholder(reason: string): string;
|
|
10
|
-
/**
|
|
11
|
-
* Extract the reason from a skipped-body placeholder.
|
|
12
|
-
*
|
|
13
|
-
* @param body - Stored response body
|
|
14
|
-
* @returns Reason if `body` is a placeholder, otherwise undefined
|
|
15
|
-
*/
|
|
16
|
-
export declare function skippedBodyReason(body: string | undefined): string | undefined;
|
|
3
|
+
import { type NetworkEvictions } from './networkRetention.js';
|
|
17
4
|
/**
|
|
18
5
|
* A request that has started but not finished or failed yet.
|
|
19
6
|
*/
|
|
@@ -41,6 +28,12 @@ export interface NetworkCollectionOptions {
|
|
|
41
28
|
networkInclude?: string[];
|
|
42
29
|
networkExclude?: string[];
|
|
43
30
|
maxBodySize?: number;
|
|
31
|
+
/** Finished requests kept at most; past it the oldest are dropped (default {@link MAX_NETWORK_REQUESTS}) */
|
|
32
|
+
maxRequests?: number;
|
|
33
|
+
/** Total size of stored response bodies; past it the oldest are evicted (default {@link MAX_TOTAL_BODY_BYTES}) */
|
|
34
|
+
maxTotalBodyBytes?: number;
|
|
35
|
+
/** Counters of dropped requests and evicted bodies; otherwise the collector keeps private ones */
|
|
36
|
+
evictions?: NetworkEvictions | undefined;
|
|
44
37
|
getCurrentNavigationId?: (() => number) | undefined;
|
|
45
38
|
}
|
|
46
39
|
/**
|
|
@@ -56,8 +49,12 @@ export interface NetworkCollectionOptions {
|
|
|
56
49
|
*
|
|
57
50
|
* @remarks
|
|
58
51
|
* - Chrome buffer limits: 50MB total, 10MB per resource, 1MB POST data (with fallback)
|
|
59
|
-
* -
|
|
60
|
-
*
|
|
52
|
+
* - The newest 10,000 finished requests are kept: past that the oldest finished
|
|
53
|
+
* ones are dropped (counted in `evictions.requestsDropped`); requests in flight
|
|
54
|
+
* are tracked separately and never dropped mid-flight
|
|
55
|
+
* - Stored response bodies total at most 100MB: past that the oldest bodies are
|
|
56
|
+
* replaced by a placeholder (counted in `evictions.bodiesEvicted`), their
|
|
57
|
+
* request metadata stays
|
|
61
58
|
* - Response bodies are automatically skipped for images, fonts, CSS, and source maps (see DEFAULT_SKIP_BODY_PATTERNS)
|
|
62
59
|
* - Response bodies larger than 5MB are skipped with a placeholder message
|
|
63
60
|
* - By default, common tracking/analytics domains are filtered out (use includeAll to disable)
|