@evan_zhong/browser-attach 0.17.0 → 0.18.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.
@@ -21,8 +21,8 @@ export function validateSteps(steps, startAt = 0) {
21
21
  frameOptions(step);
22
22
  scrollOptions(step);
23
23
  } else {
24
- if (!step.selector || step.ref != null) throw new Error('Batch element actions require selector');
25
- targetOptions(step);
24
+ if ((!step.selector && !(step.action === 'type' && step.focused === true)) || step.ref != null) throw new Error('Batch element actions require selector; type also accepts focused=true');
25
+ targetOptions(step, { focused: step.action === 'type' });
26
26
  if (['click', 'fill'].includes(step.action)) inputMode(step);
27
27
  if (step.action === 'click') clickOptions(step);
28
28
  if (step.action === 'hover') offsetOption(step);
@@ -126,7 +126,7 @@ export function createConnection(chrome, CONNECTION, controller) {
126
126
  respond({ ok: true });
127
127
  }
128
128
  if (message.type === 'release_all') {
129
- controller.releaseAll().then(() => respond({ ok: true }));
129
+ release().then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message }));
130
130
  return true;
131
131
  }
132
132
  });
@@ -343,7 +343,7 @@ export function createController(api, notify = () => {}) {
343
343
  }
344
344
  const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
345
345
  if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
346
- entries.push(...rendered);
346
+ for (const entry of rendered) entries.push(entry);
347
347
  }
348
348
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
349
349
  expression: '({title: document.title, url: location.href})', returnByValue: true,
@@ -431,6 +431,11 @@ export function createController(api, notify = () => {}) {
431
431
  });
432
432
  }
433
433
  if (command === 'read_text') {
434
+ if (args.selector_type === 'role') {
435
+ const budget = deadline(5000), node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
436
+ 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 }; }
437
+ finally { await boundedRead(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }), 100).catch(() => {}); }
438
+ }
434
439
  if (!args.frame && !args.frame_id) return await readText(cdp, value, tabId, args);
435
440
  const budget = deadline(5000), frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
436
441
  const contextId = pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId]));
@@ -440,7 +445,7 @@ export function createController(api, notify = () => {}) {
440
445
  if (command === 'screenshot') {
441
446
  const view = await viewport(tabId);
442
447
  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');
448
+ 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
449
  if (hasTarget && args.full_page) throw new Error('Element target and full_page are mutually exclusive');
445
450
  const clip = hasTarget ? await elementClip(tabId, args, view) : args.full_page ? await fullPageClip(tabId) : null;
446
451
  const includeIndicators = args.include_indicators === true;
@@ -498,8 +503,8 @@ export function createController(api, notify = () => {}) {
498
503
  if (command === 'select_option') selectOptions(args);
499
504
  if (command === 'type') typeOptions(args);
500
505
  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' });
506
+ if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'name', 'nth'].some(key => args[key] != null)) targetOptions(args);
507
+ if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom', focused: command === 'type' });
503
508
  if (command === 'click') clickOptions(args);
504
509
  if (['click', 'fill', 'type', 'press_key', 'scroll', 'hover', 'select_option', 'upload_file'].includes(command)) args = { ...args, budget: args.budget || deadline(args.timeout_ms) };
505
510
  // Check before any renderer preparation or ref invalidation. A rejected
@@ -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 };
@@ -146,10 +156,13 @@ export function createInput(cdp, value, sessions) {
146
156
  async function typeText(tabId, node, args) {
147
157
  typeOptions(args);
148
158
  const budget = args.budget || deadline(args.timeout_ms);
159
+ if (args.focused) node = await focusedNode(tabId, budget);
149
160
  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 })));
161
+ if (!args.focused) {
162
+ budget.dispatch();
163
+ value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
164
+ 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
+ }
153
166
  let typed = 0; const length = Array.from(args.text).length;
