@browserbridge/bbx 1.11.5 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ // @ts-check
2
+
3
+ import { randomUUID } from 'node:crypto';
4
+ import { sanitizeIncidentalText } from '../../protocol/src/index.js';
5
+ import { parseWindowActionRequest } from '../../protocol/src/window-actions.js';
6
+ import { writeJsonLine } from './framing.js';
7
+
8
+ /** @typedef {import('./daemon.js').ClientSocket} ClientSocket */
9
+ /** @typedef {{ source: ClientSocket, target: ClientSocket, finish: (error: string | null) => void }} PendingWindowAction */
10
+
11
+ /** Relay only user UI actions between authenticated local extensions. */
12
+ export class WindowActionRouter {
13
+ /** @param {Map<string, ClientSocket>} extensions */
14
+ constructor(extensions) {
15
+ this.extensions = extensions;
16
+ /** @type {Map<string, PendingWindowAction>} */
17
+ this.pending = new Map();
18
+ }
19
+
20
+ /**
21
+ * @param {ClientSocket} source
22
+ * @param {Record<string, unknown>} message
23
+ * @returns {Promise<void>}
24
+ */
25
+ async request(source, message) {
26
+ const request = parseWindowActionRequest(message);
27
+ const target = request ? this.extensions.get(request.extensionId) : null;
28
+ /** @type {string | null} */
29
+ let error = null;
30
+ if (
31
+ !request ||
32
+ source.__role !== 'extension' ||
33
+ !source.__windowActions ||
34
+ this.extensions.get(source.__extensionId ?? '') !== source
35
+ ) {
36
+ error = 'Invalid window action request.';
37
+ } else if (!target || target.destroyed || target === source) {
38
+ error = 'The other browser disconnected. Refresh the list and try again.';
39
+ } else if (!target.__windowActions || !target.__enabledWindow) {
40
+ error = "Update the other browser's extension and native host to control its window.";
41
+ } else if (
42
+ !target.__accessEnabled ||
43
+ target.__enabledWindow.windowId !== request.windowId ||
44
+ target.__enabledWindow.enabledAt !== request.enabledAt
45
+ ) {
46
+ error = 'Window access changed. Refresh the list and try again.';
47
+ } else if (
48
+ this.pending.size >= 64 ||
49
+ [...this.pending.values()].filter((entry) => entry.source === source).length >= 16
50
+ ) {
51
+ error = 'Too many window actions are pending. Try again shortly.';
52
+ } else {
53
+ const requestId = randomUUID();
54
+ error = await new Promise((resolve) => {
55
+ const timer = setTimeout(
56
+ () => finish('The other browser did not respond. Try again.'),
57
+ 5_000
58
+ );
59
+ /** @param {string | null} failure */
60
+ const finish = (failure) => {
61
+ if (!this.pending.delete(requestId)) return;
62
+ clearTimeout(timer);
63
+ resolve(failure);
64
+ };
65
+ this.pending.set(requestId, { source, target, finish });
66
+ void writeJsonLine(target, {
67
+ type: 'extension.window_action.command',
68
+ ...request,
69
+ requestId,
70
+ }).catch(() => finish('The other browser disconnected. Try again.'));
71
+ });
72
+ }
73
+ if (!source.destroyed) {
74
+ await writeJsonLine(source, {
75
+ type: 'extension.window_action.response',
76
+ requestId:
77
+ request?.requestId ??
78
+ (typeof message.requestId === 'string' ? message.requestId.slice(0, 80) : 'invalid'),
79
+ ok: error === null,
80
+ ...(error ? { error } : {}),
81
+ });
82
+ }
83
+ }
84
+
85
+ /**
86
+ * @param {ClientSocket} source
87
+ * @param {Record<string, unknown>} message
88
+ * @returns {void}
89
+ */
90
+ result(source, message) {
91
+ const pending =
92
+ typeof message.requestId === 'string' ? this.pending.get(message.requestId) : null;
93
+ if (!pending || pending.target !== source) return;
94
+ pending.finish(
95
+ message.ok === true
96
+ ? null
97
+ : typeof message.error === 'string'
98
+ ? sanitizeIncidentalText(message.error).slice(0, 200) || 'Window action failed.'
99
+ : 'Window action failed.'
100
+ );
101
+ }
102
+
103
+ /** @param {ClientSocket} socket @returns {void} */
104
+ disconnect(socket) {
105
+ for (const pending of this.pending.values()) {
106
+ if (pending.source === socket || pending.target === socket)
107
+ pending.finish('The browser disconnected. Try again.');
108
+ }
109
+ }
110
+
111
+ /** @returns {void} */
112
+ clear() {
113
+ for (const pending of this.pending.values()) pending.finish('The daemon stopped. Try again.');
114
+ }
115
+ }
@@ -415,6 +415,11 @@ export function validateBridgeRequest(request) {
415
415
  source: meta.source === 'cli' || meta.source === 'mcp' ? meta.source : undefined,
416
416
  mcp_era: normalizeMcpEra(meta.source, meta.mcp_era),
417
417
  automatic_retry: normalizeAutomaticRetryMeta(meta.source, meta.automatic_retry),
418
+ agent_session:
419
+ typeof meta.agent_session === 'string' &&
420
+ /^[A-Za-z0-9_.:-]{1,128}$/.test(meta.agent_session.trim())
421
+ ? meta.agent_session.trim()
422
+ : undefined,
418
423
  },
