@evan_zhong/browser-attach 0.17.2 → 0.19.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.
@@ -18,6 +18,7 @@ import { waitFor } from './wait.js';
18
18
  import { boundedRead, installOutcomeObserver } from './outcomes.js';
19
19
  import { createDrag, dragOptions } from './drag.js';
20
20
  import { createFetches, capturePage } from './fetch.js';
21
+ import { errorData } from './errors.js';
21
22
 
22
23
  export function createController(api, notify = () => {}) {
23
24
  const claims = new Map();
@@ -390,7 +391,7 @@ export function createController(api, notify = () => {}) {
390
391
  const stepBudget = budget.child(step.timeout_ms ?? 30000, command === 'wait' ? 'wait' : 'input');
391
392
  results.push(await perform(tabId, command, { ...inputArgs, dialog_policy: step.dialog_policy ?? args.dialog_policy ?? 'ask', budget: stepBudget }));
392
393
  } catch (error) {
393
- return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false, error: error.message, ...(error.post_action ? { post_action: error.post_action } : {}),
394
+ return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false, ...errorData(error),
394
395
  application_result: 'not_checked', observe_again: true };
395
396
  }
396
397
  }
@@ -401,7 +402,7 @@ export function createController(api, notify = () => {}) {
401
402
  if (args.result_type != null && !['json', 'element'].includes(args.result_type)) throw new Error('result_type must be json or element');
402
403
  if (args.cursor == null) expireCursor(tabId);
403
404
  if (args.cursor != null) return await evaluations.read(tabId, args);
404
- const budget = deadline(args.timeout_ms ?? 10000, 'evaluation'), version = documents.get(tabId), claim = claims.get(tabId);
405
+ const budget = args.budget || deadline(args.timeout_ms ?? 10000, 'evaluation'), version = documents.get(tabId), claim = claims.get(tabId);
405
406
  const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
406
407
  const contextId = args.frame || args.frame_id ? pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId])) : undefined;
407
408
  if ((args.frame || args.frame_id) && contextId == null) throw new Error('Page frame context is unavailable; observe again');
@@ -431,6 +432,11 @@ export function createController(api, notify = () => {}) {
431
432
  });
432
433
  }
433
434
  if (command === 'read_text') {
435
+ if (args.selector_type === 'role') {
436
+ const budget = deadline(5000), node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
437
+ try { return { ...await budget.run(() => readText(cdp, value, node.target, { ...args, node })), scope: args.frame || args.frame_id ? 'frame' : 'main_frame', frame_id: node.frameId }; }
438
+ finally { await boundedRead(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }), 100).catch(() => {}); }
439
+ }
434
440
  if (!args.frame && !args.frame_id) return await readText(cdp, value, tabId, args);
435
441
  const budget = deadline(5000), frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
436
442
  const contextId = pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId]));
@@ -440,7 +446,7 @@ export function createController(api, notify = () => {}) {
440
446
  if (command === 'screenshot') {
441
447
  const view = await viewport(tabId);
442
448
  const hasTarget = args.ref != null || args.selector != null;
443
- if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type)) throw new Error('Element screenshot frame/nth/type requires an element selector');
449
+ if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type || args.name != null)) throw new Error('Element screenshot frame/nth/type/name requires an element selector');
444
450
  if (hasTarget && args.full_page) throw new Error('Element target and full_page are mutually exclusive');
445
451
  const clip = hasTarget ? await elementClip(tabId, args, view) : args.full_page ? await fullPageClip(tabId) : null;
446
452
  const includeIndicators = args.include_indicators === true;
@@ -498,8 +504,8 @@ export function createController(api, notify = () => {}) {
498
504
  if (command === 'select_option') selectOptions(args);
499
505
  if (command === 'type') typeOptions(args);
500
506
  if (command === 'scroll') scrollOptions(args);
501
- if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'nth'].some(key => args[key] != null)) targetOptions(args);
502
- if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom' });
507
+ if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'name', 'nth'].some(key => args[key] != null)) targetOptions(args);
508
+ if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom', focused: command === 'type' });
503
509
  if (command === 'click') clickOptions(args);
504
510
  if (['click', 'fill', 'type', 'press_key', 'scroll', 'hover', 'select_option', 'upload_file'].includes(command)) args = { ...args, budget: args.budget || deadline(args.timeout_ms) };
505
511
  // Check before any renderer preparation or ref invalidation. A rejected