154
167
  for (const character of args.text) {
155
168
  const focused = value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
@@ -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) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.17.0",
4
+ "version": "0.18.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": [
@@ -4,7 +4,13 @@ async function refresh() {
4
4
  document.querySelector('#profile').textContent = `${result.browser.family} · ${result.browser.profile_name}`;
5
5
  document.querySelector('#claims').textContent = `正在控制 ${result.claims} 个标签页`;
6
6
  }
7
- document.querySelector('#release').onclick = async () => { await chrome.runtime.sendMessage({ type: 'release_all' }); await refresh(); };
7
+ document.querySelector('#release').onclick = async () => {
8
+ try {
9
+ const result = await chrome.runtime.sendMessage({ type: 'release_all' });
10
+ await refresh();
11
+ if (!result.ok) document.querySelector('#status').textContent = `释放失败:${result.error}`;
12
+ } catch (error) { document.querySelector('#status').textContent = `释放失败:${error.message}`; }
13
+ };
8
14
  document.querySelector('#reconnect').onclick = async () => { await chrome.runtime.sendMessage({ type: 'reconnect' }); await refresh(); };
9
15
  document.querySelector('#settings').onclick = () => chrome.runtime.openOptionsPage();
10
16
  refresh().catch(error => { document.querySelector('#status').textContent = error.message; });
@@ -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,17 +15,71 @@ 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
- if (!node || kept.has(node.nodeId)) return;
25
- const role = node.role?.value;
26
- if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
27
- kept.add(node.nodeId);
28
- for (const child of node.childIds || []) keepText(byId.get(child));
74
+ const pending = [node];
75
+ while (pending.length) {
76
+ const current = pending.pop();
77
+ if (!current || kept.has(current.nodeId)) continue;
78
+ const role = current.role?.value;
79
+ if (!textRoles.has(role) && !current.ignored && !['none', 'generic'].includes(role)) continue;
80
+ kept.add(current.nodeId);
81
+ for (const child of current.childIds || []) pending.push(byId.get(child));
82
+ }
29
83
  }
30
84
  for (const node of nodes) {
31
85
  if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
@@ -37,19 +91,33 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
37
91
  }
38
92
  }
39
93
  function plainText(node) {
40
- if (!node) return null;
41
- const role = node.role?.value;
42
- if (textRoles.has(role)) return String(node.name?.value || '');
43
- if (!node.ignored && !['none', 'generic'].includes(role)) return null;
44
- const parts = (node.childIds || []).map(id => plainText(byId.get(id)));
45
- return parts.includes(null) ? null : parts.join(' ');
94
+ const pending = [node], parts = [];
95
+ while (pending.length) {
96
+ const current = pending.pop();
97
+ if (current === ' ') { parts.push(' '); continue; }
98
+ if (!current) return null;
99
+ const role = current.role?.value;
100
+ if (textRoles.has(role)) { parts.push(String(current.name?.value || '')); continue; }
101
+ if (!current.ignored && !['none', 'generic'].includes(role)) return null;
102
+ const children = current.childIds || [];
103
+ for (let index = children.length - 1; index >= 0; index--) {
104
+ pending.push(byId.get(children[index]));
105
+ if (index > 0) pending.push(' ');
106
+ }
107
+ }
108
+ return parts.join('');
46
109
  }
