@browserbridge/bbx 1.0.1 → 1.1.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.
Files changed (63) hide show
  1. package/README.md +4 -4
  2. package/package.json +11 -13
  3. package/packages/agent-client/src/cli-helpers.js +33 -0
  4. package/packages/agent-client/src/cli.js +116 -41
  5. package/packages/agent-client/src/client.js +29 -4
  6. package/packages/agent-client/src/command-registry.js +3 -0
  7. package/packages/agent-client/src/detect.js +159 -48
  8. package/packages/agent-client/src/install.js +24 -1
  9. package/packages/agent-client/src/mcp-config.js +29 -10
  10. package/packages/agent-client/src/setup-status.js +12 -4
  11. package/packages/mcp-server/src/bin.js +57 -5
  12. package/packages/mcp-server/src/handlers.js +28 -7
  13. package/packages/mcp-server/src/server.js +12 -2
  14. package/packages/native-host/bin/bridge-daemon.js +33 -4
  15. package/packages/native-host/bin/install-manifest.js +24 -2
  16. package/packages/native-host/src/config.js +131 -6
  17. package/packages/native-host/src/daemon-process.js +396 -0
  18. package/packages/native-host/src/daemon.js +217 -68
  19. package/packages/native-host/src/framing.js +131 -11
  20. package/packages/native-host/src/install-manifest.js +121 -7
  21. package/packages/native-host/src/native-host.js +110 -73
  22. package/packages/protocol/src/capabilities.js +3 -0
  23. package/packages/protocol/src/defaults.js +1 -0
  24. package/packages/protocol/src/errors.js +4 -0
  25. package/packages/protocol/src/payload-cost.js +19 -6
  26. package/packages/protocol/src/protocol.js +143 -7
  27. package/packages/protocol/src/registry.js +11 -0
  28. package/packages/protocol/src/summary.js +18 -10
  29. package/packages/protocol/src/types.js +28 -3
  30. package/skills/browser-bridge/SKILL.md +2 -1
  31. package/skills/browser-bridge/references/interaction.md +1 -0
  32. package/skills/browser-bridge/references/protocol.md +2 -1
  33. package/CHANGELOG.md +0 -55
  34. package/assets/banner.jpg +0 -0
  35. package/assets/logo.png +0 -0
  36. package/assets/logo.svg +0 -65
  37. package/docs/api-reference.md +0 -157
  38. package/docs/cli-guide.md +0 -128
  39. package/docs/index.md +0 -25
  40. package/docs/manual-setup.md +0 -140
  41. package/docs/mcp-vs-cli.md +0 -258
  42. package/docs/publishing.md +0 -112
  43. package/docs/quickstart.md +0 -104
  44. package/docs/troubleshooting.md +0 -59
  45. package/docs/unpacked-extension.md +0 -72
  46. package/docs/usage-scenarios.md +0 -136
  47. package/manifest.json +0 -38
  48. package/packages/extension/assets/icon-128.png +0 -0
  49. package/packages/extension/assets/icon-16.png +0 -0
  50. package/packages/extension/assets/icon-32.png +0 -0
  51. package/packages/extension/assets/icon-48.png +0 -0
  52. package/packages/extension/src/background-helpers.js +0 -474
  53. package/packages/extension/src/background-routing.js +0 -89
  54. package/packages/extension/src/background.js +0 -3490
  55. package/packages/extension/src/content-script-helpers.js +0 -282
  56. package/packages/extension/src/content-script.js +0 -2043
  57. package/packages/extension/src/debugger-coordinator.js +0 -188
  58. package/packages/extension/src/sidepanel-helpers.js +0 -104
  59. package/packages/extension/ui/popup.html +0 -35
  60. package/packages/extension/ui/popup.js +0 -298
  61. package/packages/extension/ui/sidepanel.html +0 -102
  62. package/packages/extension/ui/sidepanel.js +0 -1771
  63. package/packages/extension/ui/ui.css +0 -1160
