browser-devtools-mcp 0.0.1 → 0.0.2

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.
@@ -0,0 +1,850 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TakeAxTreeSnapshot = void 0;
4
+ const zod_1 = require("zod");
5
+ /**
6
+ * -------------------------
7
+ * Default configuration
8
+ * -------------------------
9
+ * All constants are explicitly typed to keep TypeScript strict.
10
+ */
11
+ const DEFAULT_INCLUDE_STYLES = true;
12
+ const DEFAULT_INCLUDE_RUNTIME_VISUAL = true;
13
+ const DEFAULT_CHECK_OCCLUSION = false;
14
+ const DEFAULT_ONLY_VISIBLE = false;
15
+ const DEFAULT_ONLY_IN_VIEWPORT = false;
16
+ const DEFAULT_TEXT_PREVIEW_MAX_LENGTH = 80;
17
+ /**
18
+ * CSS properties that provide the highest signal for
19
+ * visual understanding and UI debugging.
20
+ */
21
+ const DEFAULT_STYLE_PROPERTIES = [
22
+ 'display',
23
+ 'visibility',
24
+ 'opacity',
25
+ 'pointer-events',
26
+ 'position',
27
+ 'z-index',
28
+ 'color',
29
+ 'background-color',
30
+ 'border-color',
31
+ 'border-width',
32
+ 'border-style',
33
+ 'font-family',
34
+ 'font-size',
35
+ 'font-weight',
36
+ 'line-height',
37
+ 'letter-spacing',
38
+ 'text-align',
39
+ 'text-decoration-line',
40
+ 'white-space',
41
+ 'overflow',
42
+ 'overflow-x',
43
+ 'overflow-y',
44
+ 'transform',
45
+ 'cursor',
46
+ ];
47
+ /**
48
+ * A focused subset of AX roles that are meaningful for
49
+ * interaction, verification and reasoning.
50
+ */
51
+ const DEFAULT_INTERESTING_ROLES = new Set([
52
+ 'button',
53
+ 'link',
54
+ 'textbox',
55
+ 'checkbox',
56
+ 'radio',
57
+ 'combobox',
58
+ 'switch',
59
+ 'tab',
60
+ 'menuitem',
61
+ 'dialog',
62
+ 'heading',
63
+ 'listbox',
64
+ 'listitem',
65
+ 'option',
66
+ ]);
67
+ /**
68
+ * Internal safeguards to protect CDP and output size.
69
+ * These are intentionally not user-configurable.
70
+ */
71
+ const INTERNAL_CONCURRENCY = 12;
72
+ const INTERNAL_SAFETY_CAP = 2000;
73
+ /**
74
+ * Converts Chromium's attribute array format into a key-value map.
75
+ */
76
+ function attrsToObj(attrs) {
77
+ const result = {};
78
+ if (!attrs) {
79
+ return result;
80
+ }
81
+ for (let i = 0; i < attrs.length; i += 2) {
82
+ const key = String(attrs[i]);
83
+ const value = String(attrs[i + 1] ?? '');
84
+ result[key] = value;
85
+ }
86
+ return result;
87
+ }
88
+ class TakeAxTreeSnapshot {
89
+ name() {
90
+ return 'accessibility_take-ax-tree-snapshot';
91
+ }
92
+ description() {
93
+ return `
94
+ Captures a UI-focused snapshot by combining Chromium's Accessibility (AX) tree with runtime visual diagnostics.
95
+
96
+ Use this tool to detect UI issues like:
97
+ - Elements that exist semantically (AX role/name) but are visually hidden or off-screen
98
+ - Wrong layout/geometry (bounding box, viewport intersection)
99
+ - Styling issues (optional computed style subset)
100
+ - Overlap / stacking / occlusion issues (enable "checkOcclusion")
101
+
102
+ **UI Debugging Usage:**
103
+ - ALWAYS use "checkOcclusion:true" when investigating UI/layout problems
104
+ - Provides precise bounding boxes for overlap detection
105
+ - Use alongside "a11y_take-aria-snapshot" tool for complete UI analysis
106
+
107
+ Important notes for AI-driven UI debugging:
108
+ - "boundingBox" comes from "getBoundingClientRect()" (viewport coords). It represents the layout box, not every painted pixel
109
+ (e.g. shadows/pseudo-elements may extend beyond it).
110
+ - If something looks visible but clicks fail, enable "checkOcclusion". This samples multiple points (center + corners)
111
+ and uses "elementFromPoint()" to identify if another element is actually on top.
112
+ - When not occluded, "topElement" is null (even if "elementFromPoint" returns the element itself or its descendant).
113
+ - "selectorHint" is a best-effort locator (prefers "data-testid" / "data-selector" / "id"). It may not be unique.
114
+ - Use "onlyVisible" / "onlyInViewport" to reduce output when focusing on what the user currently sees.
115
+ `.trim();
116
+ }
117
+ inputSchema() {
118
+ return {
119
+ roles: zod_1.z
120
+ .array(zod_1.z.enum([
121
+ 'button',
122
+ 'link',
123
+ 'textbox',
124
+ 'checkbox',
125
+ 'radio',
126
+ 'combobox',
127
+ 'switch',
128
+ 'tab',
129
+ 'menuitem',
130
+ 'dialog',
131
+ 'heading',
132
+ 'listbox',
133
+ 'listitem',
134
+ 'option',
135
+ ]))
136
+ .describe('Optional role allowlist. If omitted, a built-in set of interactive roles is used.')
137
+ .optional(),
138
+ includeStyles: zod_1.z
139
+ .boolean()
140
+ .describe('Whether to include computed CSS styles for each node. Styles are extracted via runtime getComputedStyle().')
141
+ .optional()
142
+ .default(DEFAULT_INCLUDE_STYLES),
143
+ includeRuntimeVisual: zod_1.z
144
+ .boolean()
145
+ .describe('Whether to compute runtime visual information (bounding box, visibility, viewport).')
146
+ .optional()
147
+ .default(DEFAULT_INCLUDE_RUNTIME_VISUAL),
148
+ checkOcclusion: zod_1.z
149
+ .boolean()
150
+ .describe('If true, checks whether each element is visually occluded by another element using elementFromPoint() sampled at multiple points. Disabled by default.')
151
+ .optional()
152
+ .default(DEFAULT_CHECK_OCCLUSION),
153
+ onlyVisible: zod_1.z
154
+ .boolean()
155
+ .describe('If true, only visually visible nodes are returned.')
156
+ .optional()
157
+ .default(DEFAULT_ONLY_VISIBLE),
158
+ onlyInViewport: zod_1.z
159
+ .boolean()
160
+ .describe('If true, only nodes intersecting the viewport are returned.')
161
+ .optional()
162
+ .default(DEFAULT_ONLY_IN_VIEWPORT),
163
+ textPreviewMaxLength: zod_1.z
164
+ .number()
165
+ .int()
166
+ .positive()
167
+ .describe('Maximum length of the text preview extracted from each element.')
168
+ .optional()
169
+ .default(DEFAULT_TEXT_PREVIEW_MAX_LENGTH),
170
+ styleProperties: zod_1.z
171
+ .array(zod_1.z.string())
172
+ .describe('List of CSS computed style properties to extract.')
173
+ .optional()
174
+ .default([...DEFAULT_STYLE_PROPERTIES]),
175
+ };
176
+ }
177
+ outputSchema() {
178
+ return {
179
+ url: zod_1.z
180
+ .string()
181
+ .describe('The current page URL at the time the AX snapshot was captured.'),
182
+ title: zod_1.z
183
+ .string()
184
+ .describe('The document title of the page at the time of the snapshot.'),
185
+ axNodeCount: zod_1.z
186
+ .number()
187
+ .int()
188
+ .nonnegative()
189
+ .describe('Total number of nodes returned by Chromium Accessibility.getFullAXTree before filtering.'),
190
+ candidateCount: zod_1.z
191
+ .number()
192
+ .int()
193
+ .nonnegative()
194
+ .describe('Number of DOM-backed AX nodes that passed role filtering before enrichment.'),
195
+ enrichedCount: zod_1.z
196
+ .number()
197
+ .int()
198
+ .nonnegative()
199
+ .describe('Number of nodes included in the final enriched snapshot output.'),
200
+ truncatedBySafetyCap: zod_1.z
201
+ .boolean()
202
+ .describe('Indicates whether the result set was truncated by an internal safety cap to prevent excessive output size.'),
203
+ nodes: zod_1.z
204
+ .array(zod_1.z.object({
205
+ axNodeId: zod_1.z
206
+ .string()
207
+ .describe('Unique identifier of the accessibility node within the AX tree.'),
208
+ parentAxNodeId: zod_1.z
209
+ .string()
210
+ .nullable()
211
+ .describe('Parent AX node id in the full AX tree. Null if this node is a root.'),
212
+ childAxNodeIds: zod_1.z
213
+ .array(zod_1.z.string())
214
+ .describe('Child AX node ids in the full AX tree (may include nodes not present in the filtered output).'),
215
+ role: zod_1.z
216
+ .string()
217
+ .nullable()
218
+ .describe('ARIA role of the accessibility node (e.g. button, link, textbox).'),
219
+ name: zod_1.z
220
+ .string()
221
+ .nullable()
222
+ .describe('Accessible name computed by the browser accessibility engine.'),
223
+ ignored: zod_1.z
224
+ .boolean()
225
+ .nullable()
226
+ .describe('Whether the accessibility node is marked as ignored.'),
227
+ backendDOMNodeId: zod_1.z
228
+ .number()
229
+ .int()
230
+ .describe('Chromium backend DOM node identifier used to map AX nodes to DOM elements.'),
231
+ domNodeId: zod_1.z
232
+ .number()
233
+ .int()
234
+ .nullable()
235
+ .describe('Resolved DOM nodeId from CDP if available; may be null because nodeId is not guaranteed to be stable/resolved.'),
236
+ frameId: zod_1.z
237
+ .string()
238
+ .nullable()
239
+ .describe('Frame identifier if the node belongs to an iframe or subframe.'),
240
+ localName: zod_1.z
241
+ .string()
242
+ .nullable()
243
+ .describe('Lowercased DOM tag name of the mapped element (e.g. div, button, input).'),
244
+ id: zod_1.z
245
+ .string()
246
+ .nullable()
247
+ .describe('DOM id attribute of the mapped element.'),
248
+ className: zod_1.z
249
+ .string()
250
+ .nullable()
251
+ .describe('DOM class attribute of the mapped element.'),
252
+ selectorHint: zod_1.z
253
+ .string()
254
+ .nullable()
255
+ .describe('Best-effort selector hint for targeting this element (prefers data-testid/data-selector/id).'),
256
+ textPreview: zod_1.z
257
+ .string()
258
+ .nullable()
259
+ .describe('Short preview of rendered text content or aria-label, truncated to the configured maximum length.'),
260
+ value: zod_1.z
261
+ .any()
262
+ .nullable()
263
+ .describe('Raw AX value payload associated with the node, if present.'),
264
+ description: zod_1.z
265
+ .any()
266
+ .nullable()
267
+ .describe('Raw AX description payload associated with the node, if present.'),
268
+ properties: zod_1.z
269
+ .array(zod_1.z.any())
270
+ .nullable()
271
+ .describe('Additional AX properties exposed by the accessibility tree.'),
272
+ styles: zod_1.z
273
+ .record(zod_1.z.string(), zod_1.z.string())
274
+ .optional()
275
+ .describe('Subset of computed CSS styles for the element as string key-value pairs.'),
276
+ runtime: zod_1.z
277
+ .object({
278
+ boundingBox: zod_1.z
279
+ .object({
280
+ x: zod_1.z
281
+ .number()
282
+ .describe('X coordinate of the element relative to the viewport.'),
283
+ y: zod_1.z
284
+ .number()
285
+ .describe('Y coordinate of the element relative to the viewport.'),
286
+ width: zod_1.z
287
+ .number()
288
+ .describe('Width of the element in CSS pixels.'),
289
+ height: zod_1.z
290
+ .number()
291
+ .describe('Height of the element in CSS pixels.'),
292
+ })
293
+ .nullable()
294
+ .describe('Bounding box computed at runtime using getBoundingClientRect.'),
295
+ isVisible: zod_1.z
296
+ .boolean()
297
+ .describe('Whether the element is considered visually visible (display, visibility, opacity, and size).'),
298
+ isInViewport: zod_1.z
299
+ .boolean()
300
+ .describe('Whether the element intersects the current viewport.'),
301
+ occlusion: zod_1.z
302
+ .object({
303
+ samplePoints: zod_1.z
304
+ .array(zod_1.z.object({
305
+ x: zod_1.z
306
+ .number()
307
+ .describe('Sample point X (viewport coordinates) used for occlusion testing.'),
308
+ y: zod_1.z
309
+ .number()
310
+ .describe('Sample point Y (viewport coordinates) used for occlusion testing.'),
311
+ hit: zod_1.z
312
+ .boolean()
313
+ .describe('True if elementFromPoint at this point returned a different element that is not a descendant.'),
314
+ }))
315
+ .describe('Sample points used for occlusion detection (center + corners).'),
316
+ isOccluded: zod_1.z
317
+ .boolean()
318
+ .describe('True if at least one sample point is covered by another element.'),
319
+ topElement: zod_1.z
320
+ .object({
321
+ localName: zod_1.z
322
+ .string()
323
+ .nullable()
324
+ .describe('Tag name of the occluding element.'),
325
+ id: zod_1.z
326
+ .string()
327
+ .nullable()
328
+ .describe('DOM id of the occluding element (may be null if none).'),
329
+ className: zod_1.z
330
+ .string()
331
+ .nullable()
332
+ .describe('DOM class of the occluding element (may be null if none).'),
333
+ selectorHint: zod_1.z
334
+ .string()
335
+ .nullable()
336
+ .describe('Best-effort selector hint for the occluding element.'),
337
+ boundingBox: zod_1.z
338
+ .object({
339
+ x: zod_1.z
340
+ .number()
341
+ .describe('X coordinate of the occluding element bounding box.'),
342
+ y: zod_1.z
343
+ .number()
344
+ .describe('Y coordinate of the occluding element bounding box.'),
345
+ width: zod_1.z
346
+ .number()
347
+ .describe('Width of the occluding element bounding box.'),
348
+ height: zod_1.z
349
+ .number()
350
+ .describe('Height of the occluding element bounding box.'),
351
+ })
352
+ .nullable()
353
+ .describe('Bounding box of the occluding element (if available).'),
354
+ })
355
+ .nullable()
356
+ .describe('Identity and geometry of the occluding element. Null when not occluded.'),
357
+ intersection: zod_1.z
358
+ .object({
359
+ x: zod_1.z
360
+ .number()
361
+ .describe('Intersection rect X.'),
362
+ y: zod_1.z
363
+ .number()
364
+ .describe('Intersection rect Y.'),
365
+ width: zod_1.z
366
+ .number()
367
+ .describe('Intersection rect width.'),
368
+ height: zod_1.z
369
+ .number()
370
+ .describe('Intersection rect height.'),
371
+ area: zod_1.z
372
+ .number()
373
+ .describe('Intersection rect area in CSS pixels squared.'),
374
+ })
375
+ .nullable()
376
+ .describe('Intersection box between this element and the occluding element. Null if not occluded or cannot compute.'),
377
+ })
378
+ .optional()
379
+ .describe('Occlusion detection results. Only present when checkOcclusion=true.'),
380
+ })
381
+ .optional()
382
+ .describe('Runtime-derived visual information representing how the element is actually rendered.'),
383
+ }))
384
+ .describe('List of enriched DOM-backed AX nodes combining accessibility metadata with visual diagnostics.'),
385
+ };
386
+ }
387
+ async handle(context, args) {
388
+ const page = context.page;
389
+ const includeRuntimeVisual = args.includeRuntimeVisual ?? DEFAULT_INCLUDE_RUNTIME_VISUAL;
390
+ const includeStyles = args.includeStyles ?? DEFAULT_INCLUDE_STYLES;
391
+ const checkOcclusion = args.checkOcclusion ?? DEFAULT_CHECK_OCCLUSION;
392
+ const onlyVisible = args.onlyVisible ?? DEFAULT_ONLY_VISIBLE;
393
+ const onlyInViewport = args.onlyInViewport ?? DEFAULT_ONLY_IN_VIEWPORT;
394
+ if ((onlyVisible || onlyInViewport) && !includeRuntimeVisual) {
395
+ throw new Error('onlyVisible/onlyInViewport require includeRuntimeVisual=true.');
396
+ }
397
+ if (checkOcclusion && !includeRuntimeVisual) {
398
+ throw new Error('checkOcclusion requires includeRuntimeVisual=true.');
399
+ }
400
+ const textMax = args.textPreviewMaxLength ?? DEFAULT_TEXT_PREVIEW_MAX_LENGTH;
401
+ const stylePropsRaw = args.styleProperties && args.styleProperties.length > 0
402
+ ? args.styleProperties
403
+ : DEFAULT_STYLE_PROPERTIES;
404
+ const stylePropsLower = Array.from(stylePropsRaw).map((p) => p.toLowerCase());
405
+ const roleAllow = args.roles && args.roles.length > 0
406
+ ? new Set(args.roles)
407
+ : new Set(Array.from(DEFAULT_INTERESTING_ROLES));
408
+ const cdp = await page.context().newCDPSession(page);
409
+ try {
410
+ await cdp.send('DOM.enable');
411
+ await cdp.send('Accessibility.enable');
412
+ if (includeRuntimeVisual) {
413
+ await cdp.send('Runtime.enable');
414
+ }
415
+ const axResponse = await cdp.send('Accessibility.getFullAXTree');
416
+ const axNodes = (axResponse.nodes ?? axResponse);
417
+ /**
418
+ * Build parent/child relationships from the FULL AX tree.
419
+ */
420
+ const parentByChildId = new Map();
421
+ const childIdsByNodeId = new Map();
422
+ for (const n of axNodes) {
423
+ const nodeIdStr = String(n.nodeId);
424
+ const rawChildIds = (n.childIds ?? []);
425
+ const childIds = rawChildIds.map((cid) => String(cid));
426
+ childIdsByNodeId.set(nodeIdStr, childIds);
427
+ for (const childIdStr of childIds) {
428
+ parentByChildId.set(childIdStr, nodeIdStr);
429
+ }
430
+ }
431
+ // Filter to DOM-backed nodes and role allowlist.
432
+ let candidates = axNodes.filter((n) => {
433
+ if (typeof n.backendDOMNodeId !== 'number') {
434
+ return false;
435
+ }
436
+ const roleValue = n.role?.value ?? null;
437
+ if (!roleValue) {
438
+ return false;
439
+ }
440
+ return roleAllow.has(String(roleValue));
441
+ });
442
+ const candidateCount = candidates.length;
443
+ // Internal guardrail to prevent extremely large outputs.
444
+ const truncatedBySafetyCap = candidates.length > INTERNAL_SAFETY_CAP;
445
+ if (truncatedBySafetyCap) {
446
+ candidates = candidates.slice(0, INTERNAL_SAFETY_CAP);
447
+ }
448
+ // Enrich nodes in parallel with bounded concurrency.
449
+ const queue = [...candidates];
450
+ const nodesOut = [];
451
+ const objectIds = [];
452
+ const worker = async () => {
453
+ while (queue.length > 0) {
454
+ const ax = queue.shift();
455
+ if (!ax) {
456
+ return;
457
+ }
458
+ const axNodeIdStr = String(ax.nodeId);
459
+ const parentAxNodeId = parentByChildId.get(axNodeIdStr) ?? null;
460
+ const childAxNodeIds = childIdsByNodeId.get(axNodeIdStr) ?? [];
461
+ const backendDOMNodeId = ax.backendDOMNodeId;
462
+ let domNodeId = null;
463
+ let localName = null;
464
+ let id = null;
465
+ let className = null;
466
+ let objectId = null;
467
+ // DOM.describeNode is used ONLY for tag/id/class metadata.
468
+ try {
469
+ const desc = await cdp.send('DOM.describeNode', {
470
+ backendNodeId: backendDOMNodeId,
471
+ });
472
+ const node = desc?.node;
473
+ if (node) {
474
+ const nodeIdCandidate = node.nodeId;
475
+ if (typeof nodeIdCandidate === 'number' &&
476
+ nodeIdCandidate > 0) {
477
+ domNodeId = nodeIdCandidate;
478
+ }
479
+ else {
480
+ domNodeId = null;
481
+ }
482
+ localName =
483
+ node.localName ??
484
+ (node.nodeName
485
+ ? String(node.nodeName).toLowerCase()
486
+ : null);
487
+ const attrObj = attrsToObj(node.attributes);
488
+ id = attrObj.id ?? null;
489
+ className = attrObj.class ?? null;
490
+ }
491
+ }
492
+ catch {
493
+ // Ignore per-node CDP failures
494
+ }
495
+ // DOM.resolveNode provides a Runtime objectId that reliably maps to a live Element.
496
+ if (includeRuntimeVisual) {
497
+ try {
498
+ const resolved = await cdp.send('DOM.resolveNode', {
499
+ backendNodeId: backendDOMNodeId,
500
+ });
501
+ objectId =
502
+ resolved?.object?.objectId ?? null;
503
+ }
504
+ catch {
505
+ // Ignore per-node resolve failures
506
+ }
507
+ }
508
+ const roleStr = ax.role?.value
509
+ ? String(ax.role.value)
510
+ : null;
511
+ const nameStr = ax.name?.value
512
+ ? String(ax.name.value)
513
+ : null;
514
+ const ignoredVal = typeof ax.ignored === 'boolean'
515
+ ? ax.ignored
516
+ : null;
517
+ const item = {
518
+ axNodeId: axNodeIdStr,
519
+ parentAxNodeId: parentAxNodeId,
520
+ childAxNodeIds: childAxNodeIds,
521
+ role: roleStr,
522
+ name: nameStr,
523
+ ignored: ignoredVal,
524
+ backendDOMNodeId: backendDOMNodeId,
525
+ domNodeId: domNodeId,
526
+ frameId: ax.frameId ?? null,
527
+ localName: localName,
528
+ id: id,
529
+ className: className,
530
+ selectorHint: null,
531
+ textPreview: null,
532
+ value: ax.value ?? null,
533
+ description: ax.description ?? null,
534
+ properties: Array.isArray(ax.properties)
535
+ ? ax.properties
536
+ : null,
537
+ };
538
+ const index = nodesOut.push(item) - 1;
539
+ objectIds[index] = objectId;
540
+ }
541
+ };
542
+ const workers = Array.from({ length: INTERNAL_CONCURRENCY }, () => worker());
543
+ await Promise.all(workers);
544
+ // Single batched runtime call (visual truth + optional styles + optional occlusion)
545
+ if (includeRuntimeVisual) {
546
+ const globalEval = await cdp.send('Runtime.evaluate', {
547
+ expression: 'globalThis',
548
+ returnByValue: false,
549
+ });
550
+ const globalObjectId = globalEval?.result
551
+ ?.objectId;
552
+ if (globalObjectId) {
553
+ const runtimeArgs = objectIds.map((oid) => {
554
+ if (oid) {
555
+ return { objectId: oid };
556
+ }
557
+ else {
558
+ return { value: null };
559
+ }
560
+ });
561
+ const runtimeResult = await cdp.send('Runtime.callFunctionOn', {
562
+ objectId: globalObjectId,
563
+ returnByValue: true,
564
+ functionDeclaration: `
565
+ function(textMax, includeStyles, styleProps, checkOcclusion, ...els) {
566
+ function selectorHintFor(el) {
567
+ if (!(el instanceof Element)) { return null; }
568
+
569
+ const dt = el.getAttribute('data-testid')
570
+ || el.getAttribute('data-test-id')
571
+ || el.getAttribute('data-test');
572
+
573
+ if (dt && dt.trim()) { return '[data-testid="' + dt.replace(/"/g, '\\\\\\"') + '"]'; }
574
+
575
+ const ds = el.getAttribute('data-selector');
576
+ if (ds && ds.trim()) { return '[data-selector="' + ds.replace(/"/g, '\\\\\\"') + '"]'; }
577
+
578
+ if (el.id) { return '#' + CSS.escape(el.id); }
579
+
580
+ return el.tagName.toLowerCase();
581
+ }
582
+
583
+ function textPreviewFor(el) {
584
+ if (!(el instanceof Element)) { return null; }
585
+
586
+ const aria = el.getAttribute('aria-label');
587
+ if (aria && aria.trim()) { return aria.trim().slice(0, textMax); }
588
+
589
+ const txt = (el.innerText || el.textContent || '').trim();
590
+ if (!txt) { return null; }
591
+
592
+ return txt.slice(0, textMax);
593
+ }
594
+
595
+ function pickStyles(el) {
596
+ if (!includeStyles) { return undefined; }
597
+ if (!(el instanceof Element)) { return undefined; }
598
+
599
+ const s = getComputedStyle(el);
600
+ const out = {};
601
+
602
+ for (let i = 0; i < styleProps.length; i++) {
603
+ const prop = styleProps[i];
604
+ try { out[prop] = s.getPropertyValue(prop); } catch {}
605
+ }
606
+
607
+ return out;
608
+ }
609
+
610
+ function intersectRects(a, b) {
611
+ const x1 = Math.max(a.left, b.left);
612
+ const y1 = Math.max(a.top, b.top);
613
+ const x2 = Math.min(a.right, b.right);
614
+ const y2 = Math.min(a.bottom, b.bottom);
615
+
616
+ const w = Math.max(0, x2 - x1);
617
+ const h = Math.max(0, y2 - y1);
618
+
619
+ return {
620
+ x: x1,
621
+ y: y1,
622
+ width: w,
623
+ height: h,
624
+ area: w * h,
625
+ };
626
+ }
627
+
628
+ function occlusionInfoFor(el) {
629
+ if (!(el instanceof Element)) {
630
+ return {
631
+ samplePoints: [],
632
+ isOccluded: false,
633
+ topElement: null,
634
+ intersection: null,
635
+ };
636
+ }
637
+
638
+ const r = el.getBoundingClientRect();
639
+ const hasBox = Number.isFinite(r.left) && Number.isFinite(r.top) && r.width > 0 && r.height > 0;
640
+
641
+ if (!hasBox) {
642
+ return {
643
+ samplePoints: [],
644
+ isOccluded: false,
645
+ topElement: null,
646
+ intersection: null,
647
+ };
648
+ }
649
+
650
+ // Sample center + 4 corners (inset) to better detect partial occlusion / overlap.
651
+ const inset = Math.max(1, Math.min(6, Math.floor(Math.min(r.width, r.height) / 4)));
652
+ const points = [
653
+ { x: r.left + r.width / 2, y: r.top + r.height / 2 }, // center
654
+ { x: r.left + inset, y: r.top + inset }, // top-left
655
+ { x: r.right - inset, y: r.top + inset }, // top-right
656
+ { x: r.left + inset, y: r.bottom - inset }, // bottom-left
657
+ { x: r.right - inset, y: r.bottom - inset }, // bottom-right
658
+ ];
659
+
660
+ let chosenTop = null;
661
+ let chosenTopRect = null;
662
+ let chosenIntersection = null;
663
+ let anyHit = false;
664
+
665
+ const samples = [];
666
+
667
+ for (const p of points) {
668
+ const topEl = document.elementFromPoint(p.x, p.y);
669
+
670
+ // Consider it "hit" only if topEl is not the element itself and not a descendant.
671
+ const hit = !!(topEl && topEl !== el && !el.contains(topEl));
672
+
673
+ samples.push({ x: p.x, y: p.y, hit });
674
+
675
+ if (hit) {
676
+ anyHit = true;
677
+
678
+ // Prefer the top element with the largest intersection area with this element's rect.
679
+ const topRect = topEl.getBoundingClientRect();
680
+ const inter = intersectRects(r, topRect);
681
+
682
+ if (!chosenTop || (chosenIntersection && inter.area > chosenIntersection.area)) {
683
+ chosenTop = topEl;
684
+ chosenTopRect = topRect;
685
+ chosenIntersection = inter;
686
+ }
687
+ }
688
+ }
689
+
690
+ // If not occluded, DO NOT return topElement at all (prevents "self occlusion" noise).
691
+ if (!anyHit || !chosenTop) {
692
+ return {
693
+ samplePoints: samples,
694
+ isOccluded: false,
695
+ topElement: null,
696
+ intersection: null,
697
+ };
698
+ }
699
+
700
+ const id = chosenTop.id ? chosenTop.id : null;
701
+ const className = chosenTop.getAttribute('class') ? chosenTop.getAttribute('class') : null;
702
+
703
+ return {
704
+ samplePoints: samples,
705
+ isOccluded: true,
706
+ topElement: {
707
+ localName: chosenTop.tagName ? chosenTop.tagName.toLowerCase() : null,
708
+ id: id,
709
+ className: className,
710
+ selectorHint: selectorHintFor(chosenTop),
711
+ boundingBox: chosenTopRect ? {
712
+ x: chosenTopRect.x,
713
+ y: chosenTopRect.y,
714
+ width: chosenTopRect.width,
715
+ height: chosenTopRect.height,
716
+ } : null,
717
+ },
718
+ intersection: chosenIntersection ? {
719
+ x: chosenIntersection.x,
720
+ y: chosenIntersection.y,
721
+ width: chosenIntersection.width,
722
+ height: chosenIntersection.height,
723
+ area: chosenIntersection.area,
724
+ } : null,
725
+ };
726
+ }
727
+
728
+ const vw = innerWidth;
729
+ const vh = innerHeight;
730
+
731
+ return els.map((el) => {
732
+ if (!(el instanceof Element)) {
733
+ return {
734
+ selectorHint: null,
735
+ textPreview: null,
736
+ styles: undefined,
737
+ runtime: {
738
+ boundingBox: null,
739
+ isVisible: false,
740
+ isInViewport: false,
741
+ occlusion: checkOcclusion ? {
742
+ samplePoints: [],
743
+ isOccluded: false,
744
+ topElement: null,
745
+ intersection: null,
746
+ } : undefined,
747
+ },
748
+ };
749
+ }
750
+
751
+ const r = el.getBoundingClientRect();
752
+ const s = getComputedStyle(el);
753
+
754
+ const isVisible =
755
+ s.display !== 'none'
756
+ && s.visibility !== 'hidden'
757
+ && parseFloat(s.opacity || '1') > 0
758
+ && r.width > 0
759
+ && r.height > 0;
760
+
761
+ const isInViewport =
762
+ r.right > 0
763
+ && r.bottom > 0
764
+ && r.left < vw
765
+ && r.top < vh;
766
+
767
+ const occlusion = checkOcclusion ? occlusionInfoFor(el) : undefined;
768
+
769
+ return {
770
+ selectorHint: selectorHintFor(el),
771
+ textPreview: textPreviewFor(el),
772
+ styles: pickStyles(el),
773
+ runtime: {
774
+ boundingBox: {
775
+ x: r.x,
776
+ y: r.y,
777
+ width: r.width,
778
+ height: r.height,
779
+ },
780
+ isVisible: isVisible,
781
+ isInViewport: isInViewport,
782
+ occlusion: occlusion,
783
+ },
784
+ };
785
+ });
786
+ }
787
+ `,
788
+ arguments: [
789
+ { value: textMax },
790
+ { value: includeStyles },
791
+ { value: stylePropsLower },
792
+ { value: checkOcclusion },
793
+ ...runtimeArgs,
794
+ ],
795
+ });
796
+ const values = (runtimeResult?.result?.value ??
797
+ []);
798
+ for (let i = 0; i < nodesOut.length; i++) {
799
+ const v = values[i];
800
+ if (!v) {
801
+ continue;
802
+ }
803
+ nodesOut[i].selectorHint =
804
+ v.selectorHint ?? null;
805
+ nodesOut[i].textPreview =
806
+ v.textPreview ?? null;
807
+ if (v.styles) {
808
+ nodesOut[i].styles = v.styles;
809
+ }
810
+ if (v.runtime) {
811
+ nodesOut[i].runtime =
812
+ v.runtime;
813
+ }
814
+ }
815
+ }
816
+ }
817
+ // Apply optional runtime filters.
818
+ let finalNodes = nodesOut;
819
+ if (onlyVisible || onlyInViewport) {
820
+ finalNodes = finalNodes.filter((n) => {
821
+ if (!n.runtime) {
822
+ return false;
823
+ }
824
+ if (onlyVisible && !n.runtime.isVisible) {
825
+ return false;
826
+ }
827
+ if (onlyInViewport && !n.runtime.isInViewport) {
828
+ return false;
829
+ }
830
+ return true;
831
+ });
832
+ }
833
+ const output = {
834
+ url: String(page.url()),
835
+ title: String(await page.title()),
836
+ axNodeCount: axNodes.length,
837
+ candidateCount: candidateCount,
838
+ enrichedCount: finalNodes.length,
839
+ truncatedBySafetyCap: truncatedBySafetyCap,
840
+ nodes: finalNodes,
841
+ };
842
+ return output;
843
+ }
844
+ finally {
845
+ await cdp.detach().catch(() => { });
846
+ }
847
+ }
848
+ }
849
+ exports.TakeAxTreeSnapshot = TakeAxTreeSnapshot;
850
+ //# sourceMappingURL=take-ax-tree-snapshot.js.map