@btraut/browser-bridge 0.13.2 → 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.
@@ -1,8 +1,436 @@
1
1
  "use strict";
2
2
  (() => {
3
+ // packages/extension/src/popup-trigger-state.ts
4
+ var normalizeAttr = (value) => {
5
+ if (typeof value !== "string") {
6
+ return void 0;
7
+ }
8
+ const trimmed = value.trim();
9
+ return trimmed.length > 0 ? trimmed : void 0;
10
+ };
11
+ var readPopupTriggerState = (target) => {
12
+ if (!(target instanceof HTMLElement)) {
13
+ return null;
14
+ }
15
+ const ariaHasPopup = normalizeAttr(target.getAttribute("aria-haspopup"));
16
+ const ariaExpanded = normalizeAttr(target.getAttribute("aria-expanded"));
17
+ const dataState = normalizeAttr(target.getAttribute("data-state"));
18
+ const open = "open" in target && typeof target.open === "boolean" ? target.open ?? false : void 0;
19
+ const qualifiesAsPopupTrigger = Boolean(ariaHasPopup) || ariaExpanded !== void 0 || open !== void 0;
20
+ if (!qualifiesAsPopupTrigger && dataState === void 0) {
21
+ return null;
22
+ }
23
+ if (!qualifiesAsPopupTrigger) {
24
+ return null;
25
+ }
26
+ return {
27
+ kind: "popup_trigger",
28
+ ariaHasPopup,
29
+ ariaExpanded,
30
+ dataState,
31
+ open
32
+ };
33
+ };
34
+ var popupTriggerStateChanged = (before, after) => {
35
+ if (!before || !after) {
36
+ return before !== after;
37
+ }
38
+ return before.ariaExpanded !== after.ariaExpanded || before.dataState !== after.dataState || before.open !== after.open;
39
+ };
40
+
41
+ // packages/extension/src/click-strategies/index.ts
42
+ var selectClickStrategy = (target) => {
43
+ const popupTriggerState = readPopupTriggerState(target);
44
+ if (popupTriggerState) {
45
+ return {
46
+ kind: "popup_trigger",
47
+ state: popupTriggerState
48
+ };
49
+ }
50
+ return { kind: "generic" };
51
+ };
52
+
53
+ // packages/extension/src/click-strategies/generic-click.ts
54
+ var executeGenericClick = (options) => {
55
+ const { target, clickCount } = options;
56
+ window.setTimeout(() => {
57
+ try {
58
+ if (target instanceof HTMLElement) {
59
+ try {
60
+ target.focus({ preventScroll: true });
61
+ } catch {
62
+ target.focus();
63
+ }
64
+ }
65
+ for (let i = 0; i < clickCount; i += 1) {
66
+ target.click();
67
+ }
68
+ } catch {
69
+ }
70
+ }, 0);
71
+ return { ok: true };
72
+ };
73
+
74
+ // packages/extension/src/click-strategies/popup-trigger-click.ts
75
+ var executePopupTriggerClick = async (options) => {
76
+ const popupTarget = options.target;
77
+ try {
78
+ popupTarget.focus({ preventScroll: true });
79
+ } catch {
80
+ popupTarget.focus();
81
+ }
82
+ for (let i = 0; i < options.clickCount; i += 1) {
83
+ popupTarget.click();
84
+ }
85
+ await options.sleep(options.settleMs ?? 50);
86
+ if (window.location.href !== options.locationBefore || !options.target.isConnected) {
87
+ return { ok: true };
88
+ }
89
+ const popupTriggerAfter = readPopupTriggerState(options.target);
90
+ if (!popupTriggerStateChanged(options.beforeState, popupTriggerAfter ?? null)) {
91
+ return {
92
+ ok: false,
93
+ error: {
94
+ code: "FAILED_PRECONDITION",
95
+ message: "Click focused the popup trigger but did not change its open state.",
96
+ retryable: false,
97
+ details: {
98
+ reason: "click_state_unchanged",
99
+ control: options.beforeState.kind,
100
+ aria_haspopup: options.beforeState.ariaHasPopup,
101
+ aria_expanded_before: options.beforeState.ariaExpanded,
102
+ aria_expanded_after: popupTriggerAfter?.ariaExpanded,
103
+ data_state_before: options.beforeState.dataState,
104
+ data_state_after: popupTriggerAfter?.dataState
105
+ }
106
+ }
107
+ };
108
+ }
109
+ return { ok: true };
110
+ };
111
+
112
+ // packages/extension/src/locator-point.ts
113
+ var pointHitsTarget = (target, x, y, options) => {
114
+ if (typeof document.elementFromPoint !== "function") {
115
+ return true;
116
+ }
117
+ const hit = document.elementFromPoint(x, y);
118
+ if (!hit) {
119
+ return false;
120
+ }
121
+ if (options?.directOnly) {
122
+ return target === hit;
123
+ }
124
+ return target === hit || target.contains(hit);
125
+ };
126
+ var getHittablePoint = (target, options) => {
127
+ const rect = target.getBoundingClientRect();
128
+ const insetX = Math.min(Math.max(rect.width * 0.25, 1), rect.width / 2);
129
+ const insetY = Math.min(Math.max(rect.height * 0.25, 1), rect.height / 2);
130
+ const candidatePoints = [
131
+ { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 },
132
+ { x: rect.left + insetX, y: rect.top + insetY },
133
+ { x: rect.right - insetX, y: rect.top + insetY },
134
+ { x: rect.left + insetX, y: rect.bottom - insetY },
135
+ { x: rect.right - insetX, y: rect.bottom - insetY },
136
+ { x: rect.left + rect.width / 2, y: rect.top + insetY },
137
+ { x: rect.left + rect.width / 2, y: rect.bottom - insetY },
138
+ { x: rect.left + insetX, y: rect.top + rect.height / 2 },
139
+ { x: rect.right - insetX, y: rect.top + rect.height / 2 }
140
+ ];
141
+ if (options?.preferDirectHit) {
142
+ for (const point of candidatePoints) {
143
+ if (pointHitsTarget(target, point.x, point.y, { directOnly: true })) {
144
+ return point;
145
+ }
146
+ }
147
+ }
148
+ for (const point of candidatePoints) {
149
+ if (pointHitsTarget(target, point.x, point.y)) {
150
+ return point;
151
+ }
152
+ }
153
+ return candidatePoints[0] ?? { x: rect.left, y: rect.top };
154
+ };
155
+
156
+ // packages/extension/src/locator-ranking.ts
157
+ var collectVisibleText = (node, isVisible2) => {
158
+ if (node.nodeType === Node.TEXT_NODE) {
159
+ return node.textContent || "";
160
+ }
161
+ if (!(node instanceof Element)) {
162
+ return "";
163
+ }
164
+ if (!isVisible2(node)) {
165
+ return "";
166
+ }
167
+ if (node instanceof HTMLElement) {
168
+ const { innerText } = node;
169
+ if (typeof innerText === "string" && innerText.length > 0) {
170
+ return innerText;
171
+ }
172
+ }
173
+ return Array.from(node.childNodes).map((child) => collectVisibleText(child, isVisible2)).join("");
174
+ };
175
+ var normalizeText = (value) => value.replace(/\s+/g, " ").trim();
176
+ var isClickable = (element) => element instanceof HTMLElement && element.matches(
177
+ 'a,button,input,textarea,select,summary,label,[role="button"],[tabindex]'
178
+ );
179
+ var getNodeDepth = (element) => {
180
+ let depth = 0;
181
+ let current = element;
182
+ while (current) {
183
+ depth += 1;
184
+ current = current.parentElement;
185
+ }
186
+ return depth;
187
+ };
188
+ var isVisible = (element) => {
189
+ if (!(element instanceof HTMLElement)) {
190
+ return false;
191
+ }
192
+ const style = window.getComputedStyle(element);
193
+ if (style.visibility === "hidden" || style.display === "none") {
194
+ return false;
195
+ }
196
+ const rect = element.getBoundingClientRect();
197
+ if (rect.width === 0 && rect.height === 0) {
198
+ return false;
199
+ }
200
+ if (element.offsetWidth === 0 && element.offsetHeight === 0 && element.getClientRects().length === 0) {
201
+ return false;
202
+ }
203
+ let current = element;
204
+ while (current) {
205
+ const currentStyle = window.getComputedStyle(current);
206
+ if (currentStyle.display === "none") {
207
+ return false;
208
+ }
209
+ if (currentStyle.visibility === "hidden" || currentStyle.visibility === "collapse") {
210
+ return false;
211
+ }
212
+ const opacity = Number.parseFloat(currentStyle.opacity ?? "1");
213
+ if (Number.isFinite(opacity) && opacity <= 0) {
214
+ return false;
215
+ }
216
+ current = current.parentElement;
217
+ }
218
+ return true;
219
+ };
220
+ var isOnScreen = (element) => {
221
+ if (!(element instanceof HTMLElement) || !isVisible(element)) {
222
+ return false;
223
+ }
224
+ const rect = element.getBoundingClientRect();
225
+ return rect.right > 0 && rect.bottom > 0 && rect.left < window.innerWidth && rect.top < window.innerHeight;
226
+ };
227
+ var getRenderedText = (element) => normalizeText(collectVisibleText(element, isVisible));
228
+ var getRoleAccessibleName = (element) => normalizeText(
229
+ element.getAttribute("aria-label") ?? element.getAttribute("title") ?? getRenderedText(element)
230
+ );
231
+ var getActionabilityScore = (element) => {
232
+ if (!(element instanceof HTMLElement) || !isVisible(element)) {
233
+ return { directHit: false, hittable: false };
234
+ }
235
+ const point = getHittablePoint(element, { preferDirectHit: true });
236
+ return {
237
+ directHit: pointHitsTarget(element, point.x, point.y, { directOnly: true }),
238
+ hittable: pointHitsTarget(element, point.x, point.y)
239
+ };
240
+ };
241
+ var scoreCandidates = (candidates, options) => {
242
+ const queryText = options?.exactText ? normalizeText(options.exactText) : "";
243
+ const queryHref = options?.exactHref ?? "";
244
+ if (candidates.length === 0) {
245
+ return null;
246
+ }
247
+ const scored = candidates.filter(isVisible).map((candidate) => {
248
+ const text = getRenderedText(candidate);
249
+ const accessibleName = getRoleAccessibleName(candidate);
250
+ const href = candidate.getAttribute("href") ?? "";
251
+ const actionability = getActionabilityScore(candidate);
252
+ return {
253
+ candidate,
254
+ exactText: queryText.length > 0 && (text === queryText || accessibleName === queryText),
255
+ exactHref: queryHref.length > 0 && (href === queryHref || candidate instanceof HTMLAnchorElement && candidate.href === queryHref),
256
+ directHit: actionability.directHit,
257
+ hittable: actionability.hittable,
258
+ onScreen: isOnScreen(candidate),
259
+ clickable: isClickable(candidate),
260
+ textLength: text.length,
261
+ depth: getNodeDepth(candidate)
262
+ };
263
+ }).sort((a, b) => {
264
+ if (a.exactHref !== b.exactHref) {
265
+ return a.exactHref ? -1 : 1;
266
+ }
267
+ if (a.exactText !== b.exactText) {
268
+ return a.exactText ? -1 : 1;
269
+ }
270
+ if (a.directHit !== b.directHit) {
271
+ return a.directHit ? -1 : 1;
272
+ }
273
+ if (a.hittable !== b.hittable) {
274
+ return a.hittable ? -1 : 1;
275
+ }
276
+ if (a.onScreen !== b.onScreen) {
277
+ return a.onScreen ? -1 : 1;
278
+ }
279
+ if (a.clickable !== b.clickable) {
280
+ return a.clickable ? -1 : 1;
281
+ }
282
+ if (a.textLength !== b.textLength) {
283
+ return a.textLength - b.textLength;
284
+ }
285
+ return b.depth - a.depth;
286
+ });
287
+ return scored[0]?.candidate ?? candidates[0] ?? null;
288
+ };
289
+ var findByText = (text) => {
290
+ const query = normalizeText(text);
291
+ if (query.length === 0) {
292
+ return null;
293
+ }
294
+ const candidateMap = /* @__PURE__ */ new Map();
295
+ const tree = document.createTreeWalker(
296
+ document.body,
297
+ NodeFilter.SHOW_ELEMENT
298
+ );
299
+ let node = tree.nextNode();
300
+ while (node) {
301
+ const element = node;
302
+ if (!isVisible(element)) {
303
+ node = tree.nextNode();
304
+ continue;
305
+ }
306
+ const elementText = getRenderedText(element);
307
+ if (!elementText.includes(query)) {
308
+ node = tree.nextNode();
309
+ continue;
310
+ }
311
+ let preferredTarget = element;
312
+ let current = element;
313
+ while (current) {
314
+ if (current !== element && isVisible(current) && isClickable(current) && getRenderedText(current).includes(query)) {
315
+ preferredTarget = current;
316
+ break;
317
+ }
318
+ current = current.parentElement;
319
+ }
320
+ const preferredText = getRenderedText(preferredTarget);
321
+ const currentBest = candidateMap.get(preferredTarget);
322
+ const actionability = getActionabilityScore(preferredTarget);
323
+ const nextScore = {
324
+ exact: preferredText === query || elementText === query,
325
+ directHit: actionability.directHit,
326
+ hittable: actionability.hittable,
327
+ onScreen: isOnScreen(preferredTarget),
328
+ clickable: isClickable(preferredTarget),
329
+ textLength: preferredText.length || elementText.length,
330
+ depth: getNodeDepth(preferredTarget)
331
+ };
332
+ if (!currentBest || Number(nextScore.exact) > Number(currentBest.exact) || nextScore.exact === currentBest.exact && Number(nextScore.directHit) > Number(currentBest.directHit) || nextScore.exact === currentBest.exact && nextScore.directHit === currentBest.directHit && Number(nextScore.hittable) > Number(currentBest.hittable) || nextScore.exact === currentBest.exact && nextScore.directHit === currentBest.directHit && nextScore.hittable === currentBest.hittable && Number(nextScore.onScreen) > Number(currentBest.onScreen) || nextScore.exact === currentBest.exact && nextScore.directHit === currentBest.directHit && nextScore.hittable === currentBest.hittable && nextScore.onScreen === currentBest.onScreen && Number(nextScore.clickable) > Number(currentBest.clickable) || nextScore.exact === currentBest.exact && nextScore.directHit === currentBest.directHit && nextScore.hittable === currentBest.hittable && nextScore.onScreen === currentBest.onScreen && nextScore.clickable === currentBest.clickable && nextScore.textLength < currentBest.textLength || nextScore.exact === currentBest.exact && nextScore.directHit === currentBest.directHit && nextScore.hittable === currentBest.hittable && nextScore.onScreen === currentBest.onScreen && nextScore.clickable === currentBest.clickable && nextScore.textLength === currentBest.textLength && nextScore.depth > currentBest.depth) {
333
+ candidateMap.set(preferredTarget, nextScore);
334
+ }
335
+ node = tree.nextNode();
336
+ }
337
+ const candidates = Array.from(candidateMap.entries()).sort((a, b) => {
338
+ const [, left] = a;
339
+ const [, right] = b;
340
+ if (left.exact !== right.exact) {
341
+ return left.exact ? -1 : 1;
342
+ }
343
+ if (left.directHit !== right.directHit) {
344
+ return left.directHit ? -1 : 1;
345
+ }
346
+ if (left.hittable !== right.hittable) {
347
+ return left.hittable ? -1 : 1;
348
+ }
349
+ if (left.onScreen !== right.onScreen) {
350
+ return left.onScreen ? -1 : 1;
351
+ }
352
+ if (left.clickable !== right.clickable) {
353
+ return left.clickable ? -1 : 1;
354
+ }
355
+ if (left.textLength !== right.textLength) {
356
+ return left.textLength - right.textLength;
357
+ }
358
+ return right.depth - left.depth;
359
+ });
360
+ return candidates[0]?.[0] ?? null;
361
+ };
362
+
363
+ // packages/extension/src/snapshot-ref-recovery.ts
364
+ var SNAPSHOT_REF_REGISTRY_ID = "__bb_snapshot_ref_registry__";
365
+ var readSnapshotRefRegistry = () => {
366
+ const registry = /* @__PURE__ */ new Map();
367
+ const el = document.getElementById(SNAPSHOT_REF_REGISTRY_ID);
368
+ const raw = el?.textContent;
369
+ if (!raw) {
370
+ return registry;
371
+ }
372
+ try {
373
+ const parsed = JSON.parse(raw);
374
+ if (!Array.isArray(parsed)) {
375
+ return registry;
376
+ }
377
+ for (const entry of parsed) {
378
+ if (!entry || typeof entry !== "object") {
379
+ continue;
380
+ }
381
+ const ref = entry.ref;
382
+ if (typeof ref !== "string" || ref.length === 0) {
383
+ continue;
384
+ }
385
+ registry.set(ref, {
386
+ role: typeof entry.role === "string" ? entry.role ?? void 0 : void 0,
387
+ name: typeof entry.name === "string" ? entry.name ?? void 0 : void 0,
388
+ url: typeof entry.url === "string" ? entry.url ?? void 0 : void 0
389
+ });
390
+ }
391
+ } catch {
392
+ return /* @__PURE__ */ new Map();
393
+ }
394
+ return registry;
395
+ };
396
+ var recoverElementBySnapshotRef = (ref, options) => {
397
+ const metadata = readSnapshotRefRegistry().get(ref);
398
+ if (!metadata) {
399
+ return null;
400
+ }
401
+ if (typeof metadata.url === "string" && metadata.url.length > 0) {
402
+ const linkCandidates = Array.from(
403
+ document.querySelectorAll('a[href],[role="link"]')
404
+ );
405
+ const bestLink = scoreCandidates(linkCandidates, {
406
+ exactHref: metadata.url,
407
+ exactText: metadata.name
408
+ });
409
+ if (bestLink) {
410
+ return bestLink;
411
+ }
412
+ }
413
+ if (typeof metadata.role === "string" && metadata.role.length > 0) {
414
+ const roleMatch = options.findByRole({
415
+ role: {
416
+ name: metadata.role,
417
+ ...metadata.name ? { value: metadata.name } : {}
418
+ }
419
+ });
420
+ if (roleMatch) {
421
+ return roleMatch;
422
+ }
423
+ }
424
+ if (typeof metadata.name === "string" && metadata.name.length > 0) {
425
+ return findByText(metadata.name);
426
+ }
427
+ return null;
428
+ };
429
+
3
430
  // packages/extension/src/content.ts
4
431
  var AGENT_TAB_BRANDING_ACTION = "drive.agent_tab_branding";
5
432
  var AGENT_TAB_FAVICON_MARKER_ATTR = "data-bb-agent-favicon";
433
+ var CONTENT_LISTENER_MARKER = "__browserBridgeContentListenerInstalled__";
6
434
  var applyAgentTabFavicon = (faviconUrl) => {
7
435
  if (faviconUrl.length === 0) {
8
436
  return;
@@ -47,38 +475,6 @@
47
475
  }
48
476
  return value.replace(/[\\"']/g, "\\$&");
49
477
  };
50
- const isVisible = (element) => {
51
- if (!(element instanceof HTMLElement)) {
52
- return false;
53
- }
54
- const style = window.getComputedStyle(element);
55
- if (style.visibility === "hidden" || style.display === "none") {
56
- return false;
57
- }
58
- const rect = element.getBoundingClientRect();
59
- if (rect.width === 0 && rect.height === 0) {
60
- return false;
61
- }
62
- if (element.offsetWidth === 0 && element.offsetHeight === 0 && element.getClientRects().length === 0) {
63
- return false;
64
- }
65
- let current = element;
66
- while (current) {
67
- const style2 = window.getComputedStyle(current);
68
- if (style2.display === "none") {
69
- return false;
70
- }
71
- if (style2.visibility === "hidden" || style2.visibility === "collapse") {
72
- return false;
73
- }
74
- const opacity = Number.parseFloat(style2.opacity ?? "1");
75
- if (Number.isFinite(opacity) && opacity <= 0) {
76
- return false;
77
- }
78
- current = current.parentElement;
79
- }
80
- return true;
81
- };
82
478
  const buildUrlMatcher = (pattern) => {
83
479
  const maxLength = 256;
84
480
  if (pattern.length > maxLength) {
@@ -108,20 +504,17 @@
108
504
  return { ok: true, matcher: (url) => url.includes(pattern) };
109
505
  }
110
506
  };
111
- const findByText = (text) => {
112
- const tree = document.createTreeWalker(
113
- document.body,
114
- NodeFilter.SHOW_ELEMENT
115
- );
116
- let node = tree.nextNode();
117
- while (node) {
118
- const element = node;
119
- if (element.textContent && element.textContent.includes(text)) {
120
- return element;
121
- }
122
- node = tree.nextNode();
123
- }
124
- return null;
507
+ const getRoleCandidates = (roleName) => {
508
+ const selectorMap = {
509
+ button: 'button,[role="button"],input[type="button"],input[type="submit"],input[type="reset"],summary',
510
+ link: 'a[href],[role="link"]',
511
+ checkbox: 'input[type="checkbox"],[role="checkbox"]',
512
+ radio: 'input[type="radio"],[role="radio"]',
513
+ textbox: 'textarea,input:not([type]),input[type="text"],input[type="search"],input[type="email"],input[type="url"],input[type="tel"],input[type="password"],[role="textbox"]',
514
+ combobox: 'select,input[list],[role="combobox"]'
515
+ };
516
+ const selector = selectorMap[roleName] ?? `[role="${escapeSelector(roleName)}"]`;
517
+ return Array.from(document.querySelectorAll(selector)).filter(isVisible);
125
518
  };
126
519
  const findByRole = (locator) => {
127
520
  const role = locator.role;
@@ -133,17 +526,48 @@
133
526
  if (typeof roleName !== "string" || roleName.length === 0) {
134
527
  return null;
135
528
  }
136
- const candidates = Array.from(
137
- document.querySelectorAll(`[role="${escapeSelector(roleName)}"]`)
138
- );
529
+ const candidates = getRoleCandidates(roleName);
139
530
  if (typeof roleValue !== "string" || roleValue.length === 0) {
140
531
  return candidates[0] ?? null;
141
532
  }
142
- return candidates.find((candidate) => {
143
- const label = candidate.getAttribute("aria-label") ?? "";
144
- const text = candidate.textContent ?? "";
145
- return label.includes(roleValue) || text.includes(roleValue);
146
- }) ?? null;
533
+ const query = normalizeText(roleValue);
534
+ const matching = candidates.filter(
535
+ (candidate) => getRoleAccessibleName(candidate).includes(query)
536
+ );
537
+ if (matching.length > 0) {
538
+ const exactMatches = matching.filter(
539
+ (candidate) => getRoleAccessibleName(candidate) === query
540
+ );
541
+ return scoreCandidates(
542
+ exactMatches.length > 0 ? exactMatches : matching,
543
+ {
544
+ exactText: query
545
+ }
546
+ );
547
+ }
548
+ const fallbackCandidates = Array.from(
549
+ document.querySelectorAll("*")
550
+ ).filter((candidate) => {
551
+ if (!isVisible(candidate)) {
552
+ return false;
553
+ }
554
+ if (roleName === "button" && !isClickable(candidate)) {
555
+ return false;
556
+ }
557
+ return getRoleAccessibleName(candidate).includes(query);
558
+ });
559
+ if (fallbackCandidates.length === 0) {
560
+ return null;
561
+ }
562
+ const exactFallbackMatches = fallbackCandidates.filter(
563
+ (candidate) => getRoleAccessibleName(candidate) === query
564
+ );
565
+ return scoreCandidates(
566
+ exactFallbackMatches.length > 0 ? exactFallbackMatches : fallbackCandidates,
567
+ {
568
+ exactText: query
569
+ }
570
+ );
147
571
  };
148
572
  const resolveLocator = (locator) => {
149
573
  if (!locator) {
@@ -157,18 +581,26 @@
157
581
  if (found) {
158
582
  return found;
159
583
  }
584
+ const fallback = recoverElementBySnapshotRef(normalized, {
585
+ findByRole
586
+ });
587
+ if (fallback) {
588
+ return fallback;
589
+ }
160
590
  }
161
591
  const testid = locator.testid;
162
592
  if (typeof testid === "string" && testid.length > 0) {
163
593
  const selector = `[data-testid="${escapeSelector(testid)}"]`;
164
- const found = document.querySelector(selector);
594
+ const found = scoreCandidates(
595
+ Array.from(document.querySelectorAll(selector))
596
+ );
165
597
  if (found) {
166
598
  return found;
167
599
  }
168
600
  }
169
601
  const css = locator.css;
170
602
  if (typeof css === "string" && css.length > 0) {
171
- const found = document.querySelector(css);
603
+ const found = scoreCandidates(Array.from(document.querySelectorAll(css)));
172
604
  if (found) {
173
605
  return found;
174
606
  }
@@ -401,12 +833,29 @@
401
833
  if (!target) {
402
834
  return buildError("LOCATOR_NOT_FOUND", "Failed to resolve locator.");
403
835
  }
404
- const rect = target.getBoundingClientRect();
836
+ const targetState = readPopupTriggerState(target);
837
+ const point = getHittablePoint(target, {
838
+ preferDirectHit: targetState !== null
839
+ });
405
840
  return ok({
406
- x: rect.left + rect.width / 2,
407
- y: rect.top + rect.height / 2
841
+ x: point.x,
842
+ y: point.y,
843
+ ...targetState ? { target_state: targetState } : {}
408
844
  });
409
845
  }
846
+ case "drive.focus_locator": {
847
+ const { locator } = parseParams();
848
+ const target = resolveLocator(locator);
849
+ if (!target || !(target instanceof HTMLElement)) {
850
+ return buildError("LOCATOR_NOT_FOUND", "Failed to resolve locator.");
851
+ }
852
+ try {
853
+ target.focus({ preventScroll: true });
854
+ } catch {
855
+ target.focus();
856
+ }
857
+ return ok();
858
+ }
410
859
  case "drive.snapshot_html": {
411
860
  const html = document.documentElement?.outerHTML ?? "";
412
861
  return ok({ format: "html", snapshot: html });
@@ -467,22 +916,20 @@
467
916
  return buildError("LOCATOR_NOT_FOUND", "Failed to resolve locator.");
468
917
  }
469
918
  const count = typeof click_count === "number" && Number.isFinite(click_count) ? Math.max(1, Math.floor(click_count)) : 1;
470
- window.setTimeout(() => {
471
- try {
472
- if (target instanceof HTMLElement) {
473
- try {
474
- target.focus({ preventScroll: true });
475
- } catch {
476
- target.focus();
477
- }
478
- }
479
- for (let i = 0; i < count; i += 1) {
480
- target.click();
481
- }
482
- } catch {
483
- }
484
- }, 0);
485
- return ok();
919
+ const strategy = selectClickStrategy(target);
920
+ if (strategy.kind === "popup_trigger") {
921
+ return await executePopupTriggerClick({
922
+ target,
923
+ beforeState: strategy.state,
924
+ clickCount: count,
925
+ locationBefore: window.location.href,
926
+ sleep
927
+ });
928
+ }
929
+ return executeGenericClick({
930
+ target,
931
+ clickCount: count
932
+ });
486
933
  }
487
934
  case "drive.hover": {
488
935
  const { locator, delay_ms } = parseParams();
@@ -905,7 +1352,9 @@
905
1352
  }
906
1353
  const checkCondition = () => {
907
1354
  if (kind === "text_present") {
908
- return (document.body?.innerText ?? "").includes(value);
1355
+ return getRenderedText(document.body).includes(
1356
+ normalizeText(value)
1357
+ );
909
1358
  }
910
1359
  if (kind === "url_matches") {
911
1360
  return urlMatcher ? urlMatcher.matcher(window.location.href) : window.location.href.includes(value);
@@ -952,7 +1401,15 @@
952
1401
  }
953
1402
  };
954
1403
  var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
955
- if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {
1404
+ var contentListenerInstalled = (() => {
1405
+ const target = globalThis;
1406
+ if (target[CONTENT_LISTENER_MARKER]) {
1407
+ return true;
1408
+ }
1409
+ target[CONTENT_LISTENER_MARKER] = true;
1410
+ return false;
1411
+ })();
1412
+ if (typeof chrome !== "undefined" && chrome.runtime?.onMessage && !contentListenerInstalled) {
956
1413
  chrome.runtime.onMessage.addListener(
957
1414
  (message, _sender, sendResponse) => {
958
1415
  if (!isRecord(message) || typeof message.action !== "string") {