@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.
- package/README.md +3 -1
- package/README.zh.md +1 -1
- package/docs/ARCHITECTURE.md +14 -9
- package/docs/TEST-REPORT.md +22 -4
- package/docs/USAGE.md +454 -431
- package/extension/batch.js +2 -2
- package/extension/connection.js +1 -1
- package/extension/controller.js +9 -4
- package/extension/evaluate.js +4 -2
- package/extension/input.js +37 -24
- package/extension/locator.js +54 -18
- package/extension/manifest.json +1 -1
- package/extension/popup.js +7 -1
- package/extension/scroll.js +9 -3
- package/extension/snapshot.js +94 -22
- package/extension/text.js +7 -5
- package/extension/wait.js +16 -6
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +14 -11
package/extension/batch.js
CHANGED
|
@@ -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);
|
package/extension/connection.js
CHANGED
|
@@ -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
|
-
|
|
129
|
+
release().then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message }));
|
|
130
130
|
return true;
|
|
131
131
|
}
|
|
132
132
|
});
|
package/extension/controller.js
CHANGED
|
@@ -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(
|
|
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
|
package/extension/evaluate.js
CHANGED
|
@@ -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'
|
|
113
|
-
kind === 'object' ? Object.fromEntries(items.slice(
|
|
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) };
|
package/extension/input.js
CHANGED
|
@@ -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
|
-
|
|
70
|
-
|
|
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
|
|
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 (!
|
|
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(
|
|
95
|
-
if (text != null && args.character == null) commands.push(send(
|
|
96
|
-
commands.push(send(
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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,
|
package/extension/locator.js
CHANGED
|
@@ -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 (
|
|
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
|
|
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
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
|
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) {
|
package/extension/manifest.json
CHANGED
package/extension/popup.js
CHANGED
|
@@ -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 () => {
|
|
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; });
|
package/extension/scroll.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
33
|
-
|
|
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
|
}
|
package/extension/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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))
|
|
118
|
+
if (kept && !kept.has(node.nodeId)) continue;
|
|
51
119
|
const role = node.role?.value || 'node';
|
|
52
|
-
if (role === 'InlineTextBox')
|
|
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)
|
|
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
|
|
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(
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
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
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.
|
|
18
|
+
export const VERSION = '0.18.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|