@jobshimo/browser-link 0.21.0 → 0.23.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/README.md +5 -0
- package/dist/agent-instructions/content.js +11 -0
- package/dist/agent-instructions/content.js.map +1 -1
- package/dist/bridge/events.d.ts +1 -1
- package/dist/bridge/events.js +6 -1
- package/dist/bridge/events.js.map +1 -1
- package/dist/bridge/ipc-client.d.ts +6 -7
- package/dist/bridge/ipc-client.js +4 -3
- package/dist/bridge/ipc-client.js.map +1 -1
- package/dist/bridge/protocol.d.ts +28 -16
- package/dist/bridge/protocol.js +23 -7
- package/dist/bridge/protocol.js.map +1 -1
- package/dist/bridge/server.d.ts +8 -10
- package/dist/bridge/server.js.map +1 -1
- package/dist/bridge/ws-bridge.d.ts +53 -18
- package/dist/bridge/ws-bridge.js +129 -21
- package/dist/bridge/ws-bridge.js.map +1 -1
- package/dist/cdp/client.d.ts +35 -0
- package/dist/cdp/client.js +149 -0
- package/dist/cdp/client.js.map +1 -0
- package/dist/cdp/flow.d.ts +178 -0
- package/dist/cdp/flow.js +217 -0
- package/dist/cdp/flow.js.map +1 -0
- package/dist/cdp/gate.d.ts +31 -0
- package/dist/cdp/gate.js +35 -0
- package/dist/cdp/gate.js.map +1 -0
- package/dist/cdp/grant.d.ts +45 -0
- package/dist/cdp/grant.js +81 -0
- package/dist/cdp/grant.js.map +1 -0
- package/dist/cdp/inpage/builders.d.ts +165 -0
- package/dist/cdp/inpage/builders.js +501 -0
- package/dist/cdp/inpage/builders.js.map +1 -0
- package/dist/cdp/inpage/deep-query.d.ts +57 -0
- package/dist/cdp/inpage/deep-query.js +325 -0
- package/dist/cdp/inpage/deep-query.js.map +1 -0
- package/dist/cdp/inpage/dom-helpers.d.ts +27 -0
- package/dist/cdp/inpage/dom-helpers.js +147 -0
- package/dist/cdp/inpage/dom-helpers.js.map +1 -0
- package/dist/cdp/keymap.d.ts +83 -0
- package/dist/cdp/keymap.js +205 -0
- package/dist/cdp/keymap.js.map +1 -0
- package/dist/cdp/settle.d.ts +38 -0
- package/dist/cdp/settle.js +76 -0
- package/dist/cdp/settle.js.map +1 -0
- package/dist/cdp/support.d.ts +26 -0
- package/dist/cdp/support.js +58 -0
- package/dist/cdp/support.js.map +1 -0
- package/dist/cdp/targets.d.ts +54 -0
- package/dist/cdp/targets.js +176 -0
- package/dist/cdp/targets.js.map +1 -0
- package/dist/cdp/transport.d.ts +31 -0
- package/dist/cdp/transport.js +531 -0
- package/dist/cdp/transport.js.map +1 -0
- package/dist/cli.js +64 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/about.js +13 -0
- package/dist/commands/about.js.map +1 -1
- package/dist/commands/cdp.d.ts +28 -0
- package/dist/commands/cdp.js +172 -0
- package/dist/commands/cdp.js.map +1 -0
- package/dist/commands/config.d.ts +97 -0
- package/dist/commands/config.js +228 -13
- package/dist/commands/config.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.js +94 -1
- package/dist/config.js.map +1 -1
- package/dist/extension/background.js +424 -1
- package/dist/extension/background.js.map +1 -1
- package/dist/extension/flow-recording-policy.d.ts +47 -0
- package/dist/extension/flow-recording-policy.js +54 -0
- package/dist/extension/flow-recording-policy.js.map +1 -0
- package/dist/extension/inpage/recorder.d.ts +35 -0
- package/dist/extension/inpage/recorder.js +333 -0
- package/dist/extension/inpage/recorder.js.map +1 -0
- package/dist/extension/manifest.json +1 -1
- package/dist/extension/popup.html +56 -0
- package/dist/extension/popup.js +195 -4
- package/dist/extension/popup.js.map +1 -1
- package/dist/extension/recording.d.ts +158 -0
- package/dist/extension/recording.js +214 -0
- package/dist/extension/recording.js.map +1 -0
- package/dist/messages.d.ts +53 -12
- package/dist/server.js +9 -0
- package/dist/server.js.map +1 -1
- package/dist/tools/browser-definitions.js +4 -3
- package/dist/tools/browser-definitions.js.map +1 -1
- package/dist/tools/browser-dispatch.d.ts +31 -0
- package/dist/tools/browser-dispatch.js +88 -40
- package/dist/tools/browser-dispatch.js.map +1 -1
- package/dist/tools/server-instructions.js +19 -0
- package/dist/tools/server-instructions.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent copy of `packages/extension/src/inpage/builders.ts` — see
|
|
3
|
+
* `./deep-query.ts`'s header comment for why this is a verbatim duplicate
|
|
4
|
+
* rather than a cross-package import.
|
|
5
|
+
*
|
|
6
|
+
* DELIBERATELY A SUBSET: only the builders `cdp/transport.ts` needs for
|
|
7
|
+
* cdp-direct's v1 supported tool set (snapshot, find, click, type, press,
|
|
8
|
+
* state, plus the shared settle wait) are copied. `buildDragProbeJs` /
|
|
9
|
+
* `DragProbeOpts` are NOT copied — `browser.drag` is explicitly out of
|
|
10
|
+
* v1 scope for cdp-direct (see the README's cdp-direct tool-support table
|
|
11
|
+
* and `cdp/transport.ts`'s `CDP_TOOL_SUPPORT`). If drag is ever added to
|
|
12
|
+
* cdp-direct, copy `buildDragProbeJs` here at that time.
|
|
13
|
+
*
|
|
14
|
+
* Builders that assemble the CDP `Runtime.evaluate` expression strings for
|
|
15
|
+
* `snapshot`, `find`, `click`, `type`, `press` and the shared post-action
|
|
16
|
+
* `settle` wait. The exact same string the extension sends over CDP. Because
|
|
17
|
+
* this file is a SUBSET it is not byte-compared wholesale; instead the two
|
|
18
|
+
* security-critical pieces it interpolates — `DEEP_QUERY_JS` and
|
|
19
|
+
* `DOM_HELPERS_JS` — are asserted byte-identical to their extension siblings
|
|
20
|
+
* by `cdp/drift.test.ts`, and `cdp/inpage/builders.test.ts` proves this copy
|
|
21
|
+
* embeds exactly those shared helpers.
|
|
22
|
+
*/
|
|
23
|
+
import { DEEP_QUERY_JS } from './deep-query.js';
|
|
24
|
+
import { DOM_HELPERS_JS } from './dom-helpers.js';
|
|
25
|
+
export function buildSnapshotJs(opts = {}) {
|
|
26
|
+
const optsJson = JSON.stringify({
|
|
27
|
+
withinSelector: typeof opts.within_selector === 'string' ? opts.within_selector : null,
|
|
28
|
+
onlyInteractive: opts.only_interactive === true,
|
|
29
|
+
exclude: Array.isArray(opts.exclude) ? opts.exclude : [],
|
|
30
|
+
maxInteractive: typeof opts.max_interactive === 'number' && opts.max_interactive > 0
|
|
31
|
+
? Math.min(opts.max_interactive, 500)
|
|
32
|
+
: 120,
|
|
33
|
+
});
|
|
34
|
+
return `
|
|
35
|
+
(() => {
|
|
36
|
+
${DEEP_QUERY_JS}
|
|
37
|
+
${DOM_HELPERS_JS}
|
|
38
|
+
const opts = ${optsJson};
|
|
39
|
+
let root = document;
|
|
40
|
+
let notice = '';
|
|
41
|
+
if (opts.withinSelector) {
|
|
42
|
+
const sub = deepQueryFirst(opts.withinSelector);
|
|
43
|
+
if (!sub) {
|
|
44
|
+
return {
|
|
45
|
+
title: document.title,
|
|
46
|
+
url: location.href,
|
|
47
|
+
interactive: [],
|
|
48
|
+
notice: 'within_selector did not match any element',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
root = sub;
|
|
52
|
+
}
|
|
53
|
+
const excludeSet = new Set((opts.exclude || []).map(s => String(s).toLowerCase()));
|
|
54
|
+
function inExcludedLandmark(el) {
|
|
55
|
+
// Composed-tree climb (same pattern as composedContains): walking via
|
|
56
|
+
// parentElement alone would stop dead at a shadow boundary — a button
|
|
57
|
+
// inside a web component slotted into <nav> would escape exclusion.
|
|
58
|
+
// parentNode reaches the ShadowRoot; jumping to .host continues the
|
|
59
|
+
// climb in the host document.
|
|
60
|
+
if (excludeSet.size === 0) return false;
|
|
61
|
+
let cur = el.parentNode;
|
|
62
|
+
let guard = 0;
|
|
63
|
+
while (cur && guard < 1000) {
|
|
64
|
+
guard++;
|
|
65
|
+
if (cur === root) return false;
|
|
66
|
+
if (cur.nodeType === 1) {
|
|
67
|
+
const tag = cur.tagName ? cur.tagName.toLowerCase() : '';
|
|
68
|
+
if (excludeSet.has(tag)) return true;
|
|
69
|
+
if (cur === document.body) return false;
|
|
70
|
+
cur = cur.parentNode;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (cur.host) {
|
|
74
|
+
cur = cur.host;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const sel = 'a[href], button, input, select, textarea, [role=button], [role=link], [role=checkbox], [role=tab], [role=menuitem], [contenteditable=true]';
|
|
82
|
+
const interactive = [];
|
|
83
|
+
deepQueryAll(sel, root).forEach((el) => {
|
|
84
|
+
if (!isVisible(el)) return;
|
|
85
|
+
if (inExcludedLandmark(el)) return;
|
|
86
|
+
const tag = el.tagName.toLowerCase();
|
|
87
|
+
const role = el.getAttribute('role') || tag;
|
|
88
|
+
const selInfo = genSelectorInfo(el);
|
|
89
|
+
const entry = { tag, role, selector: selInfo.selector };
|
|
90
|
+
if (selInfo.ambiguous) entry.ambiguous = true;
|
|
91
|
+
const txt = shortText(el);
|
|
92
|
+
if (txt) entry.text = txt;
|
|
93
|
+
if ('value' in el && el.value) entry.value = String(el.value);
|
|
94
|
+
const placeholder = el.getAttribute('placeholder');
|
|
95
|
+
if (placeholder) entry.placeholder = placeholder;
|
|
96
|
+
const aria_label = el.getAttribute('aria-label');
|
|
97
|
+
if (aria_label) entry.aria_label = aria_label;
|
|
98
|
+
const name = el.getAttribute('name');
|
|
99
|
+
if (name) entry.name = name;
|
|
100
|
+
const type = el.getAttribute('type');
|
|
101
|
+
if (type) entry.type = type;
|
|
102
|
+
const href = el.getAttribute('href');
|
|
103
|
+
if (href) entry.href = href;
|
|
104
|
+
if ('disabled' in el && el.disabled) entry.disabled = true;
|
|
105
|
+
const frame = frameSelectorFor(el);
|
|
106
|
+
if (frame) entry.frame = frame;
|
|
107
|
+
interactive.push(entry);
|
|
108
|
+
});
|
|
109
|
+
const result = {
|
|
110
|
+
title: document.title,
|
|
111
|
+
url: location.href,
|
|
112
|
+
interactive: interactive.slice(0, opts.maxInteractive),
|
|
113
|
+
};
|
|
114
|
+
if (!opts.onlyInteractive) {
|
|
115
|
+
const headings = [];
|
|
116
|
+
deepQueryAll('h1, h2, h3', root).forEach((h) => {
|
|
117
|
+
if (!isVisible(h)) return;
|
|
118
|
+
if (inExcludedLandmark(h)) return;
|
|
119
|
+
const t = shortText(h);
|
|
120
|
+
if (t) headings.push({ level: h.tagName, text: t });
|
|
121
|
+
});
|
|
122
|
+
result.headings = headings.slice(0, 30);
|
|
123
|
+
const textRoot = (root === document) ? (document.body || document) : root;
|
|
124
|
+
const visibleText = (textRoot && textRoot.innerText) ? textRoot.innerText.slice(0, 4000) : '';
|
|
125
|
+
if (visibleText) result.text = visibleText;
|
|
126
|
+
}
|
|
127
|
+
if (notice) result.notice = notice;
|
|
128
|
+
return result;
|
|
129
|
+
})()
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
export function buildFindJs(opts) {
|
|
133
|
+
const optsJson = JSON.stringify({
|
|
134
|
+
text: opts.text,
|
|
135
|
+
role: typeof opts.role === 'string' && opts.role.length > 0 ? opts.role : null,
|
|
136
|
+
exact: opts.exact === true,
|
|
137
|
+
candidateLimit: 5,
|
|
138
|
+
nearMissLimit: 3,
|
|
139
|
+
});
|
|
140
|
+
return `
|
|
141
|
+
(() => {
|
|
142
|
+
${DEEP_QUERY_JS}
|
|
143
|
+
${DOM_HELPERS_JS}
|
|
144
|
+
const opts = ${optsJson};
|
|
145
|
+
const needle = opts.text.toLowerCase();
|
|
146
|
+
const needleTokens = needle.split(/[^a-z0-9]+/).filter(Boolean);
|
|
147
|
+
const ROLE_SELECTORS = {
|
|
148
|
+
button: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
|
|
149
|
+
link: 'a[href], [role="link"]',
|
|
150
|
+
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"]',
|
|
151
|
+
checkbox: 'input[type="checkbox"], [role="checkbox"]',
|
|
152
|
+
tab: '[role="tab"]',
|
|
153
|
+
menuitem: '[role="menuitem"]',
|
|
154
|
+
};
|
|
155
|
+
const BROAD_SELECTOR = 'button, a, input, textarea, select, [role], [onclick], [contenteditable="true"], [tabindex]';
|
|
156
|
+
const selectorSet = opts.role && ROLE_SELECTORS[opts.role]
|
|
157
|
+
? ROLE_SELECTORS[opts.role]
|
|
158
|
+
: BROAD_SELECTOR;
|
|
159
|
+
const all = deepQueryAll(selectorSet);
|
|
160
|
+
const matches = [];
|
|
161
|
+
for (const el of all) {
|
|
162
|
+
if (!isVisible(el)) continue;
|
|
163
|
+
const text = accessibleText(el).trim();
|
|
164
|
+
if (text.length === 0) continue;
|
|
165
|
+
const lower = text.toLowerCase();
|
|
166
|
+
const ok = opts.exact ? lower === needle : lower.includes(needle);
|
|
167
|
+
if (ok) matches.push(el);
|
|
168
|
+
}
|
|
169
|
+
if (matches.length === 0) {
|
|
170
|
+
// Near-miss ranking: containment beats token overlap; anything scoring
|
|
171
|
+
// 0 is dropped so the suggestion list never pads itself with noise.
|
|
172
|
+
function snippetOf(el) {
|
|
173
|
+
const t = accessibleText(el).trim();
|
|
174
|
+
return t.length > 60 ? t.slice(0, 60) + '...' : t;
|
|
175
|
+
}
|
|
176
|
+
function scoreText(text) {
|
|
177
|
+
const lower = text.toLowerCase();
|
|
178
|
+
const contains = lower.includes(needle);
|
|
179
|
+
const candTokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
|
|
180
|
+
let overlap = 0;
|
|
181
|
+
for (let i = 0; i < needleTokens.length; i++) {
|
|
182
|
+
if (candTokens.indexOf(needleTokens[i]) !== -1) overlap++;
|
|
183
|
+
}
|
|
184
|
+
if (!contains && overlap === 0) return -1;
|
|
185
|
+
return (contains ? 1000 : 0) + overlap * 10;
|
|
186
|
+
}
|
|
187
|
+
function rankCandidates(elements) {
|
|
188
|
+
const scored = [];
|
|
189
|
+
for (const el of elements) {
|
|
190
|
+
if (!isVisible(el)) continue;
|
|
191
|
+
const text = accessibleText(el).trim();
|
|
192
|
+
if (text.length === 0) continue;
|
|
193
|
+
const score = scoreText(text);
|
|
194
|
+
if (score < 0) continue;
|
|
195
|
+
scored.push({ el: el, score: score });
|
|
196
|
+
}
|
|
197
|
+
scored.sort((a, b) => b.score - a.score);
|
|
198
|
+
return scored.slice(0, opts.nearMissLimit).map((s) => s.el);
|
|
199
|
+
}
|
|
200
|
+
function toNearMiss(el) {
|
|
201
|
+
const entry = { text: snippetOf(el), selector: genSelector(el) };
|
|
202
|
+
const roleAttr = el.getAttribute('role');
|
|
203
|
+
if (roleAttr) entry.role = roleAttr;
|
|
204
|
+
return entry;
|
|
205
|
+
}
|
|
206
|
+
function describeForError(el) {
|
|
207
|
+
const tag = el.tagName.toLowerCase();
|
|
208
|
+
if (el.hasAttribute('onclick')) return '<' + tag + ' onclick>';
|
|
209
|
+
const role = el.getAttribute('role');
|
|
210
|
+
if (role) return '<' + tag + ' role="' + role + '">';
|
|
211
|
+
return '<' + tag + '>';
|
|
212
|
+
}
|
|
213
|
+
const result = { matched: false, reason: 'not-found' };
|
|
214
|
+
if (opts.role) {
|
|
215
|
+
// Role narrowed the scan — check whether the text exists OUTSIDE the
|
|
216
|
+
// role filter so the error can name the exclusion explicitly instead
|
|
217
|
+
// of reporting a bare not-found.
|
|
218
|
+
const broadAll = deepQueryAll(BROAD_SELECTOR);
|
|
219
|
+
const broadTextMatches = [];
|
|
220
|
+
for (const el of broadAll) {
|
|
221
|
+
if (!isVisible(el)) continue;
|
|
222
|
+
const text = accessibleText(el).trim();
|
|
223
|
+
if (text.length === 0) continue;
|
|
224
|
+
if (text.toLowerCase().includes(needle)) broadTextMatches.push(el);
|
|
225
|
+
}
|
|
226
|
+
if (broadTextMatches.length > 0) {
|
|
227
|
+
const ranked = rankCandidates(broadTextMatches);
|
|
228
|
+
const closest = ranked[0];
|
|
229
|
+
result.error = 'text matched ' + broadTextMatches.length + ' element' +
|
|
230
|
+
(broadTextMatches.length === 1 ? '' : 's') +
|
|
231
|
+
' but none with role "' + opts.role + '"' +
|
|
232
|
+
(closest ? ' — closest: ' + describeForError(closest) + ' "' + snippetOf(closest) + '"' : '');
|
|
233
|
+
result.near_misses = ranked.map(toNearMiss);
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
const ranked = rankCandidates(broadAll);
|
|
237
|
+
if (ranked.length > 0) result.near_misses = ranked.map(toNearMiss);
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
const ranked = rankCandidates(all);
|
|
241
|
+
if (ranked.length > 0) result.near_misses = ranked.map(toNearMiss);
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
if (matches.length > 1) {
|
|
245
|
+
return {
|
|
246
|
+
matched: false,
|
|
247
|
+
reason: 'multiple-matches',
|
|
248
|
+
candidates: matches.slice(0, opts.candidateLimit).map((el) => ({
|
|
249
|
+
selector: genSelector(el),
|
|
250
|
+
text: shortText(el),
|
|
251
|
+
tag: el.tagName.toLowerCase(),
|
|
252
|
+
})),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const el = matches[0];
|
|
256
|
+
const center = viewportCenterOf(el);
|
|
257
|
+
const selInfo = genSelectorInfo(el);
|
|
258
|
+
const result = {
|
|
259
|
+
matched: true,
|
|
260
|
+
selector: selInfo.selector,
|
|
261
|
+
coords: {
|
|
262
|
+
x: Math.round(center.x),
|
|
263
|
+
y: Math.round(center.y),
|
|
264
|
+
},
|
|
265
|
+
tag: el.tagName.toLowerCase(),
|
|
266
|
+
text: shortText(el),
|
|
267
|
+
};
|
|
268
|
+
if (selInfo.ambiguous) result.ambiguous = true;
|
|
269
|
+
const frame = frameSelectorFor(el);
|
|
270
|
+
if (frame) result.frame = frame;
|
|
271
|
+
return result;
|
|
272
|
+
})()
|
|
273
|
+
`;
|
|
274
|
+
}
|
|
275
|
+
export function buildClickResolveJs(opts) {
|
|
276
|
+
const optsJson = JSON.stringify({ selector: opts.selector, force: opts.force });
|
|
277
|
+
return `
|
|
278
|
+
(() => {
|
|
279
|
+
${DEEP_QUERY_JS}
|
|
280
|
+
const opts = ${optsJson};
|
|
281
|
+
try {
|
|
282
|
+
document.querySelector(opts.selector);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
return { ok: false, reason: 'invalid-selector', error: e && e.message ? e.message : String(e) };
|
|
285
|
+
}
|
|
286
|
+
const el = deepQueryFirst(opts.selector);
|
|
287
|
+
if (!el) return { ok: false, reason: 'not-found' };
|
|
288
|
+
const chain = frameElementChain(el);
|
|
289
|
+
for (const fe of chain) {
|
|
290
|
+
if (fe.scrollIntoView) fe.scrollIntoView({ block: 'center', inline: 'center' });
|
|
291
|
+
}
|
|
292
|
+
if (el.scrollIntoView) el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
293
|
+
const center = viewportCenterOf(el);
|
|
294
|
+
const tag = el.tagName.toLowerCase();
|
|
295
|
+
if (!opts.force) {
|
|
296
|
+
const occlusion = checkOcclusion(el, center.localX, center.localY);
|
|
297
|
+
if (!occlusion.allowed) {
|
|
298
|
+
return { ok: false, reason: 'occluded', blocker: occlusion.blocker };
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { ok: true, x: center.x, y: center.y, tag };
|
|
302
|
+
})()
|
|
303
|
+
`;
|
|
304
|
+
}
|
|
305
|
+
export function buildTypeResolveJs(opts) {
|
|
306
|
+
const optsJson = JSON.stringify({ selector: opts.selector });
|
|
307
|
+
return `
|
|
308
|
+
(() => {
|
|
309
|
+
${DEEP_QUERY_JS}
|
|
310
|
+
const opts = ${optsJson};
|
|
311
|
+
try {
|
|
312
|
+
document.querySelector(opts.selector);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return { ok: false, reason: 'invalid-selector', error: e && e.message ? e.message : String(e) };
|
|
315
|
+
}
|
|
316
|
+
const el = deepQueryFirst(opts.selector);
|
|
317
|
+
if (!el) return { ok: false, reason: 'not-found' };
|
|
318
|
+
el.focus();
|
|
319
|
+
${opts.clear ? "if ('value' in el) { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }" : ''}
|
|
320
|
+
return { ok: true };
|
|
321
|
+
})()`;
|
|
322
|
+
}
|
|
323
|
+
export function buildFocusJs(opts) {
|
|
324
|
+
const optsJson = JSON.stringify({ selector: opts.selector });
|
|
325
|
+
return `
|
|
326
|
+
(() => {
|
|
327
|
+
${DEEP_QUERY_JS}
|
|
328
|
+
const opts = ${optsJson};
|
|
329
|
+
const el = deepQueryFirst(opts.selector);
|
|
330
|
+
if (!el) return false;
|
|
331
|
+
el.focus();
|
|
332
|
+
return true;
|
|
333
|
+
})()`;
|
|
334
|
+
}
|
|
335
|
+
export function buildSettleJs(opts) {
|
|
336
|
+
const optsJson = JSON.stringify({
|
|
337
|
+
settleMs: Math.max(0, opts.settle_ms),
|
|
338
|
+
timeoutMs: Math.max(0, opts.settle_timeout_ms),
|
|
339
|
+
});
|
|
340
|
+
return `
|
|
341
|
+
(() => {
|
|
342
|
+
const opts = ${optsJson};
|
|
343
|
+
const startUrl = location.href;
|
|
344
|
+
const startActiveEl = document.activeElement;
|
|
345
|
+
const state = { mutationCount: 0, lastMutationAt: Date.now() };
|
|
346
|
+
const observer = new MutationObserver((mutations) => {
|
|
347
|
+
state.mutationCount += mutations.length;
|
|
348
|
+
state.lastMutationAt = Date.now();
|
|
349
|
+
});
|
|
350
|
+
observer.observe(document, {
|
|
351
|
+
subtree: true,
|
|
352
|
+
childList: true,
|
|
353
|
+
attributes: true,
|
|
354
|
+
characterData: true,
|
|
355
|
+
});
|
|
356
|
+
const start = Date.now();
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
function finish(settled) {
|
|
359
|
+
observer.disconnect();
|
|
360
|
+
const result = {
|
|
361
|
+
settled: settled,
|
|
362
|
+
duration_ms: Date.now() - start,
|
|
363
|
+
mutation_count: state.mutationCount,
|
|
364
|
+
};
|
|
365
|
+
if (location.href !== startUrl) result.url_changed = location.href;
|
|
366
|
+
if (document.activeElement !== startActiveEl) result.focus_moved = true;
|
|
367
|
+
resolve(result);
|
|
368
|
+
}
|
|
369
|
+
if (opts.settleMs <= 0) {
|
|
370
|
+
finish(true);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
(function check() {
|
|
374
|
+
const now = Date.now();
|
|
375
|
+
const quietFor = now - state.lastMutationAt;
|
|
376
|
+
if (quietFor >= opts.settleMs) {
|
|
377
|
+
finish(true);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (now - start >= opts.timeoutMs) {
|
|
381
|
+
finish(false);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
setTimeout(check, Math.min(opts.settleMs - quietFor, 50));
|
|
385
|
+
})();
|
|
386
|
+
});
|
|
387
|
+
})()`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Build the `browser.state` expression: a compact orientation snapshot —
|
|
391
|
+
* current url/title, the deep-resolved focused element, visible dialog-role
|
|
392
|
+
* elements, scroll position, and viewport size. Cheaper than a full
|
|
393
|
+
* `browser.snapshot` when the agent only needs "where am I right now".
|
|
394
|
+
*
|
|
395
|
+
* `focused` descends through `document.activeElement` past shadow-root and
|
|
396
|
+
* same-origin-iframe boundaries to the real innermost focused element (a
|
|
397
|
+
* shadow host or an <iframe> being "active" at one level is not the actual
|
|
398
|
+
* focus target the agent cares about). Its `selector` goes through the same
|
|
399
|
+
* `genSelectorInfo` uniqueness check `snapshot`/`find` use, so it carries
|
|
400
|
+
* `ambiguous: true` under the same structurally-identical-twins condition.
|
|
401
|
+
*
|
|
402
|
+
* `dialogs` matches visible `[role=dialog]`, `[role=alertdialog]` and open
|
|
403
|
+
* `<dialog>` elements found via the same deep walk, with a best-effort
|
|
404
|
+
* `label` resolved from aria-label / aria-labelledby / the first heading
|
|
405
|
+
* inside the dialog.
|
|
406
|
+
*
|
|
407
|
+
* Every optional field is omitted when there is nothing to report: no
|
|
408
|
+
* `focused` beyond `<body>`, no `dialogs` when none are open, and no
|
|
409
|
+
* `scroll` when the page is at its default (0,0) position — token-lean by
|
|
410
|
+
* construction, matching the omit-falsy convention the other builders use.
|
|
411
|
+
*/
|
|
412
|
+
export function buildStateJs() {
|
|
413
|
+
return `
|
|
414
|
+
(() => {
|
|
415
|
+
${DEEP_QUERY_JS}
|
|
416
|
+
${DOM_HELPERS_JS}
|
|
417
|
+
function deepActiveElement() {
|
|
418
|
+
let el = document.activeElement;
|
|
419
|
+
let guard = 0;
|
|
420
|
+
while (el && guard < 20) {
|
|
421
|
+
guard++;
|
|
422
|
+
if (el.shadowRoot && el.shadowRoot.activeElement) {
|
|
423
|
+
el = el.shadowRoot.activeElement;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (el.tagName === 'IFRAME') {
|
|
427
|
+
let innerDoc = null;
|
|
428
|
+
try {
|
|
429
|
+
innerDoc = el.contentDocument;
|
|
430
|
+
} catch (_) {
|
|
431
|
+
innerDoc = null;
|
|
432
|
+
}
|
|
433
|
+
if (innerDoc && innerDoc.activeElement && innerDoc.activeElement !== innerDoc.body) {
|
|
434
|
+
el = innerDoc.activeElement;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
return el;
|
|
441
|
+
}
|
|
442
|
+
function dialogLabel(el) {
|
|
443
|
+
const aria = el.getAttribute('aria-label');
|
|
444
|
+
if (aria) return aria;
|
|
445
|
+
const labelledby = el.getAttribute('aria-labelledby');
|
|
446
|
+
if (labelledby) {
|
|
447
|
+
const ids = labelledby.split(/\\s+/);
|
|
448
|
+
const parts = [];
|
|
449
|
+
for (let i = 0; i < ids.length; i++) {
|
|
450
|
+
if (!ids[i]) continue;
|
|
451
|
+
const target = deepQueryFirst('#' + CSS.escape(ids[i]));
|
|
452
|
+
if (target) {
|
|
453
|
+
const t = shortText(target);
|
|
454
|
+
if (t) parts.push(t);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const joined = parts.join(' ').trim();
|
|
458
|
+
if (joined) return joined;
|
|
459
|
+
}
|
|
460
|
+
const heading = deepQueryFirst('h1, h2, h3', el);
|
|
461
|
+
if (heading) {
|
|
462
|
+
const t = shortText(heading);
|
|
463
|
+
if (t) return t;
|
|
464
|
+
}
|
|
465
|
+
return '';
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const result = {
|
|
469
|
+
url: location.href,
|
|
470
|
+
title: document.title,
|
|
471
|
+
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
const active = deepActiveElement();
|
|
475
|
+
if (active && active !== document.body && active.tagName) {
|
|
476
|
+
const selInfo = genSelectorInfo(active);
|
|
477
|
+
const focused = { selector: selInfo.selector, tag: active.tagName.toLowerCase() };
|
|
478
|
+
if (selInfo.ambiguous) focused.ambiguous = true;
|
|
479
|
+
result.focused = focused;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const dialogs = [];
|
|
483
|
+
deepQueryAll('[role="dialog"], [role="alertdialog"], dialog[open]').forEach((el) => {
|
|
484
|
+
if (!isVisible(el)) return;
|
|
485
|
+
const role = el.getAttribute('role') || 'dialog';
|
|
486
|
+
const entry = { selector: genSelector(el), role: role };
|
|
487
|
+
const label = dialogLabel(el);
|
|
488
|
+
if (label) entry.label = label;
|
|
489
|
+
dialogs.push(entry);
|
|
490
|
+
});
|
|
491
|
+
if (dialogs.length > 0) result.dialogs = dialogs;
|
|
492
|
+
|
|
493
|
+
const scrollX = Math.round(window.scrollX);
|
|
494
|
+
const scrollY = Math.round(window.scrollY);
|
|
495
|
+
if (scrollX !== 0 || scrollY !== 0) result.scroll = { x: scrollX, y: scrollY };
|
|
496
|
+
|
|
497
|
+
return result;
|
|
498
|
+
})()
|
|
499
|
+
`;
|
|
500
|
+
}
|
|
501
|
+
//# sourceMappingURL=builders.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builders.js","sourceRoot":"","sources":["../../../src/cdp/inpage/builders.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Independent copy of `packages/extension/src/inpage/deep-query.ts` — used
|
|
3
|
+
* by the cdp-direct transport (`../transport.ts`) to build the exact same
|
|
4
|
+
* `Runtime.evaluate` expressions the extension's `background.ts` sends,
|
|
5
|
+
* over a raw CDP-over-WS connection instead of `chrome.debugger`.
|
|
6
|
+
*
|
|
7
|
+
* Duplicated rather than imported for the same reason `config.ts` and
|
|
8
|
+
* `messages.ts` keep independent copies of extension-side constants/types:
|
|
9
|
+
* `@jobshimo/browser-link` (this package) publishes to npm standalone and
|
|
10
|
+
* must not carry a workspace dependency on `@browser-link/extension` (a
|
|
11
|
+
* private, unpublished package with `chrome.*` types in its own tsconfig).
|
|
12
|
+
* This file is copied VERBATIM — it has zero `chrome.*` references (it only
|
|
13
|
+
* builds a JS-source string that runs inside the TARGET PAGE, never the
|
|
14
|
+
* extension/server process), so there is nothing to adapt. If the source
|
|
15
|
+
* file changes, re-copy it here — the exported `DEEP_QUERY_JS` body is
|
|
16
|
+
* asserted byte-identical to the sibling at
|
|
17
|
+
* `packages/extension/src/inpage/deep-query.ts` by `cdp/drift.test.ts`,
|
|
18
|
+
* which fails the build if the two ever diverge (a security fix to one copy
|
|
19
|
+
* must reach the other).
|
|
20
|
+
*
|
|
21
|
+
* In-page JS source injected into CDP `Runtime.evaluate` expressions. This
|
|
22
|
+
* is the "deep search" layer that lets `snapshot`, `find`, `click` and
|
|
23
|
+
* `type` see past two boundaries plain `document.querySelector` /
|
|
24
|
+
* `document.querySelectorAll` cannot cross:
|
|
25
|
+
*
|
|
26
|
+
* - OPEN Shadow DOM roots (`element.attachShadow({ mode: 'open' })`),
|
|
27
|
+
* walked recursively (shadow roots nested inside shadow roots).
|
|
28
|
+
* - Same-origin `<iframe>` documents, walked recursively (iframes nested
|
|
29
|
+
* inside iframes, shadow roots nested inside iframes and vice versa).
|
|
30
|
+
*
|
|
31
|
+
* Exported as a raw string, not real TypeScript — it runs inside the
|
|
32
|
+
* TARGET PAGE's JS context via CDP `Runtime.evaluate`, not inside this
|
|
33
|
+
* process. Keep the syntax plain (`var`/`function`, no optional chaining
|
|
34
|
+
* assumptions) since it must run unmodified in whatever JS engine the
|
|
35
|
+
* connected tab uses.
|
|
36
|
+
*
|
|
37
|
+
* Known limitations (also documented on the MCP tool descriptions):
|
|
38
|
+
* - CLOSED shadow roots (`{ mode: 'closed' }`) are unreachable from page
|
|
39
|
+
* world JS — `element.shadowRoot` is `null` for them by design. There
|
|
40
|
+
* is no CDP-level workaround from `Runtime.evaluate`.
|
|
41
|
+
* - CROSS-ORIGIN iframes are unreachable — `contentDocument` throws or
|
|
42
|
+
* returns null under the same-origin policy. Access is wrapped in
|
|
43
|
+
* try/catch so a cross-origin frame is silently skipped rather than
|
|
44
|
+
* failing the whole query.
|
|
45
|
+
* - A plain CSS selector cannot distinguish between two elements that
|
|
46
|
+
* produce byte-identical generated selectors in two different roots
|
|
47
|
+
* (e.g. two structurally-identical Shadow DOM component instances).
|
|
48
|
+
* `deepQueryFirst` returns the first match in traversal order
|
|
49
|
+
* (the start root itself, then nested shadow roots, then nested
|
|
50
|
+
* same-origin iframe documents, depth-first). Extremely rare in
|
|
51
|
+
* practice — real content differs — but worth knowing when a page
|
|
52
|
+
* repeats the exact same component markup in multiple places. When
|
|
53
|
+
* selector generation cannot avoid this (see `genSelectorInfo` in
|
|
54
|
+
* `dom-helpers.ts`), snapshot/find mark the affected entry with
|
|
55
|
+
* `ambiguous: true` so agents know not to cache that selector.
|
|
56
|
+
*/
|
|
57
|
+
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";
|