47
- function render(node, depth, parentName = '') {
48
- if (!node || visited.has(node.nodeId)) return;
110
+ // Preserve preorder and ref numbering without using the JavaScript call stack.
111
+ const children = new Set();
112
+ for (const node of nodes) for (const child of node.childIds || []) children.add(child);
113
+ const pending = nodes.filter(node => !children.has(node.nodeId)).reverse().map(node => ({ node, depth: 0, parentName: '' }));
114
+ while (pending.length) {
115
+ const { node, depth, parentName } = pending.pop();
116
+ if (!node || visited.has(node.nodeId)) continue;
49
117
  visited.add(node.nodeId);
50
- if (kept && !kept.has(node.nodeId)) return;
118
+ if (kept && !kept.has(node.nodeId)) continue;
51
119
  const role = node.role?.value || 'node';
52
- if (role === 'InlineTextBox') return;
120
+ if (role === 'InlineTextBox') continue;
53
121
  let name = String(node.name?.value || '');
54
122
  let merged = false;
55
123
  if (['listitem', 'paragraph'].includes(role) && !name) {
@@ -60,6 +128,8 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
60
128
  ['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
61
129
  ).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
62
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)}`);
63
133
  const attributes = states.join(' ');
64
134
  const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
65
135
  (role === 'StaticText' && name === parentName);
@@ -70,18 +140,20 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
70
140
  }
71
141
  let nextDepth = depth;
72
142
  if (!skip) {
73
- 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)) {
74
144
  const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
75
145
  if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
76
146
  entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
77
147
  }
78
148
  nextDepth++;
79
149
  }
80
- if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
150
+ if (!merged) {
151
+ const childIds = node.childIds || [];
152
+ for (let index = childIds.length - 1; index >= 0; index--) {
153
+ pending.push({ node: byId.get(childIds[index]), depth: nextDepth, parentName: skip ? parentName : identityName });
154
+ }
155
+ }
81
156
  }
82
- // AX trees normally have a single root, but ignored roots can be disconnected.
83
- const children = new Set(nodes.flatMap(node => node.childIds || []));
84
- for (const node of nodes) if (!children.has(node.nodeId)) render(node, 0);
85
157
  return entries;
86
158
  }
87
159
 
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; }
package/extension/wait.js CHANGED
@@ -7,7 +7,7 @@ export function waitOptions(args) {
7
7
  if (element) {
8
8
  targetOptions(args);
9
9
  if (!args.selector) throw new Error('Element waits require a reusable selector');
10
- } else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null) throw new Error('This wait condition does not use an element target');
10
+ } else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null || args.name != null) throw new Error('This wait condition does not use an element target');
11
11
  if (args.condition === 'text' && (Number(typeof args.expected === 'string') + Number(typeof args.previous === 'string') !== 1)) throw new Error('text wait requires exactly one of expected (substring) or previous (full prior text)');
12
12
  if (args.condition === 'url' && typeof args.expected !== 'string') throw new Error('url wait requires expected text (substring)');
13
13
  if (args.condition !== 'text' && args.previous != null) throw new Error('previous is only valid for text waits');
@@ -18,7 +18,7 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
18
18
  waitOptions(args);
19
19
  const budget = args.budget || deadline(args.timeout_ms ?? 5000, 'wait');
20
20
  let current = 'not observed', previous;
21
- const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.expected != null ? ` expected substring=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
21
+ const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.name != null ? ` name=${JSON.stringify(args.name)}` : ''}${args.expected != null ? ` expected substring=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
22
22
  try {
23
23
  while (true) {
24
24
  if (args.condition === 'network_idle') {
@@ -31,9 +31,19 @@ export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, a
31
31
  try {
32
32
  const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
33
33
  const target = sessions.target(tabId, frameId);
34
- const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
35
- current = value(await budget.run(() => cdp(target, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
36
- expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` })));
34
+ if (args.selector_type === 'role') {
35
+ const node = await resolveSelector(cdp, value, sessions, tabId, args, budget, { wait: false });
36
+ try {
37
+ current = node ? value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
38
+ functionDeclaration: `function(args) { return (${inspectWait.toString()})(() => this, (${inspectTarget.toString()}), args); }`, arguments: [{ value: { selector: args.selector, condition: args.condition, expected: args.expected, previous: args.previous } }] }))) : { satisfied: false, state: 'absent' };
39
+ } finally {
40
+ if (node) await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
41
+ }
42
+ } else {
43
+ const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
44
+ current = value(await budget.run(() => cdp(target, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
45
+ expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` })));
46
+ }
37
47
  const stable = current?.ready && previous?.ready && JSON.stringify(current) === JSON.stringify(previous);
38
48
  if (current?.satisfied && (args.condition !== 'actionable' || stable)) {
39
49
  if (args.condition === 'actionable') {
@@ -70,7 +80,7 @@ export function inspectWait(query, inspect, args) {
70
80
  if (!element) return { satisfied: false, state: 'absent' };
71
81
  if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
72
82
  if (args.condition === 'text') {
73
- const text = element.textContent || '';
83
+ const text = element.innerText ?? '';
74
84
  return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
75
85
  }
76
86
  const bounds = inspect.call(element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.17.0';
18
+ export const VERSION = '0.18.0';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;