@jobshimo/browser-link 0.14.1 → 0.15.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.
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Builders that assemble the CDP `Runtime.evaluate` expression strings for
3
+ * `snapshot`, `find`, `click` and `type`. Extracted out of background.ts so
4
+ * the produced JS source can be unit-tested by evaluating it directly in a
5
+ * DOM environment (see the `*.test.ts` files next to this module) — the
6
+ * exact same string background.ts sends over CDP.
7
+ */
8
+ import { DEEP_QUERY_JS } from './deep-query.js';
9
+ import { DOM_HELPERS_JS } from './dom-helpers.js';
10
+ export function buildSnapshotJs(opts = {}) {
11
+ const optsJson = JSON.stringify({
12
+ withinSelector: typeof opts.within_selector === 'string' ? opts.within_selector : null,
13
+ onlyInteractive: opts.only_interactive === true,
14
+ exclude: Array.isArray(opts.exclude) ? opts.exclude : [],
15
+ maxInteractive: typeof opts.max_interactive === 'number' && opts.max_interactive > 0
16
+ ? Math.min(opts.max_interactive, 500)
17
+ : 120,
18
+ });
19
+ return `
20
+ (() => {
21
+ ${DEEP_QUERY_JS}
22
+ ${DOM_HELPERS_JS}
23
+ const opts = ${optsJson};
24
+ let root = document;
25
+ let notice = '';
26
+ if (opts.withinSelector) {
27
+ const sub = deepQueryFirst(opts.withinSelector);
28
+ if (!sub) {
29
+ return {
30
+ title: document.title,
31
+ url: location.href,
32
+ interactive: [],
33
+ notice: 'within_selector did not match any element',
34
+ };
35
+ }
36
+ root = sub;
37
+ }
38
+ const excludeSet = new Set((opts.exclude || []).map(s => String(s).toLowerCase()));
39
+ function inExcludedLandmark(el) {
40
+ // Composed-tree climb (same pattern as composedContains): walking via
41
+ // parentElement alone would stop dead at a shadow boundary — a button
42
+ // inside a web component slotted into <nav> would escape exclusion.
43
+ // parentNode reaches the ShadowRoot; jumping to .host continues the
44
+ // climb in the host document.
45
+ if (excludeSet.size === 0) return false;
46
+ let cur = el.parentNode;
47
+ let guard = 0;
48
+ while (cur && guard < 1000) {
49
+ guard++;
50
+ if (cur === root) return false;
51
+ if (cur.nodeType === 1) {
52
+ const tag = cur.tagName ? cur.tagName.toLowerCase() : '';
53
+ if (excludeSet.has(tag)) return true;
54
+ if (cur === document.body) return false;
55
+ cur = cur.parentNode;
56
+ continue;
57
+ }
58
+ if (cur.host) {
59
+ cur = cur.host;
60
+ continue;
61
+ }
62
+ break;
63
+ }
64
+ return false;
65
+ }
66
+ const sel = 'a[href], button, input, select, textarea, [role=button], [role=link], [role=checkbox], [role=tab], [role=menuitem], [contenteditable=true]';
67
+ const interactive = [];
68
+ deepQueryAll(sel, root).forEach((el) => {
69
+ if (!isVisible(el)) return;
70
+ if (inExcludedLandmark(el)) return;
71
+ const tag = el.tagName.toLowerCase();
72
+ const role = el.getAttribute('role') || tag;
73
+ const selInfo = genSelectorInfo(el);
74
+ const entry = { tag, role, selector: selInfo.selector };
75
+ if (selInfo.ambiguous) entry.ambiguous = true;
76
+ const txt = shortText(el);
77
+ if (txt) entry.text = txt;
78
+ if ('value' in el && el.value) entry.value = String(el.value);
79
+ const placeholder = el.getAttribute('placeholder');
80
+ if (placeholder) entry.placeholder = placeholder;
81
+ const aria_label = el.getAttribute('aria-label');
82
+ if (aria_label) entry.aria_label = aria_label;
83
+ const name = el.getAttribute('name');
84
+ if (name) entry.name = name;
85
+ const type = el.getAttribute('type');
86
+ if (type) entry.type = type;
87
+ const href = el.getAttribute('href');
88
+ if (href) entry.href = href;
89
+ if ('disabled' in el && el.disabled) entry.disabled = true;
90
+ const frame = frameSelectorFor(el);
91
+ if (frame) entry.frame = frame;
92
+ interactive.push(entry);
93
+ });
94
+ const result = {
95
+ title: document.title,
96
+ url: location.href,
97
+ interactive: interactive.slice(0, opts.maxInteractive),
98
+ };
99
+ if (!opts.onlyInteractive) {
100
+ const headings = [];
101
+ deepQueryAll('h1, h2, h3', root).forEach((h) => {
102
+ if (!isVisible(h)) return;
103
+ if (inExcludedLandmark(h)) return;
104
+ const t = shortText(h);
105
+ if (t) headings.push({ level: h.tagName, text: t });
106
+ });
107
+ result.headings = headings.slice(0, 30);
108
+ const textRoot = (root === document) ? (document.body || document) : root;
109
+ const visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
110
+ if (visibleText) result.text = visibleText;
111
+ }
112
+ if (notice) result.notice = notice;
113
+ return result;
114
+ })()
115
+ `;
116
+ }
117
+ export function buildFindJs(opts) {
118
+ const optsJson = JSON.stringify({
119
+ text: opts.text,
120
+ role: typeof opts.role === 'string' && opts.role.length > 0 ? opts.role : null,
121
+ exact: opts.exact === true,
122
+ candidateLimit: 5,
123
+ });
124
+ return `
125
+ (() => {
126
+ ${DEEP_QUERY_JS}
127
+ ${DOM_HELPERS_JS}
128
+ const opts = ${optsJson};
129
+ const needle = opts.text.toLowerCase();
130
+ const ROLE_SELECTORS = {
131
+ button: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
132
+ link: 'a[href], [role="link"]',
133
+ textbox: 'input[type="text"], input[type="email"], input[type="password"], input[type="search"], input[type="url"], input[type="tel"], input:not([type]), textarea, [role="textbox"], [contenteditable="true"]',
134
+ checkbox: 'input[type="checkbox"], [role="checkbox"]',
135
+ tab: '[role="tab"]',
136
+ menuitem: '[role="menuitem"]',
137
+ };
138
+ const selectorSet = opts.role && ROLE_SELECTORS[opts.role]
139
+ ? ROLE_SELECTORS[opts.role]
140
+ : 'button, a, input, textarea, select, [role], [onclick], [contenteditable="true"], [tabindex]';
141
+ const all = deepQueryAll(selectorSet);
142
+ const matches = [];
143
+ for (const el of all) {
144
+ if (!isVisible(el)) continue;
145
+ const text = accessibleText(el).trim();
146
+ if (text.length === 0) continue;
147
+ const lower = text.toLowerCase();
148
+ const ok = opts.exact ? lower === needle : lower.includes(needle);
149
+ if (ok) matches.push(el);
150
+ }
151
+ if (matches.length === 0) {
152
+ return { matched: false, reason: 'not-found' };
153
+ }
154
+ if (matches.length > 1) {
155
+ return {
156
+ matched: false,
157
+ reason: 'multiple-matches',
158
+ candidates: matches.slice(0, opts.candidateLimit).map((el) => ({
159
+ selector: genSelector(el),
160
+ text: shortText(el),
161
+ tag: el.tagName.toLowerCase(),
162
+ })),
163
+ };
164
+ }
165
+ const el = matches[0];
166
+ const center = viewportCenterOf(el);
167
+ const selInfo = genSelectorInfo(el);
168
+ const result = {
169
+ matched: true,
170
+ selector: selInfo.selector,
171
+ coords: {
172
+ x: Math.round(center.x),
173
+ y: Math.round(center.y),
174
+ },
175
+ tag: el.tagName.toLowerCase(),
176
+ text: shortText(el),
177
+ };
178
+ if (selInfo.ambiguous) result.ambiguous = true;
179
+ const frame = frameSelectorFor(el);
180
+ if (frame) result.frame = frame;
181
+ return result;
182
+ })()
183
+ `;
184
+ }
185
+ export function buildClickResolveJs(opts) {
186
+ const optsJson = JSON.stringify({ selector: opts.selector, force: opts.force });
187
+ return `
188
+ (() => {
189
+ ${DEEP_QUERY_JS}
190
+ const opts = ${optsJson};
191
+ const el = deepQueryFirst(opts.selector);
192
+ if (!el) return { ok: false, reason: 'not-found' };
193
+ const chain = frameElementChain(el);
194
+ for (const fe of chain) {
195
+ if (fe.scrollIntoView) fe.scrollIntoView({ block: 'center', inline: 'center' });
196
+ }
197
+ if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' });
198
+ const center = viewportCenterOf(el);
199
+ const tag = el.tagName.toLowerCase();
200
+ if (!opts.force) {
201
+ const occlusion = checkOcclusion(el, center.localX, center.localY);
202
+ if (!occlusion.allowed) {
203
+ return { ok: false, reason: 'occluded', blocker: occlusion.blocker };
204
+ }
205
+ }
206
+ return { ok: true, x: center.x, y: center.y, tag };
207
+ })()
208
+ `;
209
+ }
210
+ export function buildTypeResolveJs(opts) {
211
+ const optsJson = JSON.stringify({ selector: opts.selector });
212
+ return `
213
+ (() => {
214
+ ${DEEP_QUERY_JS}
215
+ const opts = ${optsJson};
216
+ const el = deepQueryFirst(opts.selector);
217
+ if (!el) return false;
218
+ el.focus();
219
+ ${opts.clear ? "if ('value' in el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }" : ''}
220
+ return true;
221
+ })()`;
222
+ }
223
+ //# sourceMappingURL=builders.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builders.js","sourceRoot":"","sources":["../../src/inpage/builders.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAwBlD,MAAM,UAAU,eAAe,CAAC,OAAqB,EAAE;IACrD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,cAAc,EAAE,OAAO,IAAI,CAAC,eAAe,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,IAAI;QACtF,eAAe,EAAE,IAAI,CAAC,gBAAgB,KAAK,IAAI;QAC/C,OAAO,EAAE,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE;QACxD,cAAc,EACZ,OAAO,IAAI,CAAC,eAAe,KAAK,QAAQ,IAAI,IAAI,CAAC,eAAe,GAAG,CAAC;YAClE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,eAAe,EAAE,GAAG,CAAC;YACrC,CAAC,CAAC,GAAG;KACV,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;IACb,cAAc;iBACD,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4FxB,CAAC;AACF,CAAC;AAsBD,MAAM,UAAU,WAAW,CAAC,IAAc;IACxC,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,IAAI,EAAE,IAAI,CAAC,IAAI;QACf,IAAI,EAAE,OAAO,IAAI,CAAC,IAAI,KAAK,QAAQ,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI;QAC9E,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK,IAAI;QAC1B,cAAc,EAAE,CAAC;KAClB,CAAC,CAAC;IACH,OAAO;;IAEL,aAAa;IACb,cAAc;iBACD,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuDxB,CAAC;AACF,CAAC;AAiBD,MAAM,UAAU,mBAAmB,CAAC,IAAsB;IACxD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,CAAC,CAAC;IAChF,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;;;;;;;;;;;;;;;CAkBxB,CAAC;AACF,CAAC;AAeD,MAAM,UAAU,kBAAkB,CAAC,IAAqB;IACtD,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,CAAC,CAAC;IAC7D,OAAO;;IAEL,aAAa;iBACA,QAAQ;;;;IAIrB,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,gGAAgG,CAAC,CAAC,CAAC,EAAE;;KAEjH,CAAC;AACN,CAAC"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * In-page JS source injected into CDP `Runtime.evaluate` expressions built
3
+ * by background.ts. This is the "deep search" layer that lets `snapshot`,
4
+ * `find`, `click` and `type` see past two boundaries plain
5
+ * `document.querySelector` / `document.querySelectorAll` cannot cross:
6
+ *
7
+ * - OPEN Shadow DOM roots (`element.attachShadow({ mode: 'open' })`),
8
+ * walked recursively (shadow roots nested inside shadow roots).
9
+ * - Same-origin `<iframe>` documents, walked recursively (iframes nested
10
+ * inside iframes, shadow roots nested inside iframes and vice versa).
11
+ *
12
+ * Exported as a raw string, not real TypeScript — it runs inside the
13
+ * TARGET PAGE's JS context via CDP `Runtime.evaluate`, not inside the
14
+ * extension's own service worker. Keep the syntax plain (`var`/`function`,
15
+ * no optional chaining assumptions) since it must run unmodified in
16
+ * whatever JS engine the connected tab uses, and keep it dependency-free so
17
+ * it can be unit-tested by evaluating this exact string in a DOM
18
+ * environment (see `deep-query.test.ts`).
19
+ *
20
+ * Known limitations (also documented on the MCP tool descriptions):
21
+ * - CLOSED shadow roots (`{ mode: 'closed' }`) are unreachable from page
22
+ * world JS — `element.shadowRoot` is `null` for them by design. There
23
+ * is no CDP-level workaround from `Runtime.evaluate`.
24
+ * - CROSS-ORIGIN iframes are unreachable — `contentDocument` throws or
25
+ * returns null under the same-origin policy. Access is wrapped in
26
+ * try/catch so a cross-origin frame is silently skipped rather than
27
+ * failing the whole query.
28
+ * - A plain CSS selector cannot distinguish between two elements that
29
+ * produce byte-identical generated selectors in two different roots
30
+ * (e.g. two structurally-identical Shadow DOM component instances).
31
+ * `deepQueryFirst` returns the first match in traversal order
32
+ * (the start root itself, then nested shadow roots, then nested
33
+ * same-origin iframe documents, depth-first). Extremely rare in
34
+ * practice — real content differs — but worth knowing when a page
35
+ * repeats the exact same component markup in multiple places. When
36
+ * selector generation cannot avoid this (see `genSelectorInfo` in
37
+ * `dom-helpers.ts`), snapshot/find mark the affected entry with
38
+ * `ambiguous: true` so agents know not to cache that selector.
39
+ */
40
+ export declare const DEEP_QUERY_JS = "\n function collectSearchRoots(start) {\n // start: a Document, Element or ShadowRoot to scope the search to.\n // Returns [start, ...every open shadow root and same-origin iframe\n // document reachable inside start's subtree, recursively]. The caller\n // runs .querySelectorAll()/.querySelector() on each entry directly \u2014\n // each entry's own querySelectorAll does NOT pierce into nested shadow\n // roots or iframes on its own, which is why we enumerate them here.\n var roots = [start];\n function walk(scope, depth) {\n // Depth guard, same pattern as the other recursive helpers here:\n // pathological nesting (or a cycle introduced by a broken mock)\n // must degrade to a truncated result, never a stack overflow.\n if (depth > 20) return;\n var all;\n try {\n all = scope.querySelectorAll('*');\n } catch (_) {\n return;\n }\n for (var i = 0; i < all.length; i++) {\n var el = all[i];\n if (el.shadowRoot) {\n roots.push(el.shadowRoot);\n walk(el.shadowRoot, depth + 1);\n }\n if (el.tagName === 'IFRAME') {\n var doc = null;\n try {\n doc = el.contentDocument;\n } catch (_) {\n doc = null;\n }\n if (doc) {\n roots.push(doc);\n walk(doc, depth + 1);\n }\n }\n }\n }\n walk(start, 0);\n return roots;\n }\n\n function deepWalkAll(callback, start) {\n // Tree-walk primitive: invoke callback(el) for every element reachable\n // from start (default: document) across the top document, every open\n // shadow root, and every same-origin iframe document.\n var root = start || document;\n var roots = collectSearchRoots(root);\n for (var r = 0; r < roots.length; r++) {\n var all;\n try {\n all = roots[r].querySelectorAll('*');\n } catch (_) {\n continue;\n }\n for (var i = 0; i < all.length; i++) callback(all[i]);\n }\n }\n\n function deepQueryAll(selector, start) {\n var root = start || document;\n var roots = collectSearchRoots(root);\n var out = [];\n for (var r = 0; r < roots.length; r++) {\n var found;\n try {\n found = roots[r].querySelectorAll(selector);\n } catch (_) {\n continue;\n }\n for (var i = 0; i < found.length; i++) out.push(found[i]);\n }\n return out;\n }\n\n function deepQueryFirst(selector, start) {\n var root = start || document;\n var roots = collectSearchRoots(root);\n for (var r = 0; r < roots.length; r++) {\n var el = null;\n try {\n el = roots[r].querySelector(selector);\n } catch (_) {\n el = null;\n }\n if (el) return el;\n }\n return null;\n }\n\n function frameElementChain(el) {\n // Walk up from el's own window to the top window, collecting the\n // <iframe> element that hosts each intermediate document. Outermost\n // frame first. Same-origin only \u2014 frameElement throws/returns null\n // across an origin boundary, at which point the chain simply stops\n // (the coordinate math below degrades gracefully rather than throwing).\n var chain = [];\n var doc = el.ownerDocument;\n var win = doc && doc.defaultView;\n if (!win) return chain;\n var topWin;\n try {\n topWin = win.top;\n } catch (_) {\n topWin = win;\n }\n var guard = 0;\n while (win && win !== topWin && guard < 20) {\n guard++;\n var fe = null;\n try {\n fe = win.frameElement;\n } catch (_) {\n fe = null;\n }\n if (!fe) break;\n chain.unshift(fe);\n win = fe.ownerDocument && fe.ownerDocument.defaultView;\n }\n return chain;\n }\n\n function frameViewportOriginOffset(fe) {\n // The embedded document's viewport origin sits at the frame element's\n // CONTENT box, but getBoundingClientRect() returns the BORDER box \u2014\n // border and padding must be added on top of rect.left/rect.top or\n // every coordinate inside a styled iframe lands short. clientLeft/\n // clientTop are the border widths; padding comes from computed style\n // (read through the frame's own view \u2014 same-origin, so reachable).\n var borderX = fe.clientLeft || 0;\n var borderY = fe.clientTop || 0;\n var padX = 0;\n var padY = 0;\n try {\n var view = (fe.ownerDocument && fe.ownerDocument.defaultView) || window;\n var style = view.getComputedStyle(fe);\n padX = parseFloat(style.paddingLeft) || 0;\n padY = parseFloat(style.paddingTop) || 0;\n } catch (_) {}\n return { x: borderX + padX, y: borderY + padY };\n }\n\n function clickRectOf(el) {\n // For a line-wrapped INLINE element (a link spanning two lines), the\n // center of getBoundingClientRect() can land in the unpainted gap\n // between the line boxes \u2014 elementFromPoint would report whatever sits\n // behind, and a dispatched click would miss. The first client rect is\n // always a painted fragment, so its center is a safe click point.\n // Block-level elements have exactly one client rect, identical to the\n // bounding rect \u2014 no behavior change for them.\n var rects = null;\n try {\n rects = el.getClientRects ? el.getClientRects() : null;\n } catch (_) {\n rects = null;\n }\n if (rects && rects.length > 0) return rects[0];\n return el.getBoundingClientRect();\n }\n\n function viewportCenterOf(el) {\n // Returns the element's click point in two coordinate spaces:\n // - localX/localY: relative to el's OWN document viewport (what\n // that document's elementFromPoint expects).\n // - x/y: relative to the TOP-LEVEL viewport (what CDP\n // Input.dispatchMouseEvent expects) \u2014 localX/localY plus the\n // accumulated content-box origin of every ancestor <iframe>\n // element (border-box rect + border + padding). Shadow DOM\n // boundaries need no offset: they do not introduce a new\n // coordinate space.\n // Both spaces derive from the SAME point (first-client-rect center),\n // so the occlusion hit-test and the CDP dispatch always agree.\n var rect = clickRectOf(el);\n var localX = rect.left + rect.width / 2;\n var localY = rect.top + rect.height / 2;\n var chain = frameElementChain(el);\n var offsetX = 0;\n var offsetY = 0;\n for (var i = 0; i < chain.length; i++) {\n var frameRect = chain[i].getBoundingClientRect();\n var origin = frameViewportOriginOffset(chain[i]);\n offsetX += frameRect.left + origin.x;\n offsetY += frameRect.top + origin.y;\n }\n return { x: offsetX + localX, y: offsetY + localY, localX: localX, localY: localY };\n }\n\n function composedContains(ancestor, node) {\n // Node.contains() does not cross shadow boundaries. This walks the\n // COMPOSED tree: when climbing hits a ShadowRoot (no parentNode), jump\n // to its host and keep climbing.\n var cur = node;\n var guard = 0;\n while (cur && guard < 1000) {\n guard++;\n if (cur === ancestor) return true;\n if (cur.parentNode) {\n cur = cur.parentNode;\n continue;\n }\n if (cur.host) {\n cur = cur.host;\n continue;\n }\n break;\n }\n return false;\n }\n\n function describeElement(el) {\n if (!el || !el.tagName) return 'an unknown element';\n var desc = el.tagName.toLowerCase();\n if (el.id) desc += '#' + el.id;\n if (el.classList && el.classList.length) {\n var classes = [];\n for (var i = 0; i < el.classList.length && i < 2; i++) classes.push(el.classList[i]);\n if (classes.length) desc += '.' + classes.join('.');\n }\n return desc;\n }\n\n function hitTestDeep(root, x, y) {\n var node = null;\n try {\n node = root && root.elementFromPoint ? root.elementFromPoint(x, y) : null;\n } catch (_) {\n node = null;\n }\n // The hit element may itself host a nested open shadow root whose\n // content visually covers the point \u2014 descend until elementFromPoint\n // stops finding a deeper shadow tree.\n var guard = 0;\n while (node && node.shadowRoot && guard < 20) {\n guard++;\n var inner = null;\n try {\n inner = node.shadowRoot.elementFromPoint ? node.shadowRoot.elementFromPoint(x, y) : null;\n } catch (_) {\n inner = null;\n }\n if (!inner || inner === node) break;\n node = inner;\n }\n return node;\n }\n\n function checkOcclusion(target, localX, localY) {\n // Hit-test starting from the element's OWN root (its closest shadow\n // root, or the document that owns it when not in shadow DOM) using\n // LOCAL coordinates \u2014 the same coordinate space that root's own\n // elementFromPoint expects (an iframe document's viewport starts at\n // its own top-left, not the top-level page's).\n var root = target.getRootNode ? target.getRootNode() : document;\n var hit = hitTestDeep(root, localX, localY);\n if (!hit) {\n // Nothing hit-tested at all (e.g. elementFromPoint unsupported in\n // this environment, or the point is outside the rendered area).\n // Fail open rather than block a click we cannot actually verify.\n return { allowed: true };\n }\n if (hit === target || composedContains(target, hit) || composedContains(hit, target)) {\n return { allowed: true };\n }\n return { allowed: false, blocker: describeElement(hit) };\n }\n";
@@ -0,0 +1,308 @@
1
+ /**
2
+ * In-page JS source injected into CDP `Runtime.evaluate` expressions built
3
+ * by background.ts. This is the "deep search" layer that lets `snapshot`,
4
+ * `find`, `click` and `type` see past two boundaries plain
5
+ * `document.querySelector` / `document.querySelectorAll` cannot cross:
6
+ *
7
+ * - OPEN Shadow DOM roots (`element.attachShadow({ mode: 'open' })`),
8
+ * walked recursively (shadow roots nested inside shadow roots).
9
+ * - Same-origin `<iframe>` documents, walked recursively (iframes nested
10
+ * inside iframes, shadow roots nested inside iframes and vice versa).
11
+ *
12
+ * Exported as a raw string, not real TypeScript — it runs inside the
13
+ * TARGET PAGE's JS context via CDP `Runtime.evaluate`, not inside the
14
+ * extension's own service worker. Keep the syntax plain (`var`/`function`,
15
+ * no optional chaining assumptions) since it must run unmodified in
16
+ * whatever JS engine the connected tab uses, and keep it dependency-free so
17
+ * it can be unit-tested by evaluating this exact string in a DOM
18
+ * environment (see `deep-query.test.ts`).
19
+ *
20
+ * Known limitations (also documented on the MCP tool descriptions):
21
+ * - CLOSED shadow roots (`{ mode: 'closed' }`) are unreachable from page
22
+ * world JS — `element.shadowRoot` is `null` for them by design. There
23
+ * is no CDP-level workaround from `Runtime.evaluate`.
24
+ * - CROSS-ORIGIN iframes are unreachable — `contentDocument` throws or
25
+ * returns null under the same-origin policy. Access is wrapped in
26
+ * try/catch so a cross-origin frame is silently skipped rather than
27
+ * failing the whole query.
28
+ * - A plain CSS selector cannot distinguish between two elements that
29
+ * produce byte-identical generated selectors in two different roots
30
+ * (e.g. two structurally-identical Shadow DOM component instances).
31
+ * `deepQueryFirst` returns the first match in traversal order
32
+ * (the start root itself, then nested shadow roots, then nested
33
+ * same-origin iframe documents, depth-first). Extremely rare in
34
+ * practice — real content differs — but worth knowing when a page
35
+ * repeats the exact same component markup in multiple places. When
36
+ * selector generation cannot avoid this (see `genSelectorInfo` in
37
+ * `dom-helpers.ts`), snapshot/find mark the affected entry with
38
+ * `ambiguous: true` so agents know not to cache that selector.
39
+ */
40
+ export const DEEP_QUERY_JS = `
41
+ function collectSearchRoots(start) {
42
+ // start: a Document, Element or ShadowRoot to scope the search to.
43
+ // Returns [start, ...every open shadow root and same-origin iframe
44
+ // document reachable inside start's subtree, recursively]. The caller
45
+ // runs .querySelectorAll()/.querySelector() on each entry directly —
46
+ // each entry's own querySelectorAll does NOT pierce into nested shadow
47
+ // roots or iframes on its own, which is why we enumerate them here.
48
+ var roots = [start];
49
+ function walk(scope, depth) {
50
+ // Depth guard, same pattern as the other recursive helpers here:
51
+ // pathological nesting (or a cycle introduced by a broken mock)
52
+ // must degrade to a truncated result, never a stack overflow.
53
+ if (depth > 20) return;
54
+ var all;
55
+ try {
56
+ all = scope.querySelectorAll('*');
57
+ } catch (_) {
58
+ return;
59
+ }
60
+ for (var i = 0; i < all.length; i++) {
61
+ var el = all[i];
62
+ if (el.shadowRoot) {
63
+ roots.push(el.shadowRoot);
64
+ walk(el.shadowRoot, depth + 1);
65
+ }
66
+ if (el.tagName === 'IFRAME') {
67
+ var doc = null;
68
+ try {
69
+ doc = el.contentDocument;
70
+ } catch (_) {
71
+ doc = null;
72
+ }
73
+ if (doc) {
74
+ roots.push(doc);
75
+ walk(doc, depth + 1);
76
+ }
77
+ }
78
+ }
79
+ }
80
+ walk(start, 0);
81
+ return roots;
82
+ }
83
+
84
+ function deepWalkAll(callback, start) {
85
+ // Tree-walk primitive: invoke callback(el) for every element reachable
86
+ // from start (default: document) across the top document, every open
87
+ // shadow root, and every same-origin iframe document.
88
+ var root = start || document;
89
+ var roots = collectSearchRoots(root);
90
+ for (var r = 0; r < roots.length; r++) {
91
+ var all;
92
+ try {
93
+ all = roots[r].querySelectorAll('*');
94
+ } catch (_) {
95
+ continue;
96
+ }
97
+ for (var i = 0; i < all.length; i++) callback(all[i]);
98
+ }
99
+ }
100
+
101
+ function deepQueryAll(selector, start) {
102
+ var root = start || document;
103
+ var roots = collectSearchRoots(root);
104
+ var out = [];
105
+ for (var r = 0; r < roots.length; r++) {
106
+ var found;
107
+ try {
108
+ found = roots[r].querySelectorAll(selector);
109
+ } catch (_) {
110
+ continue;
111
+ }
112
+ for (var i = 0; i < found.length; i++) out.push(found[i]);
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function deepQueryFirst(selector, start) {
118
+ var root = start || document;
119
+ var roots = collectSearchRoots(root);
120
+ for (var r = 0; r < roots.length; r++) {
121
+ var el = null;
122
+ try {
123
+ el = roots[r].querySelector(selector);
124
+ } catch (_) {
125
+ el = null;
126
+ }
127
+ if (el) return el;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function frameElementChain(el) {
133
+ // Walk up from el's own window to the top window, collecting the
134
+ // <iframe> element that hosts each intermediate document. Outermost
135
+ // frame first. Same-origin only — frameElement throws/returns null
136
+ // across an origin boundary, at which point the chain simply stops
137
+ // (the coordinate math below degrades gracefully rather than throwing).
138
+ var chain = [];
139
+ var doc = el.ownerDocument;
140
+ var win = doc && doc.defaultView;
141
+ if (!win) return chain;
142
+ var topWin;
143
+ try {
144
+ topWin = win.top;
145
+ } catch (_) {
146
+ topWin = win;
147
+ }
148
+ var guard = 0;
149
+ while (win && win !== topWin && guard < 20) {
150
+ guard++;
151
+ var fe = null;
152
+ try {
153
+ fe = win.frameElement;
154
+ } catch (_) {
155
+ fe = null;
156
+ }
157
+ if (!fe) break;
158
+ chain.unshift(fe);
159
+ win = fe.ownerDocument && fe.ownerDocument.defaultView;
160
+ }
161
+ return chain;
162
+ }
163
+
164
+ function frameViewportOriginOffset(fe) {
165
+ // The embedded document's viewport origin sits at the frame element's
166
+ // CONTENT box, but getBoundingClientRect() returns the BORDER box —
167
+ // border and padding must be added on top of rect.left/rect.top or
168
+ // every coordinate inside a styled iframe lands short. clientLeft/
169
+ // clientTop are the border widths; padding comes from computed style
170
+ // (read through the frame's own view — same-origin, so reachable).
171
+ var borderX = fe.clientLeft || 0;
172
+ var borderY = fe.clientTop || 0;
173
+ var padX = 0;
174
+ var padY = 0;
175
+ try {
176
+ var view = (fe.ownerDocument && fe.ownerDocument.defaultView) || window;
177
+ var style = view.getComputedStyle(fe);
178
+ padX = parseFloat(style.paddingLeft) || 0;
179
+ padY = parseFloat(style.paddingTop) || 0;
180
+ } catch (_) {}
181
+ return { x: borderX + padX, y: borderY + padY };
182
+ }
183
+
184
+ function clickRectOf(el) {
185
+ // For a line-wrapped INLINE element (a link spanning two lines), the
186
+ // center of getBoundingClientRect() can land in the unpainted gap
187
+ // between the line boxes — elementFromPoint would report whatever sits
188
+ // behind, and a dispatched click would miss. The first client rect is
189
+ // always a painted fragment, so its center is a safe click point.
190
+ // Block-level elements have exactly one client rect, identical to the
191
+ // bounding rect — no behavior change for them.
192
+ var rects = null;
193
+ try {
194
+ rects = el.getClientRects ? el.getClientRects() : null;
195
+ } catch (_) {
196
+ rects = null;
197
+ }
198
+ if (rects && rects.length > 0) return rects[0];
199
+ return el.getBoundingClientRect();
200
+ }
201
+
202
+ function viewportCenterOf(el) {
203
+ // Returns the element's click point in two coordinate spaces:
204
+ // - localX/localY: relative to el's OWN document viewport (what
205
+ // that document's elementFromPoint expects).
206
+ // - x/y: relative to the TOP-LEVEL viewport (what CDP
207
+ // Input.dispatchMouseEvent expects) — localX/localY plus the
208
+ // accumulated content-box origin of every ancestor <iframe>
209
+ // element (border-box rect + border + padding). Shadow DOM
210
+ // boundaries need no offset: they do not introduce a new
211
+ // coordinate space.
212
+ // Both spaces derive from the SAME point (first-client-rect center),
213
+ // so the occlusion hit-test and the CDP dispatch always agree.
214
+ var rect = clickRectOf(el);
215
+ var localX = rect.left + rect.width / 2;
216
+ var localY = rect.top + rect.height / 2;
217
+ var chain = frameElementChain(el);
218
+ var offsetX = 0;
219
+ var offsetY = 0;
220
+ for (var i = 0; i < chain.length; i++) {
221
+ var frameRect = chain[i].getBoundingClientRect();
222
+ var origin = frameViewportOriginOffset(chain[i]);
223
+ offsetX += frameRect.left + origin.x;
224
+ offsetY += frameRect.top + origin.y;
225
+ }
226
+ return { x: offsetX + localX, y: offsetY + localY, localX: localX, localY: localY };
227
+ }
228
+
229
+ function composedContains(ancestor, node) {
230
+ // Node.contains() does not cross shadow boundaries. This walks the
231
+ // COMPOSED tree: when climbing hits a ShadowRoot (no parentNode), jump
232
+ // to its host and keep climbing.
233
+ var cur = node;
234
+ var guard = 0;
235
+ while (cur && guard < 1000) {
236
+ guard++;
237
+ if (cur === ancestor) return true;
238
+ if (cur.parentNode) {
239
+ cur = cur.parentNode;
240
+ continue;
241
+ }
242
+ if (cur.host) {
243
+ cur = cur.host;
244
+ continue;
245
+ }
246
+ break;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ function describeElement(el) {
252
+ if (!el || !el.tagName) return 'an unknown element';
253
+ var desc = el.tagName.toLowerCase();
254
+ if (el.id) desc += '#' + el.id;
255
+ if (el.classList && el.classList.length) {
256
+ var classes = [];
257
+ for (var i = 0; i < el.classList.length && i < 2; i++) classes.push(el.classList[i]);
258
+ if (classes.length) desc += '.' + classes.join('.');
259
+ }
260
+ return desc;
261
+ }
262
+
263
+ function hitTestDeep(root, x, y) {
264
+ var node = null;
265
+ try {
266
+ node = root && root.elementFromPoint ? root.elementFromPoint(x, y) : null;
267
+ } catch (_) {
268
+ node = null;
269
+ }
270
+ // The hit element may itself host a nested open shadow root whose
271
+ // content visually covers the point — descend until elementFromPoint
272
+ // stops finding a deeper shadow tree.
273
+ var guard = 0;
274
+ while (node && node.shadowRoot && guard < 20) {
275
+ guard++;
276
+ var inner = null;
277
+ try {
278
+ inner = node.shadowRoot.elementFromPoint ? node.shadowRoot.elementFromPoint(x, y) : null;
279
+ } catch (_) {
280
+ inner = null;
281
+ }
282
+ if (!inner || inner === node) break;
283
+ node = inner;
284
+ }
285
+ return node;
286
+ }
287
+
288
+ function checkOcclusion(target, localX, localY) {
289
+ // Hit-test starting from the element's OWN root (its closest shadow
290
+ // root, or the document that owns it when not in shadow DOM) using
291
+ // LOCAL coordinates — the same coordinate space that root's own
292
+ // elementFromPoint expects (an iframe document's viewport starts at
293
+ // its own top-left, not the top-level page's).
294
+ var root = target.getRootNode ? target.getRootNode() : document;
295
+ var hit = hitTestDeep(root, localX, localY);
296
+ if (!hit) {
297
+ // Nothing hit-tested at all (e.g. elementFromPoint unsupported in
298
+ // this environment, or the point is outside the rendered area).
299
+ // Fail open rather than block a click we cannot actually verify.
300
+ return { allowed: true };
301
+ }
302
+ if (hit === target || composedContains(target, hit) || composedContains(hit, target)) {
303
+ return { allowed: true };
304
+ }
305
+ return { allowed: false, blocker: describeElement(hit) };
306
+ }
307
+ `;
308
+ //# sourceMappingURL=deep-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-query.js","sourceRoot":"","sources":["../../src/inpage/deep-query.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2Q5B,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared in-page DOM helpers, injected verbatim into any JS template that
3
+ * needs them (`buildSnapshotJs`, `buildFindJs`). Defining them once here
4
+ * keeps the heuristics (visibility, selector generation, accessible text)
5
+ * identical across tools so a selector returned by `snapshot` and a selector
6
+ * returned by `find` follow the same rules.
7
+ *
8
+ * `genSelectorInfo` calls `deepQueryAll` to verify a candidate selector is
9
+ * unique across the FULL deep search scope (top document + every open
10
+ * shadow root + every same-origin iframe), not just `document`. That is a
11
+ * runtime dependency, not an import-time one — these are plain string
12
+ * templates concatenated at the CDP-expression build site, not real ES
13
+ * modules. Always interpolate `DEEP_QUERY_JS` (from `./deep-query.js`)
14
+ * BEFORE this constant in any expression that calls `genSelectorInfo`.
15
+ *
16
+ * When even a fully-qualified structural path stays ambiguous (two
17
+ * structurally-identical twins in different roots — no CSS syntax can
18
+ * scope to one root), `genSelectorInfo` returns `ambiguous: true` and the
19
+ * snapshot/find builders surface it on the affected entry so agents know
20
+ * that selector resolves first-match-wins and must not be cached.
21
+ */
22
+ export declare const DOM_HELPERS_JS = "\n function isVisible(el) {\n if (!(el instanceof HTMLElement)) return true;\n const style = getComputedStyle(el);\n if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') return false;\n if (el.offsetParent === null && style.position !== 'fixed') return false;\n return true;\n }\n function shortText(el) {\n const t = (el.innerText || el.textContent || '').trim();\n return t.length > 120 ? t.slice(0, 120) + '...' : t;\n }\n function safeCss(s) {\n return s.replace(/\"/g, '\\\\\"');\n }\n function genSelectorInfo(el) {\n // Uniqueness is verified with deepQueryAll \u2014 an id/testid/aria-label/\n // name that is unique in the top document can still collide with an\n // identically-attributed element inside a shadow root or same-origin\n // iframe, since each root has its own independent attribute namespace.\n if (el.id && !/^[\\d]/.test(el.id) && !/\\s/.test(el.id)) {\n try {\n const candidate = '#' + CSS.escape(el.id);\n if (deepQueryAll(candidate).length === 1) return { selector: candidate, ambiguous: false };\n } catch (_) {}\n }\n const tid = el.getAttribute('data-testid');\n if (tid) {\n const candidate = '[data-testid=\"' + safeCss(tid) + '\"]';\n if (deepQueryAll(candidate).length === 1) return { selector: candidate, ambiguous: false };\n }\n const al = el.getAttribute('aria-label');\n if (al && al.length < 60) {\n const candidate = el.tagName.toLowerCase() + '[aria-label=\"' + safeCss(al) + '\"]';\n if (deepQueryAll(candidate).length === 1) return { selector: candidate, ambiguous: false };\n }\n const name = el.getAttribute('name');\n if (name && (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA')) {\n const candidate = el.tagName.toLowerCase() + '[name=\"' + safeCss(name) + '\"]';\n if (deepQueryAll(candidate).length === 1) return { selector: candidate, ambiguous: false };\n }\n // Structural fallback, verified like every shortcut above. The short\n // form (6 parts, indexed only where siblings force it) is preferred\n // for stability; when it matches more than one element across roots,\n // retry with a fully-qualified path (every level :nth-of-type-indexed,\n // climbed all the way to the root boundary).\n const structural = buildStructuralSelector(el, 6, false);\n try {\n if (deepQueryAll(structural).length === 1) return { selector: structural, ambiguous: false };\n } catch (_) {}\n const qualified = buildStructuralSelector(el, 32, true);\n try {\n if (deepQueryAll(qualified).length === 1) return { selector: qualified, ambiguous: false };\n } catch (_) {}\n // Still ambiguous: structurally-identical twins in different roots.\n // No CSS syntax can scope a selector to one shadow root / iframe, so\n // return the most qualified path and flag it \u2014 deepQueryFirst will\n // resolve it first-match-wins (deterministic traversal order), and\n // the flag tells agents not to cache it.\n return { selector: qualified, ambiguous: true };\n }\n function genSelector(el) {\n return genSelectorInfo(el).selector;\n }\n function buildStructuralSelector(el, maxParts, alwaysIndex) {\n // Local-root-scoped structural path: CLIMBING stops at a shadow root or\n // document boundary (parentElement is null for a shadow root's direct\n // children) \u2014 matches how deepQueryAll scopes each root's own\n // querySelectorAll call, so the path resolves back to the same element\n // when re-queried through deepQueryFirst. Sibling lookup for the\n // :nth-of-type qualifier uses parentNode rather than parentElement so a\n // shadow root's own top-level children (parentNode = the ShadowRoot,\n // which still exposes .children) get disambiguated too, even though we\n // do not climb past that boundary. With alwaysIndex, every level gets\n // an explicit :nth-of-type \u2014 maximum qualification for the retry pass.\n const parts = [];\n let cur = el;\n while (cur && cur.nodeType === 1 && cur !== document.body && parts.length < maxParts) {\n let part = cur.tagName.toLowerCase();\n const parentNode = cur.parentNode;\n if (parentNode && parentNode.children) {\n const sib = Array.from(parentNode.children).filter(s => s.tagName === cur.tagName);\n if (sib.length > 1 || alwaysIndex) {\n part += ':nth-of-type(' + (sib.indexOf(cur) + 1) + ')';\n }\n }\n parts.unshift(part);\n cur = cur.parentElement;\n }\n return parts.join(' > ');\n }\n function accessibleText(el) {\n const aria = el.getAttribute && el.getAttribute('aria-label');\n if (aria) return aria;\n const txt = (el.innerText || el.textContent || '').trim();\n if (txt) return txt;\n if ('value' in el && el.value) return String(el.value);\n const ph = el.getAttribute && el.getAttribute('placeholder');\n if (ph) return ph;\n const title = el.getAttribute && el.getAttribute('title');\n if (title) return title;\n return '';\n }\n function frameSelectorFor(el) {\n // Lightweight context field for snapshot/find entries: the selector of\n // the innermost same-origin <iframe> hosting el, or null when el lives\n // in the top document. Computed via genSelector so it benefits from the\n // same deep-uniqueness verification.\n if (el.ownerDocument === document) return null;\n const win = el.ownerDocument && el.ownerDocument.defaultView;\n let fe = null;\n try {\n fe = win && win.frameElement;\n } catch (_) {\n fe = null;\n }\n if (!fe) return null;\n return genSelector(fe);\n }\n";