@@ -636,6 +642,9 @@ export function createController(api, notify = () => {}) {
636
642
 
637
643
  async function perform(tabId, command, args) {
638
644
  if (args.dialog_policy != null && !['ask', 'accept', 'dismiss'].includes(args.dialog_policy)) throw new Error('dialog_policy must be ask, accept or dismiss');
645
+ if (!args.budget && (['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file'].includes(command) || command === 'evaluate' && args.cursor == null)) {
646
+ args = { ...args, budget: deadline(args.timeout_ms ?? (command === 'evaluate' ? 10000 : 5000), command === 'evaluate' ? 'evaluation' : 'input') };
647
+ }
639
648
  const parent = dialogPolicies.get(tabId), state = { policy: args.dialog_policy ?? parent?.policy ?? 'ask', messages: [], parent };
640
649
  dialogPolicies.set(tabId, state);
641
650
  const version = documents.get(tabId), startTab = await api.tabs.get(tabId);
@@ -644,7 +653,7 @@ export function createController(api, notify = () => {}) {
644
653
  const startObservation = async () => { if (!watch || observer || dialogs.has(tabId)) return; try {
645
654
  observer = await boundedRead(async () => {
646
655
  if (args.ref) observationFrame = snapshots.get(tabId)?.refs.get(args.ref)?.frameId || observationFrame;
647
- else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))));
656
+ else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))), { wait: false });
648
657
  observationTarget = sessions.target(tabId, observationFrame);
649
658
  observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
650
659
  const { executionContextId } = await cdp(observationTarget, 'Page.createIsolatedWorld', { frameId: observationFrame, worldName: 'browser-attach-outcome' });