@@ -1,2043 +0,0 @@
1
- // @ts-check
2
-
3
- (() => {
4
- const contentScriptGlobal =
5
- /** @type {typeof globalThis & { __chromeCodexBridgeContentScriptLoaded?: boolean }} */ (
6
- globalThis
7
- );
8
- if (contentScriptGlobal.__chromeCodexBridgeContentScriptLoaded) {
9
- return;
10
- }
11
- contentScriptGlobal.__chromeCodexBridgeContentScriptLoaded = true;
12
-
13
- /**
14
- * @typedef {{
15
- * maxNodes: number,
16
- * maxDepth: number,
17
- * textBudget: number,
18
- * includeBbox: boolean,
19
- * attributeAllowlist: string[]
20
- * }} Budget
21
- */
22
-
23
- /**
24
- * @typedef {{
25
- * selector: string,
26
- * withinRef: string | null,
27
- * budget: Budget
28
- * }} NormalizedDomQuery
29
- */
30
-
31
- /**
32
- * @typedef {{
33
- * elementRef: string,
34
- * tag: string,
35
- * role: string | null,
36
- * name: string | null,
37
- * textExcerpt: string,
38
- * attrs: Record<string, string | null>,
39
- * bbox?: { x: number, y: number, width: number, height: number }
40
- * }} NodeSummary
41
- */
42
-
43
- const elementRegistry = new Map();
44
- const reverseRegistry = new WeakMap();
45
- const patchRegistry = new Map();
46
- const MAX_REGISTRY_SIZE = 5000;
47
- const MAX_PATCH_REGISTRY_SIZE = 2000;
48
- let registryPruned = false;
49
- const contentHelpers =
50
- /** @type {typeof globalThis & { __BBX_CONTENT_HELPERS__?: {
51
- NON_TEXT_INPUT_TYPES: Set<string>,
52
- applyBudget: (options?: Record<string, any>) => Budget,
53
- clamp: (value: number | string | null | undefined, minimum: number, maximum: number) => number,
54
- escapeTailwindSelector: (selector: string) => string,
55
- extractElementText: (element: Element) => string,
56
- getImplicitRole: (element: Element) => string,
57
- getImplicitRoleSelector: (role: string) => string,
58
- toRect: (rect: DOMRect | DOMRectReadOnly) => { x: number, y: number, width: number, height: number },
59
- truncateText: (value: string, budget: number) => { value: string, truncated: boolean, omitted: number }
60
- } }} */ (globalThis).__BBX_CONTENT_HELPERS__;
61
- if (!contentHelpers) {
62
- throw new Error('Browser Bridge content-script helpers must load before content-script.js.');
63
- }
64
- const {
65
- NON_TEXT_INPUT_TYPES,
66
- applyBudget,
67
- clamp,
68
- escapeTailwindSelector,
69
- extractElementText,
70
- getImplicitRole,
71
- getImplicitRoleSelector,
72
- toRect,
73
- truncateText,
74
- } = contentHelpers;
75
-
76
- chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
77
- if (message?.type === 'bridge.ping') {
78
- sendResponse({ ok: true });
79
- return false;
80
- }
81
-
82
- if (message?.type !== 'bridge.execute') {
83
- return false;
84
- }
85
-
86
- try {
87
- const result = handleCommand(message.method, message.params);
88
- Promise.resolve(result)
89
- .then(sendResponse)
90
- .catch((err) => {
91
- sendResponse({
92
- error: err instanceof Error ? err.message : String(err),
93
- });
94
- });
95
- } catch (error) {
96
- sendResponse({
97
- error: error instanceof Error ? error.message : String(error),
98
- });
99
- }
100
-
101
- return true;
102
- });
103
-
104
- /**
105
- * Dispatch a bridge method within the page context.
106
- *
107
- * @param {string} method
108
- * @param {Record<string, any>} params
109
- * @returns {unknown}
110
- */
111
- function handleCommand(method, params) {
112
- switch (method) {
113
- case 'page.get_state':
114
- return getPageState();
115
- case 'page.get_storage':
116
- return getStorageData(params);
117
- case 'page.get_text':
118
- return getFullPageText(params);
119
- case 'navigation.navigate':
120
- case 'navigation.reload':
121
- case 'navigation.go_back':
122
- case 'navigation.go_forward':
123
- throw new Error(`Unsupported content-script method ${method}`);
124
- case 'dom.query':
125
- return domQuery(params);
126
- case 'dom.describe':
127
- return describeElement(resolveElementRefFromParams(params));
128
- case 'dom.get_text':
129
- return getText(resolveElementRefFromParams(params), params.textBudget);
130
- case 'dom.get_attributes':
131
- return getAttributes(resolveElementRefFromParams(params), params.attributes ?? []);
132
- case 'dom.wait_for':
133
- return waitForDom(params);
134
- case 'dom.find_by_text':
135
- return findByText(params);
136
- case 'dom.find_by_role':
137
- return findByRole(params);
138
- case 'dom.get_html':
139
- return getHtml({
140
- ...params,
141
- elementRef: resolveElementRefFromParams(params),
142
- });
143
- case 'layout.get_box_model':
144
- return getBoxModel(resolveElementRefFromParams(params));
145
- case 'layout.hit_test':
146
- return hitTest(params.x, params.y);
147
- case 'styles.get_computed':
148
- return getComputedStyles(resolveElementRefFromParams(params), params.properties);
149
- case 'styles.get_matched_rules':
150
- return getMatchedRules(resolveElementRefFromParams(params));
151
- case 'viewport.scroll':
152
- return scrollViewport(params);
153
- case 'input.click':
154
- return clickTarget(params);
155
- case 'input.focus':
156
- return focusTarget(params);
157
- case 'input.type':
158
- return typeIntoTarget(params);
159
- case 'input.press_key':
160
- return pressKeyTarget(params);
161
- case 'input.set_checked':
162
- return setCheckedTarget(params);
163
- case 'input.select_option':
164
- return selectOptionTarget(params);
165
- case 'input.hover':
166
- return hoverTarget(params);
167
- case 'input.drag':
168
- return dragTarget(params);
169
- case 'input.scroll_into_view':
170
- return scrollIntoViewTarget(params);
171
- case 'patch.apply_styles':
172
- return applyStylePatch(params);
173
- case 'patch.apply_dom':
174
- return applyDomPatch(params);
175
- case 'patch.list':
176
- return listPatches();
177
- case 'patch.rollback':
178
- return rollbackPatch(params.patchId);
179
- case 'patch.commit_session_baseline':
180
- return { committed: true };
181
- case 'screenshot.capture_element':
182
- return getElementRect(resolveElementRefFromParams(params));
183
- case 'screenshot.capture_full_page':
184
- return getFullPageDimensions();
185
- default:
186
- throw new Error(`Unsupported method ${method}`);
187
- }
188
- }
189
-
190
- /**
191
- * Return lightweight page state useful for browser automation decisions.
192
- *
193
- * @returns {{
194
- * url: string,
195
- * origin: string,
196
- * title: string,
197
- * readyState: DocumentReadyState,
198
- * focused: boolean,
199
- * viewport: { width: number, height: number, devicePixelRatio: number },
200
- * scroll: { x: number, y: number, maxX: number, maxY: number },
201
- * activeElement: NodeSummary | null,
202
- * selection: { value: string, truncated: boolean, omitted: number },
203
- * hints: { tailwind: boolean }
204
- * }}
205
- */
206
- function getPageState() {
207
- const scrollingElement = document.scrollingElement || document.documentElement || document.body;
208
- const selection = document.getSelection?.()?.toString() || '';
209
-
210
- return {
211
- url: window.location.href,
212
- origin: window.location.origin,
213
- title: document.title,
214
- readyState: document.readyState,
215
- focused: document.hasFocus(),
216
- viewport: {
217
- width: window.innerWidth,
218
- height: window.innerHeight,
219
- devicePixelRatio: window.devicePixelRatio || 1,
220
- },
221
- scroll: {
222
- x: window.scrollX,
223
- y: window.scrollY,
224
- maxX: Math.max(
225
- 0,
226
- (scrollingElement?.scrollWidth || document.documentElement.scrollWidth || 0) -
227
- window.innerWidth
228
- ),
229
- maxY: Math.max(
230
- 0,
231
- (scrollingElement?.scrollHeight || document.documentElement.scrollHeight || 0) -
232
- window.innerHeight
233
- ),
234
- },
235
- activeElement:
236
- document.activeElement instanceof Element
237
- ? summarizeNode(
238
- document.activeElement,
239
- ['id', 'class', 'name', 'type', 'href', 'role'],
240
- 120,
241
- true
242
- ).node
243
- : null,
244
- selection: truncateText(selection.trim(), 200),
245
- hints: detectPageHints(),
246
- };
247
- }
248
-
249
- /**
250
- * Detect CSS frameworks and page characteristics for agent guidance.
251
- * Lightweight - only checks a few DOM/stylesheet signals.
252
- *
253
- * @returns {{ tailwind: boolean }}
254
- */
255
- function detectPageHints() {
256
- let tailwind = false;
257
- // Check for Tailwind's characteristic patterns:
258
- // 1. A <style> or <link> with tailwind-related id/href
259
- // 2. Elements using Tailwind's arbitrary-value syntax: class="...-[...]"
260
- // 3. Tailwind's reset styles injected by @tailwind base
261
- try {
262
- tailwind = Boolean(
263
- document.querySelector(
264
- 'link[href*="tailwind"], style[id*="tailwind"], script[src*="tailwindcss"]'
265
- )
266
- );
267
- if (!tailwind) {
268
- // Check for Tailwind's characteristic class patterns on a sample of elements
269
- const sample = document.querySelectorAll('[class]');
270
- const twPattern =
271
- /\b(?:flex|grid|bg-|text-|p[xytblr]?-|m[xytblr]?-|w-|h-|rounded|shadow|border)-/;
272
- for (let i = 0; i < Math.min(sample.length, 30); i++) {
273
- const cls = sample[i].className;
274
- if (typeof cls === 'string' && twPattern.test(cls)) {
275
- tailwind = true;
276
- break;
277
- }
278
- }
279
- }
280
- } catch {
281
- // Ignore - cross-origin or other DOM access issues
282
- }
283
- return { tailwind };
284
- }
285
-
286
- /**
287
- * Extract the full visible text content of the page.
288
- *
289
- * @param {Record<string, any>} params
290
- * @returns {{ value: string, truncated: boolean, omitted: number, length: number }}
291
- */
292
- function getFullPageText(params) {
293
- const budget = Number(params.textBudget) || 8000;
294
- const body = document.body;
295
- if (!body) {
296
- return { value: '', truncated: false, omitted: 0, length: 0 };
297
- }
298
- const raw = (body.innerText || body.textContent || '').trim();
299
- const result = truncateText(raw, budget);
300
- return {
301
- value: result.value,
302
- truncated: result.truncated,
303
- omitted: result.omitted,
304
- length: raw.length,
305
- };
306
- }
307
-
308
- /**
309
- * Perform a bounded breadth-first DOM summary rooted at a selector or existing
310
- * element reference.
311
- *
312
- * @param {Record<string, any>} params
313
- * @returns {{ nodes: NodeSummary[], revision: number, truncated?: boolean, registrySize: number, _registryPruned?: boolean }}
314
- */
315
- function domQuery(params) {
316
- const query = normalizeDomQuery(params);
317
- const root = query.withinRef
318
- ? getRequiredElement(query.withinRef)
319
- : document.querySelector(query.selector);
320
- if (!root) {
321
- return {
322
- nodes: [],
323
- revision: getDocumentRevision(),
324
- registrySize: elementRegistry.size,
325
- };
326
- }
327
-
328
- /** @type {NodeSummary[]} */
329
- const nodes = [];
330
- let remaining = query.budget.textBudget;
331
- /** @type {Array<{ element: Element, depth: number }>} */
332
- const queue = [{ element: root, depth: 0 }];
333
-
334
- while (queue.length && nodes.length < query.budget.maxNodes && remaining > 0) {
335
- const next = queue.shift();
336
- if (!next) {
337
- continue;
338
- }
339
- const { element, depth } = next;
340
- if (depth > query.budget.maxDepth) {
341
- continue;
342
- }
343
-
344
- const summary = summarizeNode(
345
- element,
346
- query.budget.attributeAllowlist,
347
- remaining,
348
- query.budget.includeBbox
349
- );
350
- remaining -= summary.textLength;
351
- nodes.push(summary.node);
352
-
353
- for (const child of element.children) {
354
- queue.push({ element: child, depth: depth + 1 });
355
- }
356
- }
357
-
358
- const pruned = registryPruned;
359
- registryPruned = false;
360
- return {
361
- nodes,
362
- revision: getDocumentRevision(),
363
- truncated: nodes.length >= query.budget.maxNodes || remaining <= 0,
364
- registrySize: elementRegistry.size,
365
- ...(pruned ? { _registryPruned: true } : {}),
366
- };
367
- }
368
-
369
- /**
370
- * Create a compact, token-efficient summary for a single element.
371
- *
372
- * @param {Element} element
373
- * @param {string[]} attributeAllowlist
374
- * @param {number} remainingText
375
- * @param {boolean} includeBbox
376
- * @returns {{ textLength: number, node: NodeSummary }}
377
- */
378
- function summarizeNode(element, attributeAllowlist, remainingText, includeBbox) {
379
- const elementRef = rememberElement(element);
380
- const text = truncateText(
381
- extractElementText(element),
382
- Math.min(Math.max(0, remainingText), 160)
383
- );
384
- return {
385
- textLength: text.value.length,
386
- node: {
387
- elementRef,
388
- tag: element.tagName.toLowerCase(),
389
- role: element.getAttribute('role'),
390
- name: element.getAttribute('aria-label') || element.getAttribute('name') || null,
391
- textExcerpt: text.value,
392
- attrs: summarizeAttributes(element, attributeAllowlist),
393
- ...(includeBbox ? { bbox: toRect(element.getBoundingClientRect()) } : {}),
394
- },
395
- };
396
- }
397
-
398
- /**
399
- * Extract only allowlisted attributes from an element.
400
- *
401
- * @param {Element} element
402
- * @param {string[]} attributeAllowlist
403
- * @returns {Record<string, string | null>}
404
- */
405
- function summarizeAttributes(element, attributeAllowlist) {
406
- if (!attributeAllowlist.length) {
407
- return {};
408
- }
409
- return attributeAllowlist.reduce((accumulator, attribute) => {
410
- if (element.hasAttribute(attribute)) {
411
- accumulator[attribute] = element.getAttribute(attribute);
412
- }
413
- return accumulator;
414
- }, /** @type {Record<string, string | null>} */ ({}));
415
- }
416
-
417
- /**
418
- * Describe a known element reference.
419
- *
420
- * @param {string} elementRef
421
- * @returns {{ elementRef: string, tag: string, text: { value: string, truncated: boolean, omitted: number }, bbox: { x: number, y: number, width: number, height: number } }}
422
- */
423
- function describeElement(elementRef) {
424
- const element = getRequiredElement(elementRef);
425
- return {
426
- elementRef,
427
- tag: element.tagName.toLowerCase(),
428
- text: truncateText(extractElementText(element), 300),
429
- bbox: toRect(element.getBoundingClientRect()),
430
- };
431
- }
432
-
433
- /**
434
- * Return bounded text content for an element.
435
- *
436
- * @param {string} elementRef
437
- * @param {number} [budget=600]
438
- * @returns {{ value: string, truncated: boolean, omitted: number }}
439
- */
440
- function getText(elementRef, budget = 600) {
441
- const element = /** @type {HTMLElement} */ (getRequiredElement(elementRef));
442
- return truncateText((element.innerText || element.textContent || '').trim(), budget);
443
- }
444
-
445
- /**
446
- * Read a selected set of attributes from an element reference.
447
- *
448
- * @param {string} elementRef
449
- * @param {string[]} attributes
450
- * @returns {Record<string, string | null>}
451
- */
452
- function getAttributes(elementRef, attributes) {
453
- const element = getRequiredElement(elementRef);
454
- return attributes.reduce((accumulator, attribute) => {
455
- if (element.hasAttribute(attribute)) {
456
- accumulator[attribute] = element.getAttribute(attribute);
457
- }
458
- return accumulator;
459
- }, /** @type {Record<string, string | null>} */ ({}));
460
- }
461
-
462
- /**
463
- * Return the box model rectangle for an element.
464
- *
465
- * @param {string} elementRef
466
- * @returns {{ x: number, y: number, width: number, height: number }}
467
- */
468
- function getBoxModel(elementRef) {
469
- return toRect(getRequiredElement(elementRef).getBoundingClientRect());
470
- }
471
-
472
- /**
473
- * Resolve the topmost element at a viewport coordinate into a compact summary.
474
- *
475
- * @param {number} x
476
- * @param {number} y
477
- * @returns {NodeSummary | null}
478
- */
479
- function hitTest(x, y) {
480
- const element = document.elementFromPoint(x, y);
481
- return element ? summarizeNode(element, ['id', 'class'], 120, true).node : null;
482
- }
483
-
484
- /**
485
- * Read computed CSS properties for an element reference.
486
- *
487
- * @param {string} elementRef
488
- * @param {string[]} [properties=[]]
489
- * @returns {Record<string, string>}
490
- */
491
- function getComputedStyles(elementRef, properties = []) {
492
- const styles = window.getComputedStyle(getRequiredElement(elementRef));
493
- const requested = properties.length
494
- ? properties
495
- : ['display', 'position', 'width', 'height', 'color'];
496
- return requested.reduce((accumulator, property) => {
497
- accumulator[property] = styles.getPropertyValue(property);
498
- return accumulator;
499
- }, /** @type {Record<string, string>} */ ({}));
500
- }
501
-
502
- /**
503
- * Return simple matched-rule context for an element.
504
- *
505
- * @param {string} elementRef
506
- * @returns {{ elementRef: string, classes: string[], inlineStyle: string }}
507
- */
508
- function getMatchedRules(elementRef) {
509
- const element = getRequiredElement(elementRef);
510
- return {
511
- elementRef,
512
- classes: [...element.classList],
513
- inlineStyle: element.getAttribute('style') || '',
514
- };
515
- }
516
-
517
- /**
518
- * Scroll the window or a specific scrollable element.
519
- *
520
- * @param {Record<string, any>} params
521
- * @returns {{
522
- * scrolled: boolean,
523
- * target: string,
524
- * x: number,
525
- * y: number,
526
- * top: number,
527
- * left: number,
528
- * behavior: 'auto' | 'smooth',
529
- * relative: boolean
530
- * }}
531
- */
532
- function scrollViewport(params) {
533
- const top = Number(params.top) || 0;
534
- const left = Number(params.left) || 0;
535
- const behavior = params.behavior === 'smooth' ? 'smooth' : 'auto';
536
- const relative = Boolean(params.relative);
537
-
538
- if (params.target?.elementRef || params.target?.selector) {
539
- const element = resolveTarget(params.target);
540
- const scrollTarget = getScrollableElementTarget(element);
541
- if (relative) {
542
- scrollTarget.scrollBy({
543
- top,
544
- left,
545
- behavior,
546
- });
547
- } else {
548
- scrollTarget.scrollTo({
549
- top,
550
- left,
551
- behavior,
552
- });
553
- }
554
-
555
- return {
556
- scrolled: true,
557
- target: rememberElement(scrollTarget),
558
- x: scrollTarget.scrollLeft,
559
- y: scrollTarget.scrollTop,
560
- top: scrollTarget.scrollTop,
561
- left: scrollTarget.scrollLeft,
562
- behavior,
563
- relative,
564
- };
565
- }
566
-
567
- if (relative) {
568
- window.scrollBy({
569
- top,
570
- left,
571
- behavior,
572
- });
573
- } else {
574
- window.scrollTo({
575
- top,
576
- left,
577
- behavior,
578
- });
579
- }
580
-
581
- return {
582
- scrolled: true,
583
- target: 'window',
584
- x: window.scrollX,
585
- y: window.scrollY,
586
- top: window.scrollY,
587
- left: window.scrollX,
588
- behavior,
589
- relative,
590
- };
591
- }
592
-
593
- /**
594
- * Trigger a click-like interaction on a target element.
595
- *
596
- * @param {Record<string, any>} params
597
- * @returns {{ elementRef: string, clicked: boolean, button: string, clickCount: number }}
598
- */
599
- function clickTarget(params) {
600
- const element = resolveTarget(params.target);
601
- const button = normalizeMouseButton(params.button);
602
- const clickCount = clamp(params.clickCount ?? 1, 1, 2);
603
- const modifiers = normalizeModifierState(params.modifiers);
604
- const point = getViewportPoint(element);
605
-
606
- scrollTargetIntoView(element);
607
- focusElement(element);
608
- dispatchMouseEvent(element, 'mousemove', point, button, 0, modifiers);
609
- dispatchMouseEvent(element, 'mousedown', point, button, clickCount, modifiers);
610
- dispatchMouseEvent(element, 'mouseup', point, button, clickCount, modifiers);
611
-
612
- if (button === 'left') {
613
- if (element instanceof HTMLElement) {
614
- element.click();
615
- if (clickCount === 2) {
616
- element.click();
617
- dispatchMouseEvent(element, 'dblclick', point, button, clickCount, modifiers);
618
- }
619
- } else {
620
- dispatchMouseEvent(element, 'click', point, button, clickCount, modifiers);
621
- if (clickCount === 2) {
622
- dispatchMouseEvent(element, 'dblclick', point, button, clickCount, modifiers);
623
- }
624
- }
625
- } else if (button === 'right') {
626
- dispatchMouseEvent(element, 'contextmenu', point, button, clickCount, modifiers);
627
- } else {
628
- dispatchMouseEvent(element, 'auxclick', point, button, clickCount, modifiers);
629
- }
630
-
631
- return {
632
- elementRef: rememberElement(element),
633
- clicked: true,
634
- button,
635
- clickCount,
636
- };
637
- }
638
-
639
- /**
640
- * Focus one element so follow-up keyboard input lands consistently.
641
- *
642
- * @param {Record<string, any>} params
643
- * @returns {{ elementRef: string, focused: boolean, tag: string }}
644
- */
645
- function focusTarget(params) {
646
- const element = resolveTarget(params.target);
647
- scrollTargetIntoView(element);
648
- const focused = focusElement(element);
649
- return {
650
- elementRef: rememberElement(element),
651
- focused: isElementFocused(element) || isElementFocused(focused),
652
- tag: focused.tagName.toLowerCase(),
653
- };
654
- }
655
-
656
- /**
657
- * Type text into an editable control or contenteditable region.
658
- *
659
- * @param {Record<string, any>} params
660
- * @returns {{ elementRef: string, typed: number, value: string }}
661
- */
662
- function typeIntoTarget(params) {
663
- const element = resolveTarget(params.target);
664
- const editable = getEditableTarget(element);
665
- if (!editable) {
666
- throw new Error('Target is not an editable control.');
667
- }
668
-
669
- scrollTargetIntoView(editable);
670
- focusElement(editable);
671
-
672
- if (params.clear) {
673
- clearEditableValue(editable);
674
- }
675
-
676
- const text = String(params.text ?? '');
677
- for (const character of text) {
678
- runKeyAction(editable, character, params.modifiers);
679
- }
680
-
681
- if (params.submit) {
682
- submitElement(editable);
683
- }
684
-
685
- return {
686
- elementRef: rememberElement(editable),
687
- typed: text.length,
688
- value: getEditableValue(editable),
689
- };
690
- }
691
-
692
- /**
693
- * Send one keyboard interaction to the currently focused or targeted element.
694
- *
695
- * @param {Record<string, any>} params
696
- * @returns {{ elementRef: string | null, key: string, handled: boolean }}
697
- */
698
- function pressKeyTarget(params) {
699
- const target =
700
- params.target?.elementRef || params.target?.selector
701
- ? resolveTarget(params.target)
702
- : document.activeElement instanceof Element
703
- ? document.activeElement
704
- : document.body;
705
- scrollTargetIntoView(target);
706
- focusElement(target);
707
- const key = String(params.key ?? '');
708
- if (!key) {
709
- throw new Error('A key is required.');
710
- }
711
-
712
- const result = runKeyAction(target, key, params.modifiers);
713
- return {
714
- elementRef: result.target instanceof Element ? rememberElement(result.target) : null,
715
- key: result.key,
716
- handled: result.handled,
717
- };
718
- }
719
-
720
- /**
721
- * Toggle a checkbox-like control to a desired checked state.
722
- *
723
- * @param {Record<string, any>} params
724
- * @returns {{ elementRef: string, checked: boolean, changed: boolean, type: string }}
725
- */
726
- function setCheckedTarget(params) {
727
- const element = resolveCheckableTarget(params.target);
728
- const checked = params.checked !== false;
729
- if (element.type === 'radio' && !checked && element.checked) {
730
- throw new Error('Radio inputs cannot be unchecked directly.');
731
- }
732
-
733
- scrollTargetIntoView(element);
734
- focusElement(element);
735
- const changed = element.checked !== checked;
736
- if (changed) {
737
- element.click();
738
- if (element.checked !== checked) {
739
- element.checked = checked;
740
- element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
741
- element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
742
- }
743
- }
744
-
745
- return {
746
- elementRef: rememberElement(element),
747
- checked: element.checked,
748
- changed,
749
- type: element.type,
750
- };
751
- }
752
-
753
- /**
754
- * Select options in a native select control by value, label, or index.
755
- *
756
- * @param {Record<string, any>} params
757
- * @returns {{ elementRef: string, changed: boolean, multiple: boolean, selectedValues: string[] }}
758
- */
759
- function selectOptionTarget(params) {
760
- const element = resolveSelectTarget(params.target);
761
- const values = Array.isArray(params.values)
762
- ? params.values.filter((value) => typeof value === 'string')
763
- : [];
764
- const labels = Array.isArray(params.labels)
765
- ? params.labels.filter((label) => typeof label === 'string')
766
- : [];
767
- const indexes = Array.isArray(params.indexes)
768
- ? params.indexes
769
- .map((index) => Number(index))
770
- .filter((index) => Number.isInteger(index) && index >= 0)
771
- : [];
772
-
773
- if (!values.length && !labels.length && !indexes.length) {
774
- throw new Error('At least one option selector is required.');
775
- }
776
-
777
- scrollTargetIntoView(element);
778
- focusElement(element);
779
-
780
- const options = [...element.options];
781
- const selectedBefore = getSelectedOptionValues(element);
782
- const matchingOptions = options.filter((option, index) => {
783
- return (
784
- values.includes(option.value) ||
785
- labels.includes(option.label) ||
786
- labels.includes(option.text.trim()) ||
787
- indexes.includes(index)
788
- );
789
- });
790
-
791
- if (!matchingOptions.length) {
792
- throw new Error('No matching option found.');
793
- }
794
-
795
- if (element.multiple) {
796
- const matchedValues = new Set(matchingOptions.map((option) => option.value));
797
- for (const option of options) {
798
- option.selected = matchedValues.has(option.value);
799
- }
800
- } else {
801
- element.value = matchingOptions[0].value;
802
- }
803
-
804
- const selectedAfter = getSelectedOptionValues(element);
805
- const changed = !areStringArraysEqual(selectedBefore, selectedAfter);
806
- if (changed) {
807
- element.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
808
- element.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
809
- }
810
-
811
- return {
812
- elementRef: rememberElement(element),
813
- changed,
814
- multiple: element.multiple,
815
- selectedValues: selectedAfter,
816
- };
817
- }
818
-
819
- /**
820
- * Apply a reversible inline style patch to an element or selector target.
821
- *
822
- * @param {Record<string, any>} params
823
- * @returns {{ patchId: string, applied: boolean, verified?: Record<string, string>, elementRef?: string }}
824
- */
825
- function applyStylePatch(params) {
826
- const element = /** @type {HTMLElement} */ (resolveTarget(params.target));
827
- const patchId = params.patchId || `patch_${crypto.randomUUID()}`;
828
- /** @type {Record<string, string>} */
829
- const previous = {};
830
- for (const [property, value] of Object.entries(params.declarations || {})) {
831
- previous[property] = element.style.getPropertyValue(property);
832
- element.style.setProperty(property, value, params.important ? 'important' : '');
833
- }
834
- pruneRegistry(patchRegistry, MAX_PATCH_REGISTRY_SIZE);
835
- const elementRef = rememberElement(element);
836
- patchRegistry.set(patchId, {
837
- kind: 'style',
838
- elementRef,
839
- previous,
840
- });
841
- const result = { patchId, applied: true };
842
- if (params.verify) {
843
- const computed = globalThis.getComputedStyle(element);
844
- /** @type {Record<string, string>} */
845
- const verified = {};
846
- for (const property of Object.keys(params.declarations || {})) {
847
- verified[property] = computed.getPropertyValue(property);
848
- }
849
- return { ...result, verified, elementRef };
850
- }
851
- return result;
852
- }
853
-
854
- /**
855
- * Apply a reversible DOM patch to a target element.
856
- *
857
- * @param {Record<string, any>} params
858
- * @returns {{ patchId: string, applied: boolean, verified?: Record<string, unknown>, elementRef?: string }}
859
- */
860
- function applyDomPatch(params) {
861
- const element = resolveTarget(params.target);
862
- const patchId = params.patchId || `patch_${crypto.randomUUID()}`;
863
- const operation = params.operation;
864
-
865
- /** @type {{ text: string | null, attributes: Record<string, string | null>, toggledClass: string | null, hadClass: boolean | null }} */
866
- const previous = {
867
- text: null,
868
- attributes: {},
869
- toggledClass: null,
870
- hadClass: null,
871
- };
872
-
873
- switch (operation) {
874
- case 'set_text':
875
- previous.text = element.textContent;
876
- element.textContent = String(params.value ?? '');
877
- break;
878
- case 'set_attribute':
879
- previous.attributes[params.name] = element.getAttribute(params.name);
880
- element.setAttribute(params.name, String(params.value ?? ''));
881
- break;
882
- case 'remove_attribute':
883
- previous.attributes[params.name] = element.getAttribute(params.name);
884
- element.removeAttribute(params.name);
885
- break;
886
- case 'toggle_class': {
887
- const className = String(params.value);
888
- previous.toggledClass = className;
889
- previous.hadClass = element.classList.contains(className);
890
- element.classList.toggle(className);
891
- break;
892
- }
893
- default:
894
- throw new Error(`Unsupported DOM patch operation ${operation}`);
895
- }
896
-
897
- pruneRegistry(patchRegistry, MAX_PATCH_REGISTRY_SIZE);
898
- const elementRef = rememberElement(element);
899
- patchRegistry.set(patchId, {
900
- kind: 'dom',
901
- elementRef,
902
- operation,
903
- previous,
904
- });
905
- const result = { patchId, applied: true };
906
- if (params.verify) {
907
- /** @type {Record<string, unknown>} */
908
- const verified = {};
909
- if (operation === 'set_text') {
910
- verified.textContent = element.textContent;
911
- } else if (operation === 'set_attribute' || operation === 'remove_attribute') {
912
- verified[params.name] = element.getAttribute(params.name);
913
- } else if (operation === 'toggle_class') {
914
- verified.classList = [...element.classList];
915
- }
916
- return { ...result, verified, elementRef };
917
- }
918
- return result;
919
- }
920
-
921
- /**
922
- * List currently active reversible patches.
923
- *
924
- * @returns {Array<{ patchId: string, kind: string, elementRef: string }>}
925
- */
926
- function listPatches() {
927
- return [...patchRegistry.entries()].map(([patchId, patch]) => ({
928
- patchId,
929
- kind: patch.kind,
930
- elementRef: patch.elementRef,
931
- }));
932
- }
933
-
934
- /**
935
- * Roll back a previously applied patch if it still exists.
936
- *
937
- * @param {string} patchId
938
- * @returns {{ patchId: string, rolledBack: boolean }}
939
- */
940
- function rollbackPatch(patchId) {
941
- const patch = patchRegistry.get(patchId);
942
- if (!patch) {
943
- return { patchId, rolledBack: false };
944
- }
945
-
946
- const element = getRequiredElement(patch.elementRef);
947
- if (patch.kind === 'style') {
948
- const htmlElement = /** @type {HTMLElement} */ (element);
949
- for (const [property, value] of Object.entries(patch.previous)) {
950
- if (value) {
951
- htmlElement.style.setProperty(property, value);
952
- } else {
953
- htmlElement.style.removeProperty(property);
954
- }
955
- }
956
- } else if (patch.kind === 'dom') {
957
- if (patch.operation === 'set_text' && patch.previous.text !== null) {
958
- element.textContent = patch.previous.text;
959
- } else if (patch.operation === 'toggle_class' && patch.previous.toggledClass) {
960
- const hasNow = element.classList.contains(patch.previous.toggledClass);
961
- if (hasNow !== patch.previous.hadClass) {
962
- element.classList.toggle(patch.previous.toggledClass);
963
- }
964
- } else {
965
- if (patch.previous.text !== null && patch.operation === 'set_text') {
966
- element.textContent = patch.previous.text;
967
- }
968
- for (const [name, value] of Object.entries(patch.previous.attributes || {})) {
969
- if (value == null) {
970
- element.removeAttribute(name);
971
- } else {
972
- element.setAttribute(name, value);
973
- }
974
- }
975
- }
976
- }
977
-
978
- patchRegistry.delete(patchId);
979
- return { patchId, rolledBack: true };
980
- }
981
-
982
- /**
983
- * Return the viewport rect for an element reference.
984
- *
985
- * @param {string} elementRef
986
- * @returns {{ x: number, y: number, width: number, height: number, scale: number }}
987
- */
988
- function getElementRect(elementRef) {
989
- const el = getRequiredElement(elementRef);
990
- // Scroll into view so CDP can capture it in the visible viewport
991
- el.scrollIntoView({
992
- block: 'center',
993
- inline: 'center',
994
- behavior: 'instant',
995
- });
996
- const rect = el.getBoundingClientRect();
997
- if (rect.width < 1 || rect.height < 1) {
998
- throw new Error(
999
- `Element has no visible area (${rect.width}\u00d7${rect.height}). ` +
1000
- 'It may be hidden, collapsed, or not yet rendered.'
1001
- );
1002
- }
1003
- const x = Math.max(0, rect.x);
1004
- const y = Math.max(0, rect.y);
1005
- const width = Math.max(0, Math.min(rect.width, window.innerWidth - x));
1006
- const height = Math.max(0, Math.min(rect.height, window.innerHeight - y));
1007
- if (width < 1 || height < 1) {
1008
- throw new Error(
1009
- 'Element is outside the visible viewport after scroll ' +
1010
- `(${Math.round(rect.x)},${Math.round(rect.y)} ${Math.round(rect.width)}\u00d7${Math.round(rect.height)}). ` +
1011
- 'It may be in a fixed/sticky container or an iframe.'
1012
- );
1013
- }
1014
- return { x, y, width, height, scale: window.devicePixelRatio || 1 };
1015
- }
1016
-
1017
- /**
1018
- * Return the full document dimensions for a full-page screenshot.
1019
- * Chrome enforces a 16384px maximum on CDP captureScreenshot clip dimensions.
1020
- *
1021
- * @returns {{ scrollWidth: number, scrollHeight: number, devicePixelRatio: number }}
1022
- */
1023
- function getFullPageDimensions() {
1024
- const el = document.scrollingElement || document.documentElement;
1025
- return {
1026
- scrollWidth: Math.min(el.scrollWidth, 16384),
1027
- scrollHeight: Math.min(el.scrollHeight, 16384),
1028
- devicePixelRatio: window.devicePixelRatio || 1,
1029
- };
1030
- }
1031
-
1032
- // ── New methods: DOM wait, find, HTML, hover, drag, storage ────────
1033
-
1034
- /**
1035
- * Wait for a DOM condition using MutationObserver + polling fallback.
1036
- *
1037
- * @param {Record<string, any>} params
1038
- * @returns {Promise<{ found: boolean, elementRef: string | null, duration: number }>}
1039
- */
1040
- function waitForDom(params) {
1041
- const selector = String(params.selector || '');
1042
- if (!selector) {
1043
- throw new Error('selector is required for dom.wait_for');
1044
- }
1045
- const text = params.text != null ? String(params.text) : null;
1046
- const waitState = params.state || 'attached';
1047
- const timeout = clamp(params.timeoutMs ?? 5000, 100, 30000);
1048
- const start = Date.now();
1049
-
1050
- /**
1051
- * @returns {{ found: boolean, element: Element | null }}
1052
- */
1053
- function check() {
1054
- if (waitState === 'detached') {
1055
- const exists = text
1056
- ? findElementWithText(selector, text) !== null
1057
- : document.querySelector(selector) !== null;
1058
- return { found: !exists, element: null };
1059
- }
1060
- const candidates = document.querySelectorAll(selector);
1061
- for (const el of candidates) {
1062
- if (text !== null && !elementMatchesText(el, text)) {
1063
- continue;
1064
- }
1065
- if (waitState === 'visible') {
1066
- const r = el.getBoundingClientRect();
1067
- if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') {
1068
- return { found: true, element: el };
1069
- }
1070
- } else if (waitState === 'hidden') {
1071
- const r = el.getBoundingClientRect();
1072
- if (r.width === 0 || r.height === 0 || getComputedStyle(el).visibility === 'hidden') {
1073
- return { found: true, element: el };
1074
- }
1075
- } else {
1076
- return { found: true, element: el };
1077
- }
1078
- }
1079
- return { found: false, element: null };
1080
- }
1081
-
1082
- const immediate = check();
1083
- if (immediate.found) {
1084
- return Promise.resolve({
1085
- found: true,
1086
- elementRef: immediate.element ? rememberElement(immediate.element) : null,
1087
- duration: 0,
1088
- });
1089
- }
1090
-
1091
- return new Promise((resolve) => {
1092
- /** @type {MutationObserver | null} */
1093
- let observer = null;
1094
- /** @type {ReturnType<typeof setTimeout> | null} */
1095
- let timeoutHandle = null;
1096
- /** @type {ReturnType<typeof setInterval> | null} */
1097
- let pollHandle = null;
1098
-
1099
- function cleanup() {
1100
- if (observer) observer.disconnect();
1101
- if (timeoutHandle) clearTimeout(timeoutHandle);
1102
- if (pollHandle) clearInterval(pollHandle);
1103
- }
1104
-
1105
- function tryResolve() {
1106
- const result = check();
1107
- if (result.found) {
1108
- cleanup();
1109
- resolve({
1110
- found: true,
1111
- elementRef: result.element ? rememberElement(result.element) : null,
1112
- duration: Date.now() - start,
1113
- });
1114
- }
1115
- }
1116
-
1117
- observer = new MutationObserver(tryResolve);
1118
- observer.observe(document.documentElement, {
1119
- childList: true,
1120
- subtree: true,
1121
- attributes: true,
1122
- characterData: true,
1123
- });
1124
- pollHandle = setInterval(tryResolve, 250);
1125
- timeoutHandle = setTimeout(() => {
1126
- cleanup();
1127
- resolve({
1128
- found: false,
1129
- elementRef: null,
1130
- duration: Date.now() - start,
1131
- });
1132
- }, timeout);
1133
- });
1134
- }
1135
-
1136
- /**
1137
- * Find elements matching visible text content.
1138
- *
1139
- * @param {Record<string, any>} params
1140
- * @returns {{ nodes: NodeSummary[], count: number }}
1141
- */
1142
- function findByText(params) {
1143
- const searchText = String(params.text || '');
1144
- if (!searchText) {
1145
- throw new Error('text is required for dom.find_by_text');
1146
- }
1147
- const exact = Boolean(params.exact);
1148
- const scope = String(params.selector || '*');
1149
- const maxResults = clamp(params.maxResults ?? 10, 1, 50);
1150
- const candidates = document.querySelectorAll(scope);
1151
- const results = [];
1152
-
1153
- for (const el of candidates) {
1154
- if (results.length >= maxResults) break;
1155
- const visibleText = extractElementText(el);
1156
- if (!visibleText) continue;
1157
- const matches = exact
1158
- ? visibleText === searchText
1159
- : visibleText.toLowerCase().includes(searchText.toLowerCase());
1160
- if (matches) {
1161
- results.push(
1162
- summarizeNode(el, ['id', 'class', 'role', 'href', 'data-testid'], 120, true).node
1163
- );
1164
- }
1165
- }
1166
-
1167
- return { nodes: results, count: results.length };
1168
- }
1169
-
1170
- /**
1171
- * Find elements matching ARIA role and optional accessible name.
1172
- *
1173
- * @param {Record<string, any>} params
1174
- * @returns {{ nodes: NodeSummary[], count: number }}
1175
- */
1176
- function findByRole(params) {
1177
- const role = String(params.role || '');
1178
- if (!role) {
1179
- throw new Error('role is required for dom.find_by_role');
1180
- }
1181
- const name = params.name ? String(params.name) : null;
1182
- const scope = String(params.selector || '*');
1183
- const maxResults = clamp(params.maxResults ?? 10, 1, 50);
1184
-
1185
- const implicitSelector = getImplicitRoleSelector(role);
1186
- const attrSelector = `[role="${CSS.escape(role)}"]`;
1187
- const combinedSelector =
1188
- scope === '*'
1189
- ? implicitSelector
1190
- ? `${attrSelector}, ${implicitSelector}`
1191
- : attrSelector
1192
- : scope;
1193
- const candidates = document.querySelectorAll(combinedSelector);
1194
- const results = [];
1195
-
1196
- for (const el of candidates) {
1197
- if (results.length >= maxResults) break;
1198
- const elRole = el.getAttribute('role') || getImplicitRole(el);
1199
- if (elRole !== role) continue;
1200
- if (name !== null) {
1201
- const accName =
1202
- el.getAttribute('aria-label') ||
1203
- el.getAttribute('aria-labelledby') ||
1204
- el.getAttribute('title') ||
1205
- extractElementText(el);
1206
- if (!accName || !accName.toLowerCase().includes(name.toLowerCase())) {
1207
- continue;
1208
- }
1209
- }
1210
- results.push(
1211
- summarizeNode(el, ['id', 'class', 'role', 'aria-label', 'href'], 120, true).node
1212
- );
1213
- }
1214
-
1215
- return { nodes: results, count: results.length };
1216
- }
1217
-
1218
- /**
1219
- * Return innerHTML or outerHTML of an element, truncated to budget.
1220
- *
1221
- * @param {Record<string, any>} params
1222
- * @returns {{ html: string, truncated: boolean, omitted: number }}
1223
- */
1224
- function getHtml(params) {
1225
- const element = getRequiredElement(String(params.elementRef || ''));
1226
- const outer = Boolean(params.outer);
1227
- const maxLength = clamp(params.maxLength ?? 2000, 32, 50000);
1228
- const raw = outer ? element.outerHTML : element.innerHTML;
1229
- const t = truncateText(raw, maxLength);
1230
- return { html: t.value, truncated: t.truncated, omitted: t.omitted };
1231
- }
1232
-
1233
- /**
1234
- * Trigger hover state on an element by dispatching mouse events.
1235
- *
1236
- * @param {Record<string, any>} params
1237
- * @returns {Promise<{ elementRef: string, hovered: boolean }> | { elementRef: string, hovered: boolean }}
1238
- */
1239
- function hoverTarget(params) {
1240
- const element = resolveTarget(params.target);
1241
- const point = getViewportPoint(element);
1242
- const modifiers = normalizeModifierState(params.modifiers);
1243
- const duration = clamp(params.duration ?? 0, 0, 5000);
1244
-
1245
- scrollTargetIntoView(element);
1246
- dispatchMouseEvent(element, 'mouseenter', point, 'left', 0, modifiers);
1247
- dispatchMouseEvent(element, 'mouseover', point, 'left', 0, modifiers);
1248
- dispatchMouseEvent(element, 'mousemove', point, 'left', 0, modifiers);
1249
-
1250
- const ref = rememberElement(element);
1251
- if (duration > 0) {
1252
- return new Promise((resolve) => {
1253
- setTimeout(() => {
1254
- resolve({ elementRef: ref, hovered: true });
1255
- }, duration);
1256
- });
1257
- }
1258
- return { elementRef: ref, hovered: true };
1259
- }
1260
-
1261
- /**
1262
- * Perform a drag-and-drop operation between two elements.
1263
- *
1264
- * @param {Record<string, any>} params
1265
- * @returns {{ sourceRef: string, destinationRef: string, dragged: boolean }}
1266
- */
1267
- function dragTarget(params) {
1268
- const source = resolveTarget(params.source);
1269
- const destination = resolveTarget(params.destination);
1270
- const sourcePoint = getViewportPoint(source);
1271
- const destPoint = getViewportPoint(destination);
1272
- const offsetX = Number(params.offsetX) || 0;
1273
- const offsetY = Number(params.offsetY) || 0;
1274
- const endPoint = { x: destPoint.x + offsetX, y: destPoint.y + offsetY };
1275
- const emptyMods = {
1276
- altKey: false,
1277
- ctrlKey: false,
1278
- metaKey: false,
1279
- shiftKey: false,
1280
- };
1281
-
1282
- scrollTargetIntoView(source);
1283
-
1284
- const dataTransfer = new DataTransfer();
1285
-
1286
- source.dispatchEvent(
1287
- new MouseEvent('mousedown', {
1288
- bubbles: true,
1289
- cancelable: true,
1290
- composed: true,
1291
- clientX: sourcePoint.x,
1292
- clientY: sourcePoint.y,
1293
- ...emptyMods,
1294
- })
1295
- );
1296
- source.dispatchEvent(
1297
- new DragEvent('dragstart', {
1298
- bubbles: true,
1299
- cancelable: true,
1300
- composed: true,
1301
- clientX: sourcePoint.x,
1302
- clientY: sourcePoint.y,
1303
- dataTransfer,
1304
- })
1305
- );
1306
- source.dispatchEvent(
1307
- new DragEvent('drag', {
1308
- bubbles: true,
1309
- cancelable: true,
1310
- composed: true,
1311
- clientX: sourcePoint.x,
1312
- clientY: sourcePoint.y,
1313
- dataTransfer,
1314
- })
1315
- );
1316
-
1317
- scrollTargetIntoView(destination);
1318
-
1319
- destination.dispatchEvent(
1320
- new DragEvent('dragenter', {
1321
- bubbles: true,
1322
- cancelable: true,
1323
- composed: true,
1324
- clientX: endPoint.x,
1325
- clientY: endPoint.y,
1326
- dataTransfer,
1327
- })
1328
- );
1329
- destination.dispatchEvent(
1330
- new DragEvent('dragover', {
1331
- bubbles: true,
1332
- cancelable: true,
1333
- composed: true,
1334
- clientX: endPoint.x,
1335
- clientY: endPoint.y,
1336
- dataTransfer,
1337
- })
1338
- );
1339
- destination.dispatchEvent(
1340
- new DragEvent('drop', {
1341
- bubbles: true,
1342
- cancelable: true,
1343
- composed: true,
1344
- clientX: endPoint.x,
1345
- clientY: endPoint.y,
1346
- dataTransfer,
1347
- })
1348
- );
1349
- source.dispatchEvent(
1350
- new DragEvent('dragend', {
1351
- bubbles: true,
1352
- cancelable: true,
1353
- composed: true,
1354
- clientX: endPoint.x,
1355
- clientY: endPoint.y,
1356
- dataTransfer,
1357
- })
1358
- );
1359
- source.dispatchEvent(
1360
- new MouseEvent('mouseup', {
1361
- bubbles: true,
1362
- cancelable: true,
1363
- composed: true,
1364
- clientX: endPoint.x,
1365
- clientY: endPoint.y,
1366
- ...emptyMods,
1367
- })
1368
- );
1369
-
1370
- return {
1371
- sourceRef: rememberElement(source),
1372
- destinationRef: rememberElement(destination),
1373
- dragged: true,
1374
- };
1375
- }
1376
-
1377
- /**
1378
- * Scroll an element into the visible viewport.
1379
- *
1380
- * @param {Record<string, any>} params
1381
- * @returns {{ elementRef: string, scrolled: boolean }}
1382
- */
1383
- function scrollIntoViewTarget(params) {
1384
- const element = resolveTarget(params.target);
1385
- scrollTargetIntoView(element);
1386
- return { elementRef: rememberElement(element), scrolled: true };
1387
- }
1388
-
1389
- /**
1390
- * Read localStorage or sessionStorage entries.
1391
- *
1392
- * @param {Record<string, any>} params
1393
- * @returns {{ type: string, entries: Record<string, string | null>, count: number }}
1394
- */
1395
- function getStorageData(params) {
1396
- const type = params.type === 'session' ? 'session' : 'local';
1397
- const storage = type === 'session' ? sessionStorage : localStorage;
1398
- const keys = Array.isArray(params.keys)
1399
- ? params.keys.filter((k) => typeof k === 'string')
1400
- : null;
1401
- /** @type {Record<string, string | null>} */
1402
- const result = {};
1403
- if (keys) {
1404
- for (const key of keys) {
1405
- result[key] = storage.getItem(key);
1406
- }
1407
- } else {
1408
- for (let i = 0; i < Math.min(storage.length, 100); i++) {
1409
- const key = storage.key(i);
1410
- if (key !== null) {
1411
- const val = storage.getItem(key);
1412
- result[key] = val !== null && val.length > 500 ? val.slice(0, 500) + '\u2026' : val;
1413
- }
1414
- }
1415
- }
1416
- return { type, entries: result, count: Object.keys(result).length };
1417
- }
1418
-
1419
- // ── Helpers for new methods ────────────────────────────────────────
1420
-
1421
- /**
1422
- * Check whether an element's visible text contains the given string.
1423
- *
1424
- * @param {Element} element
1425
- * @param {string} text
1426
- * @returns {boolean}
1427
- */
1428
- function elementMatchesText(element, text) {
1429
- const visible = extractElementText(element);
1430
- return visible.toLowerCase().includes(text.toLowerCase());
1431
- }
1432
-
1433
- /**
1434
- * Find the first element matching a selector whose text contains a string.
1435
- *
1436
- * @param {string} selector
1437
- * @param {string} text
1438
- * @returns {Element | null}
1439
- */
1440
- function findElementWithText(selector, text) {
1441
- for (const el of document.querySelectorAll(selector)) {
1442
- if (elementMatchesText(el, text)) {
1443
- return el;
1444
- }
1445
- }
1446
- return null;
1447
- }
1448
-
1449
- /**
1450
- * Resolve a patch target from either an element reference or a selector.
1451
- *
1452
- * @param {{ elementRef?: string, selector?: string }} [target={}]
1453
- * @returns {Element}
1454
- */
1455
- function resolveTarget(target = {}) {
1456
- if (target.elementRef) {
1457
- return getRequiredElement(target.elementRef);
1458
- }
1459
- if (target.selector) {
1460
- const element = document.querySelector(target.selector);
1461
- if (element) {
1462
- return element;
1463
- }
1464
- }
1465
- throw new Error('Target not found.');
1466
- }
1467
-
1468
- /**
1469
- * Resolve element-level read params from either a legacy top-level
1470
- * `elementRef` or the newer `target` alias.
1471
- *
1472
- * @param {{ elementRef?: string, target?: { elementRef?: string, selector?: string } }} [params={}]
1473
- * @returns {string}
1474
- */
1475
- function resolveElementRefFromParams(params = {}) {
1476
- if (typeof params.elementRef === 'string' && params.elementRef) {
1477
- return params.elementRef;
1478
- }
1479
- if (params.target && typeof params.target === 'object') {
1480
- return rememberElement(resolveTarget(params.target));
1481
- }
1482
- throw new Error('Element target not found.');
1483
- }
1484
-
1485
- /**
1486
- * @param {{ elementRef?: string, selector?: string }} [target={}]
1487
- * @returns {HTMLInputElement}
1488
- */
1489
- function resolveCheckableTarget(target = {}) {
1490
- const element = resolveTarget(target);
1491
- if (
1492
- element instanceof HTMLInputElement &&
1493
- ['checkbox', 'radio'].includes(element.type.toLowerCase())
1494
- ) {
1495
- return element;
1496
- }
1497
-
1498
- if (element instanceof HTMLElement) {
1499
- const nested = element.querySelector('input[type="checkbox"], input[type="radio"]');
1500
- if (nested instanceof HTMLInputElement) {
1501
- return nested;
1502
- }
1503
- }
1504
-
1505
- throw new Error('Target is not a checkbox or radio input.');
1506
- }
1507
-
1508
- /**
1509
- * @param {{ elementRef?: string, selector?: string }} [target={}]
1510
- * @returns {HTMLSelectElement}
1511
- */
1512
- function resolveSelectTarget(target = {}) {
1513
- const element = resolveTarget(target);
1514
- if (element instanceof HTMLSelectElement) {
1515
- return element;
1516
- }
1517
-
1518
- if (
1519
- element instanceof HTMLOptionElement &&
1520
- element.parentElement instanceof HTMLSelectElement
1521
- ) {
1522
- return element.parentElement;
1523
- }
1524
-
1525
- if (element instanceof HTMLElement) {
1526
- const nested = element.querySelector('select');
1527
- if (nested instanceof HTMLSelectElement) {
1528
- return nested;
1529
- }
1530
- }
1531
-
1532
- throw new Error('Target is not a select control.');
1533
- }
1534
-
1535
- /**
1536
- * @param {Element} element
1537
- * @returns {HTMLElement}
1538
- */
1539
- function getScrollableElementTarget(element) {
1540
- if (element instanceof HTMLElement) {
1541
- return element;
1542
- }
1543
- if (document.scrollingElement instanceof HTMLElement) {
1544
- return document.scrollingElement;
1545
- }
1546
- return document.documentElement;
1547
- }
1548
-
1549
- /**
1550
- * Resolve an existing element reference and verify it is still attached.
1551
- *
1552
- * @param {string} elementRef
1553
- * @returns {Element}
1554
- */
1555
- function getRequiredElement(elementRef) {
1556
- const element = elementRegistry.get(elementRef);
1557
- if (!element) {
1558
- throw new Error('Element reference is stale.');
1559
- }
1560
- if (!document.contains(element)) {
1561
- elementRegistry.delete(elementRef);
1562
- reverseRegistry.delete(element);
1563
- throw new Error('Element reference is stale.');
1564
- }
1565
- return element;
1566
- }
1567
-
1568
- /**
1569
- * Reuse or create a stable element reference for later bridge calls.
1570
- * Uses a reverse WeakMap for O(1) lookup instead of scanning the registry.
1571
- *
1572
- * @param {Element} element
1573
- * @returns {string}
1574
- */
1575
- function rememberElement(element) {
1576
- const existing = reverseRegistry.get(element);
1577
- if (existing && elementRegistry.has(existing)) {
1578
- return existing;
1579
- }
1580
- // Prune stale entries when registry grows too large
1581
- if (elementRegistry.size >= MAX_REGISTRY_SIZE) {
1582
- pruneElementRegistry();
1583
- }
1584
- const elementRef = `el_${crypto.randomUUID()}`;
1585
- elementRegistry.set(elementRef, element);
1586
- reverseRegistry.set(element, elementRef);
1587
- return elementRef;
1588
- }
1589
-
1590
- /**
1591
- * Remove entries for elements no longer in the document, keeping the
1592
- * registry bounded.
1593
- *
1594
- * @returns {void}
1595
- */
1596
- function pruneElementRegistry() {
1597
- for (const [ref, element] of elementRegistry.entries()) {
1598
- if (!document.contains(element)) {
1599
- elementRegistry.delete(ref);
1600
- registryPruned = true;
1601
- }
1602
- }
1603
- }
1604
-
1605
- /**
1606
- * @param {Map<any, any>} registry
1607
- * @param {number} maxSize
1608
- * @returns {void}
1609
- */
1610
- function pruneRegistry(registry, maxSize) {
1611
- if (registry.size < maxSize) return;
1612
- const excess = registry.size - maxSize;
1613
- let count = 0;
1614
- for (const key of registry.keys()) {
1615
- if (count >= excess) break;
1616
- registry.delete(key);
1617
- count++;
1618
- }
1619
- }
1620
-
1621
- /**
1622
- * Keep the target visible before dispatching interaction events.
1623
- *
1624
- * @param {Element} element
1625
- * @returns {void}
1626
- */
1627
- function scrollTargetIntoView(element) {
1628
- element.scrollIntoView({
1629
- block: 'center',
1630
- inline: 'center',
1631
- });
1632
- }
1633
-
1634
- /**
1635
- * Focus an element when the platform allows it.
1636
- *
1637
- * @param {Element} element
1638
- * @returns {Element}
1639
- */
1640
- function focusElement(element) {
1641
- if ('focus' in element && typeof element.focus === 'function') {
1642
- element.focus({
1643
- preventScroll: true,
1644
- });
1645
- }
1646
-
1647
- return document.activeElement instanceof Element ? document.activeElement : element;
1648
- }
1649
-
1650
- /**
1651
- * @param {Element} element
1652
- * @returns {boolean}
1653
- */
1654
- function isElementFocused(element) {
1655
- return document.activeElement === element || element.contains(document.activeElement);
1656
- }
1657
-
1658
- /**
1659
- * @param {Element} element
1660
- * @returns {{ x: number, y: number }}
1661
- */
1662
- function getViewportPoint(element) {
1663
- const rect = element.getBoundingClientRect();
1664
- return {
1665
- x: rect.left + rect.width / 2,
1666
- y: rect.top + rect.height / 2,
1667
- };
1668
- }
1669
-
1670
- /**
1671
- * @param {unknown} value
1672
- * @returns {'left' | 'middle' | 'right'}
1673
- */
1674
- function normalizeMouseButton(value) {
1675
- return value === 'middle' || value === 'right' ? value : 'left';
1676
- }
1677
-
1678
- /**
1679
- * @param {unknown} value
1680
- * @returns {{ altKey: boolean, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean }}
1681
- */
1682
- function normalizeModifierState(value) {
1683
- const modifiers = Array.isArray(value)
1684
- ? value.filter((modifier) => typeof modifier === 'string')
1685
- : [];
1686
- return {
1687
- altKey: modifiers.includes('Alt'),
1688
- ctrlKey: modifiers.includes('Control') || modifiers.includes('Ctrl'),
1689
- metaKey: modifiers.includes('Meta') || modifiers.includes('Command'),
1690
- shiftKey: modifiers.includes('Shift'),
1691
- };
1692
- }
1693
-
1694
- /**
1695
- * @param {'left' | 'middle' | 'right'} button
1696
- * @returns {{ button: number, buttons: number }}
1697
- */
1698
- function getMouseButtonState(button) {
1699
- switch (button) {
1700
- case 'middle':
1701
- return { button: 1, buttons: 4 };
1702
- case 'right':
1703
- return { button: 2, buttons: 2 };
1704
- default:
1705
- return { button: 0, buttons: 1 };
1706
- }
1707
- }
1708
-
1709
- /**
1710
- * @param {Element} element
1711
- * @param {string} type
1712
- * @param {{ x: number, y: number }} point
1713
- * @param {'left' | 'middle' | 'right'} button
1714
- * @param {number} detail
1715
- * @param {{ altKey: boolean, ctrlKey: boolean, metaKey: boolean, shiftKey: boolean }} modifiers
1716
- * @returns {boolean}
1717
- */
1718
- function dispatchMouseEvent(element, type, point, button, detail, modifiers) {
1719
- const buttonState = getMouseButtonState(button);
1720
- return element.dispatchEvent(
1721
- new MouseEvent(type, {
1722
- bubbles: true,
1723
- cancelable: true,
1724
- composed: true,
1725
- clientX: point.x,
1726
- clientY: point.y,
1727
- detail,
1728
- button: buttonState.button,
1729
- buttons: buttonState.buttons,
1730
- ...modifiers,
1731
- })
1732
- );
1733
- }
1734
-
1735
- /**
1736
- * @param {Element} element
1737
- * @returns {HTMLInputElement | HTMLTextAreaElement | HTMLElement | null}
1738
- */
1739
- function getEditableTarget(element) {
1740
- if (isEditableElement(element)) {
1741
- return /** @type {HTMLInputElement | HTMLTextAreaElement | HTMLElement} */ (element);
1742
- }
1743
-
1744
- if (!(element instanceof HTMLElement)) {
1745
- return null;
1746
- }
1747
-
1748
- const editable = element.querySelector(
1749
- "input, textarea, [contenteditable=''], [contenteditable='true']"
1750
- );
1751
- return editable && isEditableElement(editable)
1752
- ? /** @type {HTMLInputElement | HTMLTextAreaElement | HTMLElement} */ (editable)
1753
- : null;
1754
- }
1755
-
1756
- /**
1757
- * @param {Element} element
1758
- * @returns {boolean}
1759
- */
1760
- function isEditableElement(element) {
1761
- if (element instanceof HTMLTextAreaElement) {
1762
- return true;
1763
- }
1764
-
1765
- if (element instanceof HTMLInputElement) {
1766
- return !NON_TEXT_INPUT_TYPES.has(element.type.toLowerCase());
1767
- }
1768
-
1769
- return element instanceof HTMLElement && element.isContentEditable;
1770
- }
1771
-
1772
- /**
1773
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1774
- * @returns {string}
1775
- */
1776
- function getEditableValue(element) {
1777
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
1778
- return element.value;
1779
- }
1780
-
1781
- return element.innerText || element.textContent || '';
1782
- }
1783
-
1784
- /**
1785
- * @param {HTMLSelectElement} element
1786
- * @returns {string[]}
1787
- */
1788
- function getSelectedOptionValues(element) {
1789
- return [...element.selectedOptions].map((option) => option.value);
1790
- }
1791
-
1792
- /**
1793
- * @param {string[]} left
1794
- * @param {string[]} right
1795
- * @returns {boolean}
1796
- */
1797
- function areStringArraysEqual(left, right) {
1798
- if (left.length !== right.length) {
1799
- return false;
1800
- }
1801
-
1802
- return left.every((value, index) => value === right[index]);
1803
- }
1804
-
1805
- /**
1806
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1807
- * @returns {void}
1808
- */
1809
- function clearEditableValue(element) {
1810
- if (!getEditableValue(element)) {
1811
- return;
1812
- }
1813
-
1814
- dispatchKeyboardEvent(element, 'keydown', 'Backspace', {});
1815
- if (dispatchBeforeInputEvent(element, '', 'deleteContentBackward')) {
1816
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
1817
- element.value = '';
1818
- } else {
1819
- element.textContent = '';
1820
- }
1821
- dispatchInputEvent(element, '', 'deleteContentBackward');
1822
- }
1823
- dispatchKeyboardEvent(element, 'keyup', 'Backspace', {});
1824
- }
1825
-
1826
- /**
1827
- * @param {Element} element
1828
- * @param {string} key
1829
- * @param {unknown} modifiers
1830
- * @returns {{ target: Element, key: string, handled: boolean }}
1831
- */
1832
- function runKeyAction(element, key, modifiers) {
1833
- const normalizedKey = key === 'Space' ? ' ' : key;
1834
- const keyboardTarget = focusElement(element);
1835
- const modifierState = normalizeModifierState(modifiers);
1836
- dispatchKeyboardEvent(keyboardTarget, 'keydown', normalizedKey, modifierState);
1837
-
1838
- let handled = false;
1839
- const editable = getEditableTarget(keyboardTarget);
1840
- if (
1841
- editable &&
1842
- normalizedKey.length === 1 &&
1843
- !modifierState.altKey &&
1844
- !modifierState.ctrlKey &&
1845
- !modifierState.metaKey
1846
- ) {
1847
- handled = insertTextIntoEditable(editable, normalizedKey);
1848
- } else if (editable && normalizedKey === 'Backspace') {
1849
- handled = deleteTextFromEditable(editable, 'backward');
1850
- } else if (editable && normalizedKey === 'Delete') {
1851
- handled = deleteTextFromEditable(editable, 'forward');
1852
- } else if (normalizedKey === 'Enter') {
1853
- handled = handleEnterKey(keyboardTarget);
1854
- }
1855
-
1856
- dispatchKeyboardEvent(keyboardTarget, 'keyup', normalizedKey, modifierState);
1857
- return {
1858
- target: keyboardTarget,
1859
- key: normalizedKey,
1860
- handled,
1861
- };
1862
- }
1863
-
1864
- /**
1865
- * @param {Element} element
1866
- * @param {string} type
1867
- * @param {string} key
1868
- * @param {{ altKey?: boolean, ctrlKey?: boolean, metaKey?: boolean, shiftKey?: boolean }} modifiers
1869
- * @returns {boolean}
1870
- */
1871
- function dispatchKeyboardEvent(element, type, key, modifiers) {
1872
- return element.dispatchEvent(
1873
- new KeyboardEvent(type, {
1874
- key,
1875
- bubbles: true,
1876
- cancelable: true,
1877
- composed: true,
1878
- ...modifiers,
1879
- })
1880
- );
1881
- }
1882
-
1883
- /**
1884
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1885
- * @param {string} value
1886
- * @param {string} inputType
1887
- * @returns {boolean}
1888
- */
1889
- function dispatchBeforeInputEvent(element, value, inputType) {
1890
- return element.dispatchEvent(
1891
- new InputEvent('beforeinput', {
1892
- data: value,
1893
- inputType,
1894
- bubbles: true,
1895
- cancelable: true,
1896
- composed: true,
1897
- })
1898
- );
1899
- }
1900
-
1901
- /**
1902
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1903
- * @param {string} value
1904
- * @param {string} inputType
1905
- * @returns {boolean}
1906
- */
1907
- function dispatchInputEvent(element, value, inputType) {
1908
- return element.dispatchEvent(
1909
- new InputEvent('input', {
1910
- data: value,
1911
- inputType,
1912
- bubbles: true,
1913
- composed: true,
1914
- })
1915
- );
1916
- }
1917
-
1918
- /**
1919
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1920
- * @param {string} value
1921
- * @returns {boolean}
1922
- */
1923
- function insertTextIntoEditable(element, value) {
1924
- if (!dispatchBeforeInputEvent(element, value, 'insertText')) {
1925
- return false;
1926
- }
1927
-
1928
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
1929
- const start = element.selectionStart ?? element.value.length;
1930
- const end = element.selectionEnd ?? element.value.length;
1931
- element.setRangeText(value, start, end, 'end');
1932
- } else {
1933
- element.textContent = `${element.textContent || ''}${value}`;
1934
- }
1935
-
1936
- dispatchInputEvent(element, value, 'insertText');
1937
- return true;
1938
- }
1939
-
1940
- /**
1941
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
1942
- * @param {'backward' | 'forward'} direction
1943
- * @returns {boolean}
1944
- */
1945
- function deleteTextFromEditable(element, direction) {
1946
- const inputType = direction === 'backward' ? 'deleteContentBackward' : 'deleteContentForward';
1947
- if (!dispatchBeforeInputEvent(element, '', inputType)) {
1948
- return false;
1949
- }
1950
-
1951
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
1952
- const start = element.selectionStart ?? element.value.length;
1953
- const end = element.selectionEnd ?? element.value.length;
1954
- if (start !== end) {
1955
- element.setRangeText('', start, end, 'end');
1956
- } else if (direction === 'backward' && start > 0) {
1957
- element.setRangeText('', start - 1, start, 'end');
1958
- } else if (direction === 'forward' && end < element.value.length) {
1959
- element.setRangeText('', end, end + 1, 'end');
1960
- }
1961
- } else {
1962
- const text = element.textContent || '';
1963
- element.textContent =
1964
- direction === 'backward' ? text.slice(0, Math.max(0, text.length - 1)) : text.slice(1);
1965
- }
1966
-
1967
- dispatchInputEvent(element, '', inputType);
1968
- return true;
1969
- }
1970
-
1971
- /**
1972
- * @param {Element} element
1973
- * @returns {boolean}
1974
- */
1975
- function handleEnterKey(element) {
1976
- const editable = getEditableTarget(element);
1977
- if (
1978
- editable instanceof HTMLTextAreaElement ||
1979
- (editable instanceof HTMLElement && editable.isContentEditable)
1980
- ) {
1981
- return insertTextIntoEditable(editable, '\n');
1982
- }
1983
-
1984
- if (editable instanceof HTMLInputElement) {
1985
- submitElement(editable);
1986
- return true;
1987
- }
1988
-
1989
- if (
1990
- element instanceof HTMLButtonElement ||
1991
- (element instanceof HTMLInputElement && ['button', 'submit'].includes(element.type))
1992
- ) {
1993
- element.click();
1994
- return true;
1995
- }
1996
-
1997
- const form = element instanceof HTMLElement ? element.closest('form') : null;
1998
- if (form) {
1999
- form.requestSubmit();
2000
- return true;
2001
- }
2002
-
2003
- return false;
2004
- }
2005
-
2006
- /**
2007
- * @param {HTMLInputElement | HTMLTextAreaElement | HTMLElement} element
2008
- * @returns {void}
2009
- */
2010
- function submitElement(element) {
2011
- const form = element instanceof HTMLElement ? element.closest('form') : null;
2012
- if (form) {
2013
- form.requestSubmit();
2014
- element.dispatchEvent(new Event('change', { bubbles: true }));
2015
- }
2016
- }
2017
-
2018
- /**
2019
- * Return a cheap document revision marker for change detection.
2020
- *
2021
- * @returns {number}
2022
- */
2023
- function getDocumentRevision() {
2024
- return (document.body?.textContent || '').length;
2025
- }
2026
-
2027
- /**
2028
- * Keep the content script self-contained because manifest-declared
2029
- * content scripts are classic scripts, not ES modules.
2030
- *
2031
- * @param {Record<string, any>} [params={}]
2032
- * @returns {NormalizedDomQuery}
2033
- */
2034
- function normalizeDomQuery(params = {}) {
2035
- const rawSelector =
2036
- typeof params.selector === 'string' && params.selector.trim() ? params.selector : 'body';
2037
- return {
2038
- selector: escapeTailwindSelector(rawSelector),
2039
- withinRef: typeof params.withinRef === 'string' ? params.withinRef : null,
2040
- budget: applyBudget(params),
2041
- };
2042
- }
2043
- })();