419
424
  };
420
425
  }
@@ -456,13 +461,14 @@ function normalizeAutomaticRetryMeta(source, value) {
456
461
 
457
462
  /**
458
463
  * Normalize request params early so malformed payloads fail at the shared
459
- * protocol boundary before daemon or extension dispatch.
464
+ * protocol boundary before daemon or extension dispatch. Exported so later
465
+ * stages re-normalize with the same per-method options (auto-wait, observe).
460
466
  *
461
467
  * @param {BridgeMethod} method
462
468
  * @param {Record<string, unknown>} params
463
469
  * @returns {Record<string, unknown>}
464
470
  */
465
- function normalizeRequestParams(method, params) {
471
+ export function normalizeRequestParams(method, params) {
466
472
  switch (method) {
467
473
  case 'access.request':
468
474
  return normalizeAccessRequestParams(params);
@@ -526,7 +532,7 @@ function normalizeRequestParams(method, params) {
526
532
  case 'input.type':
527
533
  case 'input.fill':
528
534
  case 'input.press_key':
529
- return normalizeInputAction(params);
535
+ return withInputOptions(method, normalizeInputAction(params), params);
530
536
  case 'cdp.dispatch_key_event':
531
537
  return normalizeCdpDispatchKeyEventParams(params);
532
538
  case 'cdp.get_box_model':
@@ -535,13 +541,13 @@ function normalizeRequestParams(method, params) {
535
541
  case 'cdp.get_dom_snapshot':
536
542
  return normalizeCdpDomSnapshotParams(params);
537
543
  case 'input.set_checked':
538
- return normalizeCheckedAction(params);
544
+ return withInputOptions(method, normalizeCheckedAction(params), params);
539
545
  case 'input.select_option':
540
- return normalizeSelectAction(params);
546
+ return withInputOptions(method, normalizeSelectAction(params), params);
541
547
  case 'input.hover':
542
- return normalizeHoverParams(params);
548
+ return withInputOptions(method, normalizeHoverParams(params), params);
543
549
  case 'input.drag':
544
- return normalizeDragParams(params);
550
+ return withInputOptions(method, normalizeDragParams(params), params);
545
551
  case 'input.touch':
546
552
  return normalizeTouchParams(params);
547
553
  case 'input.perform':
@@ -625,9 +631,23 @@ export function getBridgeOperationTimeoutMs(method, params = {}) {
625
631
  case 'input.perform':
626
632
  return normalizeInputPerformParams(params).timeoutMs;
627
633
  case 'input.click':
628
- case 'input.press_key': {
634
+ case 'input.press_key':
635
+ case 'input.focus':
636
+ case 'input.type':
637
+ case 'input.fill':
638
+ case 'input.set_checked':
639
+ case 'input.select_option':
640
+ case 'input.hover':
641
+ case 'input.drag': {
642
+ const normalized =
643
+ /** @type {{ timeoutMs?: number, observe?: { settleMs: number } | null }} */ (
644
+ normalizeRequestParams(method, params)
645
+ );
629
646
  const holdMs = normalizeHoldMs(params.holdMs, 0);
630
- return holdMs > 0 ? holdMs + INPUT_HOLD_TIMEOUT_MARGIN_MS : null;
647
+ const waitMs = normalized.timeoutMs ?? 0;
648
+ const settleMs = normalized.observe?.settleMs ?? 0;
649
+ const total = holdMs + waitMs + settleMs;
650
+ return total > 0 ? total + INPUT_HOLD_TIMEOUT_MARGIN_MS : null;
631
651
  }
632
652
  case 'input.touch':
633
653
  return normalizeHoldMs(params.holdMs, DEFAULT_TOUCH_HOLD_MS) + INPUT_HOLD_TIMEOUT_MARGIN_MS;
@@ -754,15 +774,107 @@ export function normalizeStyleQuery(params = {}) {
754
774
  };
755
775
  }
756
776
 
777
+ const TARGET_LOCATOR_KEYS = /** @type {const} */ ([
778
+ 'role',
779
+ 'name',
780
+ 'text',
781
+ 'label',
782
+ 'placeholder',
783
+ 'testId',
784
+ ]);
785
+ const MAX_LOCATOR_VALUE_LENGTH = 500;
786
+
757
787
  /**
758
- * @param {{ elementRef?: string, selector?: string } | null | undefined} target
788
+ * Normalize an input target. Besides `elementRef` and `selector`, a target can
789
+ * be a semantic locator (`role` + `name`, `text`, `label`, `placeholder`,
790
+ * `testId`) so one action call can find and act on an element. `selector`
791
+ * then narrows locator candidates; `nth` picks one of several matches.
792
+ *
793
+ * @param {Record<string, unknown> | null | undefined} target
759
794
  * @returns {import('./types.js').InputTarget}
760
795
  */
761
796
  function normalizeTarget(target) {
762
- return {
797
+ /** @type {import('./types.js').InputTarget} */
798
+ const normalized = {
763
799
  elementRef: typeof target?.elementRef === 'string' ? target.elementRef : undefined,
764
800
  selector: typeof target?.selector === 'string' ? target.selector : undefined,
765
801
  };
802
+ if (!target) return normalized;
803
+ for (const key of TARGET_LOCATOR_KEYS) {
804
+ const value = target[key];
805
+ if (typeof value !== 'string' || !value.trim()) continue;
806
+ if (value.length > MAX_LOCATOR_VALUE_LENGTH) {
807
+ throw new BridgeError(
808
+ ERROR_CODES.INVALID_REQUEST,
809
+ `target.${key} must not exceed ${MAX_LOCATOR_VALUE_LENGTH} characters.`
810
+ );
811
+ }
812
+ normalized[key] = value.trim();
813
+ }
814
+ if (target.exact === true) normalized.exact = true;
815
+ if (target.nth !== undefined && target.nth !== null) {
816
+ const nth = Number(target.nth);
817
+ if (!Number.isInteger(nth) || nth < 0 || nth > 49) {
818
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'target.nth must be an integer 0-49.');
819
+ }
820
+ normalized.nth = nth;
821
+ }
822
+ return normalized;
823
+ }
824
+
825
+ /** Default bounded wait for a target to exist and become actionable (ms). */
826
+ export const DEFAULT_INPUT_WAIT_MS = 2_500;
827
+ export const MAX_INPUT_WAIT_MS = 15_000;
828
+ /** Default and maximum post-action settle window used by `observe` (ms). */
829
+ export const DEFAULT_OBSERVE_SETTLE_MS = 500;
830
+ export const MAX_OBSERVE_SETTLE_MS = 5_000;
831
+
832
+ /** Actions that report their observed effects unless `observe: false`. */
833
+ const OBSERVE_BY_DEFAULT_METHODS = new Set([
834
+ 'input.click',
835
+ 'input.press_key',
836
+ 'input.select_option',
837
+ 'input.set_checked',
838
+ ]);
839
+
840
+ /**
841
+ * @param {string} method
842
+ * @param {unknown} value
843
+ * @param {{ submit?: boolean }} normalized
844
+ * @returns {{ settleMs: number } | null}
845
+ */
846
+ function normalizeObserveOption(method, value, normalized) {
847
+ if (value === false || value === null) return null;
848
+ if (value && typeof value === 'object') {
849
+ const settleMs = /** @type {{ settleMs?: unknown }} */ (value).settleMs;
850
+ return {
851
+ settleMs: clampInt(settleMs, 0, MAX_OBSERVE_SETTLE_MS, DEFAULT_OBSERVE_SETTLE_MS),
852
+ };
853
+ }
854
+ const observeByDefault =
855
+ OBSERVE_BY_DEFAULT_METHODS.has(method) ||
856
+ ((method === 'input.type' || method === 'input.fill') && normalized.submit === true);
857
+ return value === true || observeByDefault ? { settleMs: DEFAULT_OBSERVE_SETTLE_MS } : null;
858
+ }
859
+
860
+ /**
861
+ * Add the shared auto-wait and observation options to normalized input params.
862
+ *
863
+ * @template {Record<string, unknown>} T
864
+ * @param {string} method
865
+ * @param {T} normalized
866
+ * @param {Record<string, unknown>} raw
867
+ * @returns {T & { timeoutMs: number, observe: { settleMs: number } | null }}
868
+ */
869
+ function withInputOptions(method, normalized, raw) {
870
+ return {
871
+ ...normalized,
872
+ timeoutMs:
873
+ typeof raw.timeoutMs === 'number' && Number.isFinite(raw.timeoutMs)
874
+ ? Math.round(clampNumber(raw.timeoutMs, 0, MAX_INPUT_WAIT_MS, DEFAULT_INPUT_WAIT_MS))
875
+ : DEFAULT_INPUT_WAIT_MS,
876
+ observe: normalizeObserveOption(method, raw.observe, normalized),
877
+ };
766
878
  }
767
879
 
768
880
  /**
@@ -770,10 +882,10 @@ function normalizeTarget(target) {
770
882
  * @returns {import('./types.js').InputExecutionMode}
771
883
  */
772
884
  function normalizeInputExecutionMode(value) {
773
- if (value !== undefined && value !== 'dom' && value !== 'cdp') {
774
- throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'executionMode must be either dom or cdp.');
885
+ if (value !== undefined && value !== 'dom' && value !== 'cdp' && value !== 'auto') {
886
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'executionMode must be dom, cdp, or auto.');
775
887
  }
776
- return value === 'cdp' ? 'cdp' : 'dom';
888
+ return value === 'cdp' || value === 'auto' ? value : 'dom';
777
889
  }
778
890
 
779
891
  /**
@@ -992,9 +1104,14 @@ export function normalizeInputPerformParams(params = {}) {
992
1104
  );
993
1105
  }
994
1106
  const stepMethod = /** @type {PerformStepMethod} */ (method);
1107
+ const normalizedStepParams = normalizeRequestParams(stepMethod, stepParams);
1108
+ // Timed sequences keep their schedule: steps observe effects only on request.
1109
+ if (rawParams.observe === undefined && 'observe' in normalizedStepParams) {
1110
+ normalizedStepParams.observe = null;
1111
+ }
995
1112
  return {
996
1113
  method: stepMethod,
997
- params: normalizeRequestParams(stepMethod, stepParams),
1114
+ params: normalizedStepParams,
998
1115
  delayMs: delayMs ?? 0,
999
1116
  atMs,
1000
1117
  };
@@ -1342,6 +1459,7 @@ export function normalizeFindByTextParams(params = {}) {
1342
1459
  exact: Boolean(params.exact),
1343
1460
  selector: typeof params.selector === 'string' && params.selector.trim() ? params.selector : '*',
1344
1461
  maxResults: clampInt(params.maxResults, 1, 50, 10),
1462
+ includeHidden: params.includeHidden === true,
1345
1463
  };
1346
1464
  }
1347
1465
 
@@ -1353,8 +1471,10 @@ export function normalizeFindByRoleParams(params = {}) {
1353
1471
  return {
1354
1472
  role: typeof params.role === 'string' ? params.role : '',
1355
1473
  name: typeof params.name === 'string' ? params.name : '',
1474
+ exact: Boolean(params.exact),
1356
1475
  selector: typeof params.selector === 'string' && params.selector.trim() ? params.selector : '*',
1357
1476
  maxResults: clampInt(params.maxResults, 1, 50, 10),
1477
+ includeHidden: params.includeHidden === true,
1358
1478
  };
1359
1479
  }
1360
1480
 
@@ -1604,6 +1724,14 @@ export function normalizeTabCloseParams(params = {}) {
1604
1724
  * @returns {NormalizedAccessibilityTreeParams}
1605
1725
  */
1606
1726
  export function normalizeAccessibilityTreeParams(params = {}) {
1727
+ const source = params.source ?? 'cdp';
1728
+ if (source !== 'cdp' && source !== 'dom') {
1729
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'source must be cdp or dom.');
1730
+ }
1731
+ const format = params.format ?? (source === 'dom' ? 'outline' : 'tree');
1732
+ if (format !== 'tree' && format !== 'outline') {
1733
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'format must be tree or outline.');
1734
+ }
1607
1735
  return {
1608
1736
  selector:
1609
1737
  typeof params.selector === 'string' && params.selector.trim() ? params.selector.trim() : null,
@@ -1611,6 +1739,9 @@ export function normalizeAccessibilityTreeParams(params = {}) {
1611
1739
  maxNodes: clampInt(params.maxNodes, 10, 5000, DEFAULT_A11Y_MAX_NODES),
1612
1740
  compact: params.compact === true,
1613
1741
  interactiveOnly: params.interactiveOnly === true,
1742
+ // Omitted at defaults so existing CDP callers see an unchanged shape.
1743
+ ...(source === 'dom' ? { source } : {}),
1744
+ ...(format === 'outline' ? { format } : {}),
1614
1745
  };
1615
1746
  }
1616
1747
 
@@ -140,11 +140,13 @@ const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
140
140
  'dom.get_text': 'Read bounded text for one element.',
141
141
  'dom.get_attributes': 'Read selected attributes for one element.',
142
142
  'dom.wait_for': 'Wait for a selector or text condition in the DOM.',
143
- 'dom.find_by_text': 'Find elements by visible text.',
144
- 'dom.find_by_role': 'Find elements by ARIA role and optional name.',
143
+ 'dom.find_by_text':
144
+ 'Find the innermost visible elements by text, including open/closed shadow DOM.',
145
+ 'dom.find_by_role':
146
+ 'Find visible elements by ARIA role and accessible name (labels, aria-*, alt, content).',
145
147
  'dom.get_html': 'Read inner or outer HTML for one element.',
146
148
  'dom.get_accessibility_tree':
147
- 'Read a depth-limited accessibility tree with optional compact or interactive filtering.',
149
+ 'Read an accessibility tree; source=dom returns a compact actionable outline with elementRefs and no debugger.',
148
150
  'layout.get_box_model': 'Read the box model for one element.',
149
151
  'layout.hit_test': 'Resolve the topmost element at a viewport point.',
150
152
  'styles.get_computed':
@@ -522,7 +524,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
522
524
  'dom.find_by_text',
523
525
  'inspect',
524
526
  true,
525
- ['text', 'exact', 'selector', 'maxResults'],
527
+ ['text', 'exact', 'selector', 'maxResults', 'includeHidden'],
526
528
  'low',
527
529
  METHOD_POLICIES.domRead
528
530
  ),
@@ -530,7 +532,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
530
532
  'dom.find_by_role',
531
533
  'inspect',
532
534
  true,
533
- ['role', 'name', 'selector', 'maxResults'],
535
+ ['role', 'name', 'exact', 'selector', 'maxResults', 'includeHidden'],
534
536
  'low',
535
537
  METHOD_POLICIES.domRead
536
538
  ),
@@ -546,7 +548,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
546
548
  'dom.get_accessibility_tree',
547
549
  'inspect',
548
550
  true,
549
- ['selector', 'maxNodes', 'maxDepth', 'compact', 'interactiveOnly'],
551
+ ['selector', 'maxNodes', 'maxDepth', 'compact', 'interactiveOnly', 'source', 'format'],
550
552
  'moderate',
551
553
  METHOD_POLICIES.domReadDebugger
552
554
  ),