@@ -672,7 +681,6 @@ export function createController(api, notify = () => {}) {
672
681
  current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
673
682
  assessment: dialogs.has(tabId) || state.messages.length || documents.get(tabId) !== version || frameChanged || dom?.dom_changed ? 'observable_effects' : 'no_observable_effects',
674
683
  note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
675
- error.message += ` Post-action observation: ${JSON.stringify(error.post_action)}`;
676
684
  } else if (state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
677
685
  throw error;
678
686
  } finally {
@@ -0,0 +1,13 @@
1
+ const fields = ['input_dispatched', 'post_action', 'diagnostics'];
2
+
3
+ export function errorData(error) {
4
+ const data = { error: error.message || String(error) };
5
+ for (const key of fields) if (error[key] !== undefined) data[key] = error[key];
6
+ return data;
7
+ }
8
+
9
+ export function errorFromData(data) {
10
+ const error = new Error(String(data.error));
11
+ for (const key of fields) if (data[key] !== undefined) error[key] = data[key];
12
+ return error;
13
+ }
@@ -109,8 +109,8 @@ export function createEvaluations(cdp) {
109
109
  }
110
110
  const { kind, items, offset, result } = state;
111
111
  const length = kind === 'scalar' ? 1 : items.length;
112
- const make = end => kind === 'string' ? items.slice(offset, end) : kind === 'array' ? items.slice(offset, end) :
113
- kind === 'object' ? Object.fromEntries(items.slice(offset, end)) : items;
112
+ const make = (end, start = offset) => kind === 'string' || kind === 'array' ? items.slice(start, end) :
113
+ kind === 'object' ? Object.fromEntries(items.slice(start, end)) : items;
114
114
  let low = offset, high = length;
115
115
  while (low < high) {
116
116
  const mid = Math.ceil((low + high) / 2);
@@ -124,7 +124,9 @@ export function createEvaluations(cdp) {
124
124
  }
125
125
  state.offset = low;
126
126
  state.cursor = low < length ? `${state.token}@${low}` : null;
127
+ const nextRequired = low < length ? JSON.stringify(make(low + 1, low)).length : null;
127
128
  return { ...result, value: make(low), result_kind: kind, has_more: low < length,
129
+ ...(nextRequired > max ? { next_item: { ...(kind === 'object' ? { key: items[low][0] } : { index: low }), required_chars: nextRequired, exceeds_max_chars: nextRequired > 100000 } } : {}),
128
130
  next_cursor: state.cursor, capture_truncated: result.truncated, truncated: result.truncated || low < length };
129
131
  }
130
132
  return { read, forgetFrame(tabId, frameId) { if (results.get(tabId)?.frameId === frameId) results.delete(tabId); }, forget: tabId => results.delete(tabId) };
@@ -32,6 +32,30 @@ export function createInput(cdp, value, sessions) {
32
32
  }
33
33
  function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during keyboard input; outcome is unknown. Observe before retrying.')); }
34
34
 
35
+ async function focusedNode(tabId, budget, worlds, frames) {
36
+ frames ||= await budget.run(() => sessions.frames(tabId));
37
+ let frameId = frames[0].id;
38
+ const visited = new Set();
39
+ while (true) {
40
+ if (visited.has(frameId)) throw new Error('Focus frame cycle; no input was dispatched.');
41
+ visited.add(frameId);
42
+ let world = worlds?.get(frameId);
43
+ if (!world) {
44
+ if (worlds || !frames.some(frame => frame.id === frameId)) throw new Error('Focused frame is unavailable; no input was dispatched.');
45
+ const target = sessions.target(tabId, frameId);
46
+ const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' }));
47
+ world = { target, contextId: executionContextId };
48
+ }
49
+ const active = await budget.run(() => cdp(world.target, 'Runtime.evaluate', { contextId: world.contextId,
50
+ expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' }));
51
+ value(active);
52
+ if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
53
+ const { node } = await budget.run(() => cdp(world.target, 'DOM.describeNode', { objectId: active.result.objectId }));
54
+ if (node.frameId) frameId = node.frameId;
55
+ else return { target: world.target, frameId, objectId: active.result.objectId, backendNodeId: node.backendNodeId };
56
+ }
57
+ }
58
+
35
59
  async function pressKey(tabId, chord, args = {}) {
36
60
  const budget = args.budget || deadline(args.timeout_ms);
37
61
  const send = (...params) => budget.run(() => cdp(...params));
@@ -66,34 +90,20 @@ export function createInput(cdp, value, sessions) {
66
90
  omittedFrames.push({ frame_id: frame.id, reason: error.message });
67
91
  }
68
92
  }
69
- let focusTarget = worlds.get(frames[0].id), focusedNode;
70
- const visited = new Set();
71
- while (focusTarget) {
72
- const { target, contextId } = focusTarget;
73
- if (visited.has(contextId + ':' + target.sessionId)) throw new Error('Focus frame cycle; no input was dispatched.');
74
- visited.add(contextId + ':' + target.sessionId);
75
- const active = await send(target, 'Runtime.evaluate', { contextId,
76
- expression: `(${activeElement.toString()})()`, objectGroup: 'browser-attach' });
77
- if (!active.result?.objectId) throw new Error('No focused page element is available; no input was dispatched.');
78
- const { node } = await send(target, 'DOM.describeNode', { objectId: active.result.objectId });
79
- if (node.frameId) {
80
- focusTarget = worlds.get(node.frameId);
81
- if (!focusTarget) throw new Error('Focused frame is unavailable; no input was dispatched.');
82
- } else { focusedNode = { objectId: active.result.objectId }; break; }
83
- }
84
- await waitForTarget(cdp, value, focusTarget.target, focusedNode, budget);
93
+ const focused = await focusedNode(tabId, budget, worlds, frames);
94
+ await waitForTarget(cdp, value, focused.target, focused, budget);
85
95
  if (args.expectedNode) {
86
96
  const expected = args.expectedNode;
87
- const focused = focusTarget.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
97
+ const matches = focused.frameId === expected.frameId && value(await send(expected.target, 'Runtime.callFunctionOn', { objectId: expected.objectId,
88
98
  functionDeclaration: 'function() { const a=this.getRootNode().activeElement; return this.isConnected && (a===this || this.contains(a)); }', returnByValue: true }));
89
- if (!focused) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
99
+ if (!matches) throw new Error('Typing target lost focus during key preparation; no further key was dispatched');
90
100
  }
91
101
  budget.dispatch();
92
102
  // Queue the complete chord before waiting for acknowledgements. Always
93
103
  // enqueue keyUp, even if keyDown fails or opens a modal.
94
- const commands = [send(focusTarget.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
95
- if (text != null && args.character == null) commands.push(send(focusTarget.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
96
- commands.push(send(focusTarget.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
104
+ const commands = [send(focused.target, 'Input.dispatchKeyEvent', { type: args.character != null ? 'keyDown' : 'rawKeyDown', ...(args.character != null ? { text, unmodifiedText: text } : {}), ...params })];
105
+ if (text != null && args.character == null) commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'char', text, unmodifiedText: text, ...params }));
106
+ commands.push(send(focused.target, 'Input.dispatchKeyEvent', { type: 'keyUp', ...params }));
97
107
  await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
98
108
  return { sent: chord, method: 'native', delivery_verified: true, application_result: 'not_checked',
99
109
  ...(omittedFrames.length ? { verification_omitted_frames: omittedFrames } : {}), observe_again: true };
@@ -135,21 +145,28 @@ export function createInput(cdp, value, sessions) {
135
145
  async function selectOption(tabId, node, args) {
136
146
  selectOptions(args);
137
147
  const budget = args.budget || deadline(args.timeout_ms);
138
- await waitForTarget(cdp, value, node.target, node, budget);
148
+ const choice = { value: args.value, label: args.label, index: args.index };
149
+ value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
150
+ objectId: node.objectId, functionDeclaration: selectElement.toString(),
151
+ arguments: [{ value: choice }, { value: false }], returnByValue: true,
152
+ })));
139
153
  budget.dispatch();
140
154
  const result = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', {
141
155
  objectId: node.objectId, functionDeclaration: selectElement.toString(),
142
- arguments: [{ value: { value: args.value, label: args.label, index: args.index } }], returnByValue: true,
156
+ arguments: [{ value: choice }], returnByValue: true,
143
157
  })));
144
158
  return { ...result, method: 'dom', events_trusted: false, application_result: 'not_checked', observe_again: true };
145
159
  }
146
160
  async function typeText(tabId, node, args) {
147
161
  typeOptions(args);
148
162
  const budget = args.budget || deadline(args.timeout_ms);
163
+ if (args.focused) node = await focusedNode(tabId, budget);
149
164
  await waitForTarget(cdp, value, node.target, node, budget, { editable: true });
150
- budget.dispatch();
151
- value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
152
- functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
165
+ if (!args.focused) {
166
+ budget.dispatch();
167
+ value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
168
+ functionDeclaration: 'function() { this.focus(); if (this.getRootNode().activeElement !== this && !this.contains(this.getRootNode().activeElement)) throw new Error("Field did not receive focus"); }', returnByValue: true })));
169
+ }
153
170
  let typed = 0; const length = Array.from(args.text).length;
154
171
  for (const character of args.text) {
155
172
  const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
@@ -286,14 +303,17 @@ export function selectOptions(args) {
286
303
  for (const key of ['value', 'label']) if (args[key] != null && typeof args[key] !== 'string') throw new Error(`${key} must be a string`);
287
304
  }
288
305
 
289
- export function selectElement(choice) {
306
+ export function selectElement(choice, apply = true) {
290
307
  if (this.tagName !== 'SELECT' || !this.isConnected) throw new Error('Target must be a connected HTML select');
291
- if (this.matches(':disabled') || this.closest('[inert]')) throw new Error('Select is disabled or inert');
308
+ for (let parent = this; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
309
+ if (parent.matches(':disabled') || parent.hasAttribute('inert')) throw new Error('Select is disabled or inert');
310
+ }
292
311
  const options = Array.from(this.options);
293
312
  const matches = options.filter((option, index) => choice.index != null ? index === choice.index : choice.value != null ? option.value === choice.value : option.label === choice.label);
294
313
  if (matches.length !== 1) throw new Error(`Option must match exactly once; matched ${matches.length}. Use an explicit index for duplicate labels/values. No value was set.`);
295
314
  const option = matches[0];
296
315
  if (option.disabled || option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.disabled) throw new Error('Option is disabled; no value was set');
316
+ if (!apply) return true;
297
317
  const win = this.ownerDocument.defaultView;
298
318
  const index = options.indexOf(option);
299
319
  Object.getOwnPropertyDescriptor(win.HTMLSelectElement.prototype, 'selectedIndex').set.call(this, index);
@@ -1,16 +1,18 @@
1
1
  // Selectors are reusable queries, never identities inferred from old AX text.
2
- export function targetOptions(args, { coordinates = false } = {}) {
2
+ export function targetOptions(args, { coordinates = false, focused = false } = {}) {
3
3
  const hasRef = args.ref != null, hasSelector = args.selector != null;
4
4
  const hasPoint = args.x != null || args.y != null;
5
- if (Number(hasRef) + Number(hasSelector) + Number(hasPoint) !== 1 ||
5
+ if (args.focused != null && (args.focused !== true || !focused)) throw new Error('focused=true is only supported by type');
6
+ if (Number(hasRef) + Number(hasSelector) + Number(hasPoint) + Number(args.focused === true) !== 1 ||
6
7
  (hasPoint && (!coordinates || !Number.isFinite(args.x) || !Number.isFinite(args.y)))) {
7
- throw new Error(coordinates ? 'Provide exactly one of ref, selector, or both finite x and y' : 'Provide exactly one of ref or selector');
8
+ throw new Error(focused ? 'Provide exactly one of ref, selector or focused=true' : coordinates ? 'Provide exactly one of ref, selector, or both finite x and y' : 'Provide exactly one of ref or selector');
8
9
  }
9
10
  if (hasRef && (typeof args.ref !== 'string' || !args.ref)) throw new Error('ref must be nonempty');
10
11
  if (hasSelector && (typeof args.selector !== 'string' || !args.selector.trim() || args.selector.length > 10000)) throw new Error('selector must contain 1..10000 characters');
11
- if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or xpath');
12
+ if (args.selector_type != null && !['css', 'xpath', 'label', 'placeholder', 'role'].includes(args.selector_type)) throw new Error('selector_type must be css, xpath, label, placeholder or role');
13
+ if (args.name != null && (args.selector_type !== 'role' || typeof args.name !== 'string' || args.name.length > 10000)) throw new Error('name requires selector_type=role and must be a string of at most 10000 characters');
12
14
  frameOptions(args);
13
- if (!hasSelector && (args.frame_id != null || args.frame != null || args.selector_type != null)) throw new Error('frame_id and selector_type require selector');
15
+ if (!hasSelector && (args.frame_id != null || args.frame != null || args.selector_type != null)) throw new Error('frame/frame_id and selector_type require selector');
14
16
  if (args.nth != null && (!hasSelector || !Number.isSafeInteger(args.nth) || args.nth < 0)) throw new Error('nth requires selector and must be a nonnegative safe integer (zero-based)');
15
17
  if (args.coordinate_space != null && (!hasPoint || !['viewport', 'document'].includes(args.coordinate_space))) throw new Error('coordinate_space must be viewport or document and applies to coordinate clicks only');
16
18
  }
@@ -22,7 +24,13 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
22
24
  const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
23
25
  count = result.snapshotLength; at = index => result.snapshotItem(index);
24
26
  } else {
25
- const elements = document.querySelectorAll(selector);
27
+ const normalize = text => text.replace(/\s+/g, ' ').trim();
28
+ const elements = type === 'label'
29
+ ? Array.from(document.querySelectorAll('input,textarea,select,button,meter,output,progress')).filter(element =>
30
+ Array.from(element.labels || []).some(label => normalize(label.textContent || '') === normalize(selector)))
31
+ : type === 'placeholder'
32
+ ? Array.from(document.querySelectorAll('[placeholder]')).filter(element => element.getAttribute('placeholder') === selector)
33
+ : document.querySelectorAll(selector);
26
34
  count = elements.length; at = index => elements[index];
27
35
  }
28
36
  if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
@@ -43,27 +51,55 @@ export function queryElement(selector, type = 'css', nth, waitForMatch = false)
43
51
  return element;
44
52
  }
45
53
 
46
- export async function resolveSelector(cdp, value, sessions, tabId, args, budget) {
54
+ export async function resolveSelector(cdp, value, sessions, tabId, args, budget, { wait = true } = {}) {
55
+ targetOptions(args);
47
56
  const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
48
57
  const target = sessions.target(tabId, frameId);
49
58
  const send = (method, params) => budget.run(() => cdp(target, method, params));
50
- const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
51
- let result;
59
+ const role = args.selector_type === 'role';
60
+ const context = role ? null : await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
61
+ let backendNodeId;
52
62
  while (true) {
53
- result = await send('Runtime.evaluate', { contextId: executionContextId, objectGroup: 'browser-attach',
54
- expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
55
- value(result);
56
- if (result.result?.subtype !== 'null') break;
57
- budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`);
63
+ if (role) {
64
+ const { nodes } = await send('Accessibility.getFullAXTree', { frameId });
65
+ backendNodeId = matchRole(nodes, args)?.backendDOMNodeId;
66
+ } else {
67
+ const result = await send('Runtime.evaluate', { contextId: context.executionContextId, objectGroup: 'browser-attach',
68
+ expression: `(${queryElement.toString()})(${JSON.stringify(args.selector)},${JSON.stringify(args.selector_type ?? 'css')},${args.nth ?? 'undefined'},true)` });
69
+ value(result);
70
+ if (result.result?.subtype !== 'null') {
71
+ if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
72
+ const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
73
+ backendNodeId = node.backendNodeId;
74
+ }
75
+ }
76
+ if (backendNodeId != null) break;
77
+ if (!wait) return null;
78
+ budget.describe?.(`selector ${JSON.stringify(args.selector)}${args.name == null ? '' : ` name=${JSON.stringify(args.name)}`}${args.nth == null ? '' : ` nth=${args.nth}`} to appear; current=no matching element`);
58
79
  await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
59
80
  }
60
- if (!result.result?.objectId) throw new Error('Selector did not resolve to an element');
61
- const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
62
81
  // Hit-test refs resolve in the default world too. CDP forbids passing object
63
82
  // arguments between worlds, even when both wrappers refer to the same node.
64
- const resolved = await send('DOM.resolveNode', { backendNodeId: node.backendNodeId, objectGroup: 'browser-attach' });
83
+ const resolved = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
65
84
  if (!resolved.object?.objectId) throw new Error('Selector target detached before resolution; no input was dispatched');
66
- return { target, frameId, objectId: resolved.object.objectId, backendNodeId: node.backendNodeId };
85
+ return { target, frameId, objectId: resolved.object.objectId, backendNodeId };
86
+ }
87
+
88
+ export function matchRole(nodes, args) {
89
+ const byId = new Map(nodes.map(node => [node.nodeId, node]));
90
+ const children = new Set(nodes.flatMap(node => node.childIds || []));
91
+ const pending = nodes.filter(node => !children.has(node.nodeId)).reverse(), seen = new Set(), matches = new Map();
92
+ while (pending.length) {
93
+ const node = pending.pop();
94
+ if (!node || seen.has(node.nodeId)) continue;
95
+ seen.add(node.nodeId);
96
+ if (!node.ignored && node.backendDOMNodeId && node.role?.value === args.selector &&
97
+ (args.name == null || String(node.name?.value || '') === args.name)) matches.set(node.backendDOMNodeId, node);
98
+ for (let i = (node.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(node.childIds[i]));
99
+ }
100
+ const candidates = [...matches.values()];
101
+ if (args.nth == null && candidates.length > 1) throw new Error(`Role selector must match exactly one element; matched ${candidates.length}. Provide name or an explicit zero-based nth. Candidates: ${JSON.stringify(candidates.slice(0, 10).map((node, nth) => ({ nth, role: node.role?.value, name: String(node.name?.value || '').slice(0, 120) })))}`);
102
+ return candidates[args.nth ?? 0] || null;
67
103
  }
68
104
 
69
105
  export function frameOptions(args) {
@@ -78,25 +114,37 @@ export function frameOptions(args) {
78
114
  async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
79
115
  frameOptions(args);
80
116
  const frames = await budget.run(() => sessions.frames(tabId));
117
+ const failure = (error, scope = {}) => {
118
+ const candidates = frames.slice(0, 10).map(frame => ({ frame_id: frame.id, parent_frame_id: frame.parentId ?? null, url: frame.url }));
119
+ error.diagnostics = { reason: 'frame_locator', ...scope, candidates, omitted_candidates: Math.max(0, frames.length - candidates.length) };
120
+ error.message += `; frame scope=${JSON.stringify(scope)}; current frames=${JSON.stringify(candidates)}${frames.length > candidates.length ? ` (${frames.length - candidates.length} more omitted)` : ''}`;
121
+ return error;
122
+ };
81
123
  if (args.frame_id != null) {
82
- if (!frames.some(f => f.id === args.frame_id)) throw new Error('frame_id is unknown; take a new snapshot');
124
+ if (!frames.some(f => f.id === args.frame_id)) throw failure(new Error('frame_id is unknown; take a new snapshot'), { frame_id: args.frame_id });
83
125
  return args.frame_id;
84
126
  }
85
127
  if (!args.frame) return frames[0].id;
86
128
  const scope = args.frame;
87
129
  let frameId = frames[0].id;
88
- for (const selector of scope.path || []) {
89
- const target = sessions.target(tabId, frameId);
90
- const send = (method, params) => budget.run(() => cdp(target, method, params));
91
- const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
92
- const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
93
- value(result);
130
+ for (const [pathIndex, selector] of (scope.path || []).entries()) {
131
+ const parentFrameId = frameId;
94
132
  try {
95
- const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
96
- if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
97
- frameId = node.frameId || node.contentDocument?.frameId;
98
- if (!frameId) throw new Error('Frame document is not available yet');
99
- } finally { await send('Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-frame' }); }
133
+ const target = sessions.target(tabId, frameId);
134
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
135
+ const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
136
+ const result = await send('Runtime.evaluate', { contextId: executionContextId, expression: `(${queryElement.toString()})(${JSON.stringify(selector)},"css")`, objectGroup: 'browser-attach-frame' });
137
+ value(result);
138
+ try {
139
+ const { node } = await send('DOM.describeNode', { objectId: result.result.objectId });
140
+ if (!['IFRAME', 'FRAME'].includes(node.nodeName)) throw new Error('frame.path must resolve to iframe/frame elements');
141
+ frameId = node.frameId || node.contentDocument?.frameId;
142
+ if (!frameId) throw new Error('Frame document is not available yet');
143
+ } finally { await send('Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-frame' }); }
144
+ } catch (error) {
145
+ error.message += '; frame.path entries are CSS selectors, e.g. "#content_iframe"';
146
+ throw failure(error, { parent_frame_id: parentFrameId, path_index: pathIndex, selector });
147
+ }
100
148
  }
101
149
  const candidates = scope.path ? frames.filter(f => f.id === frameId) : frames.filter(f => f.id !== frames[0].id);
102
150
  const matches = [];
@@ -110,16 +158,17 @@ async function resolveFrameOnce(cdp, value, sessions, tabId, args, budget) {
110
158
  }
111
159
  matches.push(frame.id);
112
160
  }
113
- if (matches.length !== 1) throw new Error(`Frame locator must match exactly once; matched ${matches.length}`);
161
+ if (matches.length !== 1) throw failure(new Error(`Frame locator must match exactly once; matched ${matches.length}`), { locator: scope, matched_frame_ids: matches.slice(0, 10) });
114
162
  return matches[0];
115
163
  }
116
164
 
117
- export async function resolveFrame(cdp, value, sessions, tabId, args, budget) {
165
+ export async function resolveFrame(cdp, value, sessions, tabId, args, budget, { wait = true } = {}) {
118
166
  while (true) {
119
167
  try { return await resolveFrameOnce(cdp, value, sessions, tabId, args, budget); }
120
168
  catch (error) {
169
+ if (!wait) throw error;
121
170
  if (!/Frame locator.*matched 0|Frame document.*not available|Selector.*matched 0|context.*destroyed|cannot find context/i.test(error.message)) throw error;
122
- budget.describe?.(`frame ${JSON.stringify(args.frame)}; current=${error.message}`);
171
+ budget.describe?.(`frame ${JSON.stringify(args.frame)}; current=${error.message}`, error.diagnostics);
123
172
  await budget.run(() => new Promise(resolve => setTimeout(resolve, 20)));
124
173
  }
125
174
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.17.2",
4
+ "version": "0.19.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -27,10 +27,16 @@ export function scrollState() {
27
27
 
28
28
  export function findScrollText(text, selector) {
29
29
  if (!this.isConnected) throw new Error('Scroll container detached');
30
- const elements = Array.from(this.querySelectorAll(selector || '*'));
30
+ const elements = this.querySelectorAll(selector || '*');
31
31
  if (elements.length > 10000) throw new Error('Scroll search exceeds 10000 candidates; scope until.selector');
32
- const matches = elements.filter(e => (e.textContent || '').includes(text) && e.getBoundingClientRect().width > 0 && e.getBoundingClientRect().height > 0 && e.ownerDocument.defaultView.getComputedStyle(e).visibility === 'visible');
33
- const leaves = matches.filter(e => !matches.some(other => other !== e && e.contains(other)));
32
+ const matches = [];
33
+ for (const element of elements) {
34
+ if (!(element.textContent || '').includes(text)) continue;
35
+ const rect = element.getBoundingClientRect();
36
+ if (rect.width > 0 && rect.height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible') matches.push(element);
37
+ }
38
+ // Document order keeps each subtree contiguous, even after filtering.
39
+ const leaves = matches.filter((element, index) => index === matches.length - 1 || !element.contains(matches[index + 1]));
34
40
  if (leaves.length > 1) throw new Error(`Scroll text must match one element; matched ${leaves.length}. Scope until.selector`);
35
41
  return leaves[0] || null;
36
42
  }
@@ -15,11 +15,61 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
15
15
  const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
16
16
  const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
17
17
  'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
18
+ const fields = new Set(['checkbox', 'combobox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
19
+ const parents = new Map();
20
+ for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
21
+ function contextText(node) {
22
+ const pending = [node], parts = [];
23
+ let visited = 0, length = 0;
24
+ while (pending.length && ++visited <= 32) {
25
+ const current = pending.pop();
26
+ if (!current) return null;
27
+ const role = current.role?.value;
28
+ if (current.ignored && !['none', 'generic'].includes(role)) continue;
29
+ if (role === 'StaticText') {
30
+ const text = String(current.name?.value || '');
31
+ const separator = parts.length ? 1 : 0;
32
+ if (length + text.length + separator > 160) return null;
33
+ parts.push(text); length += text.length + separator;
34
+ }
35
+ else {
36
+ if (!['none', 'generic', 'LabelText', 'paragraph'].includes(role)) return null;
37
+ if (visited + pending.length + (current.childIds?.length || 0) > 32) return null;
38
+ for (let i = (current.childIds?.length || 0) - 1; i >= 0; i--) pending.push(byId.get(current.childIds[i]));
39
+ }
40
+ }
41
+ const text = parts.join(' ').replace(/\s+/g, ' ').trim();
42
+ return !pending.length && text && text.length <= 160 ? text : null;
43
+ }
44
+ // Nearby text is observation context, never a replacement for AX identity or
45
+ // an inferred label locator. Bound both the ancestor search and subtree work.
46
+ function fieldContext(node) {
47
+ let child = node;
48
+ for (let depth = 0; depth < 4; depth++) {
49
+ const parent = byId.get(parents.get(child.nodeId));
50
+ if (!parent || !['none', 'generic', 'group'].includes(parent.role?.value)) return null;
51
+ const pending = [parent];
52
+ let count = 0, visited = 0;
53
+ while (pending.length && ++visited <= 64 && count < 2) {
54
+ const current = pending.pop();
55
+ if (!current) return null;
56
+ if (!current.ignored && controls.has(current.role?.value)) count++;
57
+ if (visited + pending.length + (current.childIds?.length || 0) > 64) return null;
58
+ for (const id of current.childIds || []) pending.push(byId.get(id));
59
+ }
60
+ if (pending.length || count !== 1) return null;
61
+ const index = parent.childIds.indexOf(child.nodeId);
62
+ if (index > 0) {
63
+ const text = contextText(byId.get(parent.childIds[index - 1]));
64
+ if (text) return text;
65
+ }
66
+ child = parent;
67
+ }
68
+ return null;
69
+ }
18
70
  let kept;
19
71
  if (options.profile === 'interactive') {
20
72
  kept = new Set();
21
- const parents = new Map();
22
- for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
23
73
  function keepText(node) {
24
74
  const pending = [node];
25
75
  while (pending.length) {
@@ -78,6 +128,8 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
78
128
  ['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
79
129
  ).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
80
130
  if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
131
+ const context = fields.has(role) && !node.ignored ? fieldContext(node) : null;
132
+ if (context && context !== name) states.push(`context=${JSON.stringify(context)}`);
81
133
  const attributes = states.join(' ');
82
134
  const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
83
135
  (role === 'StaticText' && name === parentName);
@@ -88,7 +140,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
88
140
  }
89
141
  let nextDepth = depth;
90
142
  if (!skip) {
91
- if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query)) {
143
+ if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query) || context?.toLowerCase().includes(query)) {
92
144
  const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
93
145
  if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
94
146
  entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
package/extension/text.js CHANGED
@@ -1,21 +1,23 @@
1
- import { queryElement } from './locator.js';
1
+ import { queryElement, targetOptions } from './locator.js';
2
2
 
3
3
  export async function readText(cdp, value, tabId, args) {
4
4
  const max = args.max_chars ?? 20000, offset = args.offset ?? 0, mode = args.mode ?? 'visible';
5
5
  if (!Number.isInteger(max) || max < 1 || max > 100000) throw new Error('max_chars must be 1..100000');
6
6
  if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('offset must be 0..9007199254740991');
7
7
  if (!['visible', 'text_content', 'value', 'editor'].includes(mode)) throw new Error('read_text mode must be visible, text_content, value or editor');
8
- if (args.selector != null && (typeof args.selector !== 'string' || !args.selector.trim() || args.selector.length > 10000)) throw new Error('selector must contain 1..10000 characters');
9
- if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or xpath');
8
+ if (args.selector != null || args.selector_type != null || args.name != null || args.nth != null) targetOptions(args);
10
9
  if (mode === 'value' || mode === 'editor') {
11
10
  if (!args.selector) throw new Error(`${mode} requires selector to identify exactly one field/editor`);
12
11
  }
12
+ const options = { selector: args.selector, selector_type: args.selector_type, nth: args.nth, mode, offset, max_chars: max };
13
+ if (args.node) return value(await cdp(tabId, 'Runtime.callFunctionOn', { objectId: args.node.objectId, returnByValue: true,
14
+ functionDeclaration: `function(args) { return (${extractText.toString()})(args, () => this); }`, arguments: [{ value: options }] }));
13
15
  return value(await cdp(tabId, 'Runtime.evaluate', { returnByValue: true, ...(args.context_id != null ? { contextId: args.context_id } : {}),
14
- expression: `(${extractText.toString()})(${JSON.stringify({ selector: args.selector, selector_type: args.selector_type, mode, offset, max_chars: max })},${queryElement.toString()})` }));
16
+ expression: `(${extractText.toString()})(${JSON.stringify(options)},${queryElement.toString()})` }));
15
17
  }
16
18
 
17
19
  export function extractText(args, query) {
18
- const element = args.selector ? query(args.selector, args.selector_type ?? 'css') : document.body;
20
+ const element = args.selector ? query(args.selector, args.selector_type ?? 'css', args.nth) : document.body;
19
21
  let text, source = args.mode, complete = true;
20
22
  if (args.mode === 'visible') { text = element?.innerText ?? ''; complete = false; }
21
23
  else if (args.mode === 'text_content') { text = element?.textContent ?? ''; complete = false; }