machine-bridge-mcp 0.13.0 → 0.15.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/CHANGELOG.md +38 -0
- package/README.md +41 -12
- package/SECURITY.md +5 -5
- package/browser-extension/browser-operations.js +515 -0
- package/browser-extension/devtools-input.js +151 -0
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/page-automation.js +540 -75
- package/browser-extension/pairing.js +1 -1
- package/browser-extension/service-worker.js +158 -258
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/AUDIT.md +13 -1
- package/docs/LOCAL_AUTOMATION.md +18 -14
- package/docs/OPERATIONS.md +17 -9
- package/docs/TESTING.md +4 -4
- package/package.json +10 -4
- package/src/local/agent-context.mjs +1 -1
- package/src/local/browser-bridge.mjs +245 -91
- package/src/local/browser-command.mjs +126 -0
- package/src/local/cli.mjs +37 -5
- package/src/local/runtime.mjs +2 -0
- package/src/local/service.mjs +7 -3
- package/src/shared/tool-catalog.json +214 -8
- package/src/worker/index.ts +14 -8
|
@@ -1,83 +1,227 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
if (globalThis.__machineBridgePageAutomation) return;
|
|
3
3
|
|
|
4
|
-
const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable='
|
|
4
|
+
const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable]:not([contenteditable='false']),summary";
|
|
5
5
|
const MAX_SHADOW_ROOTS = 200;
|
|
6
|
+
const MAX_SCAN_NODES = 100000;
|
|
7
|
+
const MAX_QUERY_MATCHES = 10001;
|
|
8
|
+
const MAX_PAGE_FIELD_CHARS = 2000;
|
|
9
|
+
const MAX_PAGE_URL_CHARS = 8192;
|
|
10
|
+
const MAX_WAIT_TEXT_CHARS = 2 * 1024 * 1024;
|
|
11
|
+
const MAX_ELEMENT_REFS = 10000;
|
|
12
|
+
const elementRefs = new WeakMap();
|
|
13
|
+
const refElements = new Map();
|
|
14
|
+
let nextRef = 1;
|
|
15
|
+
let evictedRefs = 0;
|
|
6
16
|
|
|
7
|
-
function
|
|
17
|
+
function scanPageElements(maxNodes = MAX_SCAN_NODES) {
|
|
8
18
|
const roots = [document];
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
const seenRoots = new Set(roots);
|
|
20
|
+
const entries = [];
|
|
21
|
+
let visitedNodes = 0;
|
|
22
|
+
let truncated = false;
|
|
23
|
+
for (let rootIndex = 0; rootIndex < roots.length; rootIndex += 1) {
|
|
24
|
+
const root = roots[rootIndex];
|
|
25
|
+
const elements = [];
|
|
26
|
+
const source = iterateElements(root);
|
|
27
|
+
for (const element of source) {
|
|
28
|
+
if (visitedNodes >= maxNodes) {
|
|
29
|
+
truncated = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
visitedNodes += 1;
|
|
33
|
+
elements.push(element);
|
|
34
|
+
if (element.shadowRoot && !seenRoots.has(element.shadowRoot)) {
|
|
35
|
+
if (roots.length >= MAX_SHADOW_ROOTS + 1) truncated = true;
|
|
36
|
+
else {
|
|
37
|
+
seenRoots.add(element.shadowRoot);
|
|
38
|
+
roots.push(element.shadowRoot);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
14
41
|
}
|
|
42
|
+
entries.push({ root, elements });
|
|
43
|
+
if (visitedNodes >= maxNodes) break;
|
|
15
44
|
}
|
|
16
|
-
return roots;
|
|
45
|
+
return { roots, entries, visitedNodes, truncated };
|
|
17
46
|
}
|
|
18
47
|
|
|
19
|
-
function
|
|
48
|
+
function iterateElements(root) {
|
|
49
|
+
if (typeof document.createTreeWalker === "function") {
|
|
50
|
+
const walker = document.createTreeWalker(root, globalThis.NodeFilter?.SHOW_ELEMENT || 1);
|
|
51
|
+
return {
|
|
52
|
+
[Symbol.iterator]() {
|
|
53
|
+
return {
|
|
54
|
+
next() {
|
|
55
|
+
const value = walker.nextNode();
|
|
56
|
+
return value ? { value, done: false } : { value: undefined, done: true };
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return root.querySelectorAll("*");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function deepQuerySelectorAll(selector, limit = MAX_QUERY_MATCHES) {
|
|
66
|
+
if (!selector || limit < 1) return [];
|
|
67
|
+
const scan = scanPageElements();
|
|
20
68
|
const results = [];
|
|
21
|
-
|
|
69
|
+
try {
|
|
70
|
+
if (typeof document.documentElement?.matches === "function") document.documentElement.matches(selector);
|
|
71
|
+
for (const entry of scan.entries) {
|
|
72
|
+
for (const element of entry.elements) {
|
|
73
|
+
if (element.matches?.(selector)) {
|
|
74
|
+
results.push(element);
|
|
75
|
+
if (results.length >= limit) return results;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new Error(`invalid CSS selector: ${String(error?.message || error).slice(0, 300)}`);
|
|
81
|
+
}
|
|
22
82
|
return results;
|
|
23
83
|
}
|
|
24
84
|
|
|
85
|
+
function isInteractiveElement(element) {
|
|
86
|
+
const tag = String(element.tagName || "").toLowerCase();
|
|
87
|
+
return ["a", "button", "input", "select", "textarea", "summary"].includes(tag)
|
|
88
|
+
|| element.hasAttribute?.("role")
|
|
89
|
+
|| (element.hasAttribute?.("contenteditable") && String(element.getAttribute?.("contenteditable") || "").toLowerCase() !== "false");
|
|
90
|
+
}
|
|
91
|
+
|
|
25
92
|
function rootById(element, id) {
|
|
26
93
|
const root = element.getRootNode?.();
|
|
27
94
|
if (root && typeof root.getElementById === "function") return root.getElementById(id);
|
|
28
95
|
return document.getElementById(id);
|
|
29
96
|
}
|
|
30
97
|
|
|
98
|
+
function refFor(element) {
|
|
99
|
+
let ref = elementRefs.get(element);
|
|
100
|
+
if (!ref) {
|
|
101
|
+
ref = `e${nextRef++}`;
|
|
102
|
+
elementRefs.set(element, ref);
|
|
103
|
+
}
|
|
104
|
+
rememberRef(ref, element);
|
|
105
|
+
return ref;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rememberRef(ref, element) {
|
|
109
|
+
if (refElements.has(ref)) refElements.delete(ref);
|
|
110
|
+
while (refElements.size >= MAX_ELEMENT_REFS) {
|
|
111
|
+
const oldest = refElements.keys().next().value;
|
|
112
|
+
if (!oldest) break;
|
|
113
|
+
refElements.delete(oldest);
|
|
114
|
+
evictedRefs += 1;
|
|
115
|
+
}
|
|
116
|
+
refElements.set(ref, element);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function pruneDetachedRefs() {
|
|
120
|
+
for (const [ref, element] of refElements) {
|
|
121
|
+
if (!element?.isConnected) refElements.delete(ref);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
31
125
|
function inspect(params) {
|
|
32
|
-
|
|
126
|
+
pruneDetachedRefs();
|
|
127
|
+
const maxElements = Math.max(0, Number(params.maxElements) || 0);
|
|
33
128
|
const includeValues = params.includeValues === true;
|
|
34
|
-
const
|
|
35
|
-
const candidates =
|
|
129
|
+
const scan = scanPageElements();
|
|
130
|
+
const candidates = [];
|
|
131
|
+
let interactiveCount = 0;
|
|
132
|
+
let formCount = 0;
|
|
133
|
+
for (const entry of scan.entries) {
|
|
134
|
+
for (const element of entry.elements) {
|
|
135
|
+
if (String(element.tagName || "").toLowerCase() === "form") formCount += 1;
|
|
136
|
+
if (!isInteractiveElement(element)) continue;
|
|
137
|
+
interactiveCount += 1;
|
|
138
|
+
if (candidates.length < maxElements) candidates.push(element);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const describedElements = candidates.map((element, index) => describeElement(element, index, includeValues));
|
|
36
142
|
return {
|
|
143
|
+
snapshot_version: 2,
|
|
37
144
|
document: {
|
|
38
|
-
title: document.title,
|
|
39
|
-
url: location.href,
|
|
40
|
-
language: document.documentElement.lang
|
|
41
|
-
|
|
42
|
-
|
|
145
|
+
title: boundedPageText(document.title, 1000),
|
|
146
|
+
url: safePageUrl(location.href),
|
|
147
|
+
language: boundedPageText(document.documentElement.lang, 100),
|
|
148
|
+
ready_state: document.readyState,
|
|
149
|
+
forms: formCount,
|
|
150
|
+
open_shadow_roots: Math.max(0, scan.roots.length - 1),
|
|
151
|
+
scanned_nodes: scan.visitedNodes,
|
|
152
|
+
scan_limit: MAX_SCAN_NODES,
|
|
153
|
+
scan_truncated: scan.truncated,
|
|
154
|
+
tracked_refs: refElements.size,
|
|
155
|
+
ref_limit: MAX_ELEMENT_REFS,
|
|
156
|
+
refs_evicted: evictedRefs,
|
|
43
157
|
},
|
|
44
|
-
elements:
|
|
45
|
-
truncated:
|
|
158
|
+
elements: describedElements,
|
|
159
|
+
truncated: scan.truncated || interactiveCount > describedElements.length,
|
|
46
160
|
};
|
|
47
161
|
}
|
|
48
162
|
|
|
49
|
-
function
|
|
50
|
-
const element =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
163
|
+
async function prepareAction(params) {
|
|
164
|
+
const element = await prepareElementForAction(params);
|
|
165
|
+
const result = actionTarget(element);
|
|
166
|
+
return { ok: true, element: describeElement(element, 0, false), point: result.point };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function action(params) {
|
|
170
|
+
const element = await prepareElementForAction(params);
|
|
171
|
+
await applyOne(element, params.action, params.value, params.key);
|
|
172
|
+
return { ok: true, element: describeElement(element, 0, false), input_mode: "dom" };
|
|
54
173
|
}
|
|
55
174
|
|
|
56
|
-
function
|
|
175
|
+
async function prepareElementForAction(params) {
|
|
176
|
+
const timeoutMs = Number(params.elementTimeoutMs) || 10000;
|
|
177
|
+
const element = await waitForActionable(params.selector, params.action, timeoutMs);
|
|
178
|
+
if (["press", "type_text", "focus"].includes(params.action)) element.focus();
|
|
179
|
+
if (["click", "double_click", "hover"].includes(params.action)) {
|
|
180
|
+
scrollElementIntoView(element);
|
|
181
|
+
await waitForStableBox(element, timeoutMs);
|
|
182
|
+
await waitForPointerTarget(element, timeoutMs);
|
|
183
|
+
} else if (params.action === "scroll_into_view") {
|
|
184
|
+
scrollElementIntoView(element);
|
|
185
|
+
}
|
|
186
|
+
return element;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function fillForm(params) {
|
|
57
190
|
const results = [];
|
|
191
|
+
const timeoutMs = Number(params.elementTimeoutMs) || 10000;
|
|
58
192
|
for (let index = 0; index < params.fields.length; index += 1) {
|
|
59
193
|
const field = params.fields[index];
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
194
|
+
try {
|
|
195
|
+
const element = await waitForActionable(field.selector, field.action, timeoutMs);
|
|
196
|
+
await applyOne(element, field.action, field.value, "");
|
|
197
|
+
results.push({ index, action: field.action, sensitive: field.sensitive === true, element: describeElement(element, index, false) });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
const prefix = index > 0
|
|
200
|
+
? `form field ${index} (${field.action}) failed after ${index} earlier field(s) may have changed`
|
|
201
|
+
: `form field 0 (${field.action}) failed before any earlier field changed`;
|
|
202
|
+
throw new Error(`${prefix}: ${boundedPageText(error?.message || error, 500)}`);
|
|
203
|
+
}
|
|
64
204
|
}
|
|
65
205
|
if (params.submit) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
206
|
+
try {
|
|
207
|
+
const submitter = params.submitSelector ? await waitForActionable(params.submitSelector, "click", timeoutMs) : deepQuerySelectorAll("button[type='submit'],input[type='submit']", 1)[0];
|
|
208
|
+
if (submitter) submitter.click();
|
|
209
|
+
else {
|
|
210
|
+
const field = params.fields.length ? findOne(params.fields[0].selector) : null;
|
|
211
|
+
const form = field?.form || field?.closest?.("form") || deepQuerySelectorAll("form", 1)[0];
|
|
212
|
+
if (!form) throw new Error("no form or submit control found");
|
|
213
|
+
if (typeof form.requestSubmit === "function") form.requestSubmit();
|
|
214
|
+
else form.submit();
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
throw new Error(`form submission failed after ${results.length} field(s) changed: ${boundedPageText(error?.message || error, 500)}`);
|
|
74
218
|
}
|
|
75
219
|
}
|
|
76
220
|
return { ok: true, fields: results, submitted: params.submit === true, values_exposed: false };
|
|
77
221
|
}
|
|
78
222
|
|
|
79
|
-
function uploadFiles(params) {
|
|
80
|
-
const input =
|
|
223
|
+
async function uploadFiles(params) {
|
|
224
|
+
const input = await waitForActionable(params.selector, "upload", Number(params.elementTimeoutMs) || 10000);
|
|
81
225
|
if (!(input instanceof HTMLInputElement) || input.type !== "file") throw new Error("matched element is not a file input");
|
|
82
226
|
const transfer = new DataTransfer();
|
|
83
227
|
for (const item of params.files) {
|
|
@@ -88,47 +232,183 @@
|
|
|
88
232
|
}
|
|
89
233
|
input.files = transfer.files;
|
|
90
234
|
dispatchValueEvents(input);
|
|
91
|
-
return { ok: true, file_count: transfer.files.length, values_exposed: false };
|
|
235
|
+
return { ok: true, file_count: transfer.files.length, element: describeElement(input, 0, false), values_exposed: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function checkWait(params) {
|
|
239
|
+
const elements = params.selector ? findElements(params.selector, { allowStaleRef: true }) : [];
|
|
240
|
+
const visible = elements.filter(isVisible);
|
|
241
|
+
const enabled = elements.filter(isEnabled);
|
|
242
|
+
const editable = elements.filter(isEditable);
|
|
243
|
+
const stateMatched = !params.state
|
|
244
|
+
|| (params.state === "attached" && elements.length > 0)
|
|
245
|
+
|| (params.state === "detached" && elements.length === 0)
|
|
246
|
+
|| (params.state === "visible" && visible.length > 0)
|
|
247
|
+
|| (params.state === "hidden" && visible.length === 0)
|
|
248
|
+
|| (params.state === "enabled" && enabled.length > 0)
|
|
249
|
+
|| (params.state === "editable" && editable.length > 0)
|
|
250
|
+
|| (params.state === "checked" && elements.some((element) => "checked" in element && element.checked === true))
|
|
251
|
+
|| (params.state === "unchecked" && elements.some((element) => "checked" in element && element.checked === false));
|
|
252
|
+
const textSearch = params.text ? pageContainsText(params.text) : { found: true, truncated: false, scanned_chars: 0 };
|
|
253
|
+
const textMatched = textSearch.found;
|
|
254
|
+
const loadMatched = !params.loadState
|
|
255
|
+
|| (params.loadState === "domcontentloaded" && ["interactive", "complete"].includes(document.readyState))
|
|
256
|
+
|| (params.loadState === "complete" && document.readyState === "complete");
|
|
257
|
+
return {
|
|
258
|
+
matched: stateMatched && textMatched && loadMatched,
|
|
259
|
+
selector_count: elements.length,
|
|
260
|
+
visible_count: visible.length,
|
|
261
|
+
state: params.state || "",
|
|
262
|
+
text_found: textMatched,
|
|
263
|
+
text_scan_truncated: textSearch.truncated,
|
|
264
|
+
text_scanned_chars: textSearch.scanned_chars,
|
|
265
|
+
ready_state: document.readyState,
|
|
266
|
+
};
|
|
92
267
|
}
|
|
93
268
|
|
|
94
269
|
function describeElement(element, index, includeValues) {
|
|
95
|
-
const tag = element.tagName.toLowerCase();
|
|
96
|
-
const type =
|
|
270
|
+
const tag = String(element.tagName || "").toLowerCase();
|
|
271
|
+
const type = boundedPageText(element.getAttribute("type"), 100).toLowerCase();
|
|
97
272
|
const sensitive = isSensitiveElement(element, tag, type);
|
|
273
|
+
const visible = isVisible(element);
|
|
98
274
|
const item = {
|
|
275
|
+
ref: refFor(element),
|
|
99
276
|
index,
|
|
100
277
|
tag,
|
|
101
278
|
type,
|
|
102
|
-
role: element.getAttribute("role") || implicitRole(element),
|
|
279
|
+
role: boundedPageText(element.getAttribute("role") || implicitRole(element), 200),
|
|
103
280
|
name: accessibleName(element),
|
|
104
|
-
id: element.id
|
|
105
|
-
field_name: element.getAttribute("name")
|
|
281
|
+
id: boundedPageText(element.id, MAX_PAGE_FIELD_CHARS),
|
|
282
|
+
field_name: boundedPageText(element.getAttribute("name"), MAX_PAGE_FIELD_CHARS),
|
|
106
283
|
label: labelText(element),
|
|
107
|
-
placeholder: element.getAttribute("placeholder")
|
|
284
|
+
placeholder: boundedPageText(element.getAttribute("placeholder"), MAX_PAGE_FIELD_CHARS),
|
|
285
|
+
visible,
|
|
286
|
+
enabled: isEnabled(element),
|
|
287
|
+
editable: isEditable(element),
|
|
108
288
|
disabled: Boolean(element.disabled),
|
|
109
289
|
checked: "checked" in element ? Boolean(element.checked) : null,
|
|
110
|
-
selected: tag === "select" ? element.value : null,
|
|
111
|
-
href: tag === "a" ? element.href : "",
|
|
290
|
+
selected: tag === "select" ? boundedPageText(element.value, MAX_PAGE_FIELD_CHARS) : null,
|
|
291
|
+
href: tag === "a" ? safePageUrl(element.href) : "",
|
|
112
292
|
sensitive,
|
|
113
293
|
in_shadow_dom: element.getRootNode?.() instanceof ShadowRoot,
|
|
294
|
+
bounding_box: visible ? boundingBox(element) : null,
|
|
114
295
|
};
|
|
115
|
-
if (includeValues && !sensitive && "value" in element) item.value =
|
|
296
|
+
if (includeValues && !sensitive && "value" in element) item.value = boundedPageText(element.value, MAX_PAGE_FIELD_CHARS);
|
|
116
297
|
return item;
|
|
117
298
|
}
|
|
118
299
|
|
|
119
300
|
function isSensitiveElement(element, tag, type) {
|
|
120
|
-
if (tag !== "input" && tag !== "textarea") return false;
|
|
301
|
+
if (tag !== "input" && tag !== "textarea" && !element.isContentEditable) return false;
|
|
121
302
|
if (["password", "hidden"].includes(type)) return true;
|
|
122
303
|
const autocomplete = String(element.getAttribute("autocomplete") || "").toLowerCase();
|
|
123
304
|
if (/(?:password|one-time-code|cc-number|cc-csc|cc-exp)/.test(autocomplete)) return true;
|
|
124
|
-
const identity =
|
|
305
|
+
const identity = [element.id, element.getAttribute("name"), element.getAttribute("aria-label"), element.getAttribute("placeholder")]
|
|
306
|
+
.map((value) => boundedPageText(value, 500))
|
|
307
|
+
.join(" ")
|
|
308
|
+
.toLowerCase();
|
|
125
309
|
return /(?:password|passwd|secret|token|api[-_ ]?key|otp|one[-_ ]?time|verification|cvc|cvv|security[-_ ]?code|card[-_ ]?number)/.test(identity);
|
|
126
310
|
}
|
|
127
311
|
|
|
128
|
-
function
|
|
129
|
-
|
|
312
|
+
async function waitForActionable(selector, actionName, timeoutMs) {
|
|
313
|
+
const deadline = Date.now() + Math.max(1, timeoutMs);
|
|
314
|
+
let lastProblem = "no element matched selector";
|
|
315
|
+
while (Date.now() <= deadline) {
|
|
316
|
+
const element = findOne(selector);
|
|
317
|
+
if (!element && selector?.ref) throw new Error("element reference is stale; inspect the page again");
|
|
318
|
+
if (element) {
|
|
319
|
+
lastProblem = actionabilityProblem(element, actionName);
|
|
320
|
+
if (!lastProblem) return element;
|
|
321
|
+
}
|
|
322
|
+
await delay(100);
|
|
323
|
+
}
|
|
324
|
+
throw new Error(`element was not actionable before timeout: ${lastProblem}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function actionabilityProblem(element, actionName) {
|
|
328
|
+
if (!element.isConnected) return "element is detached";
|
|
329
|
+
if (actionName === "upload") return isEnabled(element) ? "" : "file input is disabled";
|
|
330
|
+
if (actionName === "scroll_into_view") return "";
|
|
331
|
+
if (!isVisible(element)) return "element is not visible";
|
|
332
|
+
if (["click", "double_click", "hover", "fill", "type_text", "select", "check", "uncheck", "press", "submit"].includes(actionName) && !isEnabled(element)) return "element is disabled";
|
|
333
|
+
if (["fill", "type_text"].includes(actionName) && !isEditable(element)) return "element is not editable";
|
|
334
|
+
if (actionName === "select" && !(element instanceof HTMLSelectElement)) return "element is not a select control";
|
|
335
|
+
if (["check", "uncheck"].includes(actionName) && !("checked" in element)) return "element is not checkable";
|
|
336
|
+
return "";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function waitForStableBox(element, timeoutMs) {
|
|
340
|
+
const deadline = Date.now() + Math.max(1, timeoutMs);
|
|
341
|
+
let previous = boundingBox(element);
|
|
342
|
+
while (Date.now() <= deadline) {
|
|
343
|
+
await delay(50);
|
|
344
|
+
const current = boundingBox(element);
|
|
345
|
+
if (current && previous && boxDistance(previous, current) <= 0.5) return current;
|
|
346
|
+
previous = current;
|
|
347
|
+
}
|
|
348
|
+
throw new Error("element did not become geometrically stable before timeout");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function waitForPointerTarget(element, timeoutMs) {
|
|
352
|
+
const deadline = Date.now() + Math.max(1, timeoutMs);
|
|
353
|
+
let lastProblem = "element does not receive pointer events";
|
|
354
|
+
while (Date.now() <= deadline) {
|
|
355
|
+
const point = actionTarget(element).point;
|
|
356
|
+
if (!point) lastProblem = "element has no usable viewport box";
|
|
357
|
+
else if (point.x < 0 || point.y < 0 || point.x >= innerWidth || point.y >= innerHeight) lastProblem = "element is outside the viewport";
|
|
358
|
+
else if (receivesPointerEvents(element, point)) return;
|
|
359
|
+
else lastProblem = "element is obscured by another element";
|
|
360
|
+
await delay(100);
|
|
361
|
+
}
|
|
362
|
+
throw new Error(`element was not clickable before timeout: ${lastProblem}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function receivesPointerEvents(element, point) {
|
|
366
|
+
const hit = document.elementFromPoint(point.x, point.y);
|
|
367
|
+
if (!hit) return false;
|
|
368
|
+
if (hit === element || element.contains?.(hit)) return true;
|
|
369
|
+
const surfaces = targetSurfaces(element);
|
|
370
|
+
return surfaces.slice(1).some((host) => host === hit);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function targetSurfaces(element) {
|
|
374
|
+
const surfaces = [element];
|
|
375
|
+
let root = element.getRootNode?.();
|
|
376
|
+
while (root instanceof ShadowRoot) {
|
|
377
|
+
surfaces.push(root.host);
|
|
378
|
+
root = root.host.getRootNode?.();
|
|
379
|
+
}
|
|
380
|
+
return surfaces;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function scrollElementIntoView(element) {
|
|
384
|
+
element.scrollIntoView({ behavior: "instant", block: "center", inline: "nearest" });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function actionTarget(element) {
|
|
388
|
+
const box = boundingBox(element);
|
|
389
|
+
if (!box) return { point: null };
|
|
390
|
+
const viewportWidth = Number(globalThis.innerWidth);
|
|
391
|
+
const viewportHeight = Number(globalThis.innerHeight);
|
|
392
|
+
const left = Math.max(0, box.x);
|
|
393
|
+
const top = Math.max(0, box.y);
|
|
394
|
+
const right = Math.min(Number.isFinite(viewportWidth) ? viewportWidth : box.x + box.width, box.x + box.width);
|
|
395
|
+
const bottom = Math.min(Number.isFinite(viewportHeight) ? viewportHeight : box.y + box.height, box.y + box.height);
|
|
396
|
+
if (right <= left || bottom <= top) return { point: null };
|
|
397
|
+
return { point: { x: left + (right - left) / 2, y: top + (bottom - top) / 2 } };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function applyOne(element, operation, value, key) {
|
|
401
|
+
if (!["click", "double_click", "hover", "scroll_into_view"].includes(operation)) scrollElementIntoView(element);
|
|
130
402
|
if (operation === "click") { element.click(); return; }
|
|
403
|
+
if (operation === "double_click") {
|
|
404
|
+
element.click();
|
|
405
|
+
element.click();
|
|
406
|
+
element.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, composed: true, detail: 2 }));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (operation === "hover") { dispatchHoverEvents(element); return; }
|
|
131
410
|
if (operation === "focus") { element.focus(); return; }
|
|
411
|
+
if (operation === "scroll_into_view") return;
|
|
132
412
|
if (operation === "submit") {
|
|
133
413
|
const form = element.form || element.closest("form");
|
|
134
414
|
if (!form) throw new Error("matched element is not associated with a form");
|
|
@@ -136,38 +416,71 @@
|
|
|
136
416
|
return;
|
|
137
417
|
}
|
|
138
418
|
if (operation === "check" || operation === "uncheck") {
|
|
139
|
-
if (!("checked" in element)) throw new Error("matched element is not checkable");
|
|
140
419
|
const wanted = operation === "check";
|
|
141
420
|
if (Boolean(element.checked) !== wanted) element.click();
|
|
421
|
+
if (Boolean(element.checked) !== wanted) throw new Error(`checkable control did not reach the requested ${wanted ? "checked" : "unchecked"} state`);
|
|
142
422
|
return;
|
|
143
423
|
}
|
|
144
424
|
if (operation === "select") {
|
|
145
|
-
if (!(element instanceof HTMLSelectElement)) throw new Error("matched element is not a select control");
|
|
146
425
|
const text = String(value ?? "");
|
|
147
426
|
const option = [...element.options].find((item) => item.value === text || item.text.trim() === text);
|
|
148
427
|
if (!option) throw new Error("select option was not found");
|
|
149
|
-
element
|
|
428
|
+
setNativeValue(element, option.value);
|
|
150
429
|
dispatchValueEvents(element);
|
|
151
430
|
return;
|
|
152
431
|
}
|
|
153
432
|
if (operation === "fill") {
|
|
154
|
-
if (!("value" in element) && !element.isContentEditable) throw new Error("matched element is not editable");
|
|
155
433
|
element.focus();
|
|
156
434
|
if (element.isContentEditable) element.textContent = String(value ?? "");
|
|
157
435
|
else setNativeValue(element, String(value ?? ""));
|
|
158
436
|
dispatchValueEvents(element);
|
|
159
437
|
return;
|
|
160
438
|
}
|
|
439
|
+
if (operation === "type_text") {
|
|
440
|
+
element.focus();
|
|
441
|
+
const text = String(value ?? "");
|
|
442
|
+
if (element.isContentEditable) element.textContent = `${element.textContent || ""}${text}`;
|
|
443
|
+
else if (typeof element.setRangeText === "function" && Number.isInteger(element.selectionStart) && Number.isInteger(element.selectionEnd)) {
|
|
444
|
+
element.setRangeText(text, element.selectionStart, element.selectionEnd, "end");
|
|
445
|
+
} else setNativeValue(element, `${String(element.value || "")}${text}`);
|
|
446
|
+
dispatchValueEvents(element);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
161
449
|
if (operation === "press") {
|
|
162
|
-
const
|
|
450
|
+
const event = keyboardEventInit(key || value || "Enter");
|
|
163
451
|
element.focus();
|
|
164
|
-
element.dispatchEvent(new KeyboardEvent("keydown", {
|
|
165
|
-
element.dispatchEvent(new KeyboardEvent("keyup", {
|
|
452
|
+
element.dispatchEvent(new KeyboardEvent("keydown", { ...event, bubbles: true, composed: true }));
|
|
453
|
+
element.dispatchEvent(new KeyboardEvent("keyup", { ...event, bubbles: true, composed: true }));
|
|
166
454
|
return;
|
|
167
455
|
}
|
|
168
456
|
throw new Error(`unsupported element action: ${operation}`);
|
|
169
457
|
}
|
|
170
458
|
|
|
459
|
+
function keyboardEventInit(value) {
|
|
460
|
+
const parts = String(value || "Enter").split("+").map((part) => part.trim()).filter(Boolean);
|
|
461
|
+
const rawKey = parts.pop() || "Enter";
|
|
462
|
+
const modifiers = new Set(parts.map((part) => part === "Ctrl" ? "Control" : part === "Cmd" || part === "Command" ? "Meta" : part));
|
|
463
|
+
return {
|
|
464
|
+
key: rawKey === "Space" ? " " : rawKey,
|
|
465
|
+
altKey: modifiers.has("Alt"),
|
|
466
|
+
ctrlKey: modifiers.has("Control"),
|
|
467
|
+
metaKey: modifiers.has("Meta"),
|
|
468
|
+
shiftKey: modifiers.has("Shift"),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function dispatchHoverEvents(element) {
|
|
473
|
+
const options = { bubbles: true, composed: true };
|
|
474
|
+
if (typeof PointerEvent === "function") {
|
|
475
|
+
element.dispatchEvent(new PointerEvent("pointerover", options));
|
|
476
|
+
element.dispatchEvent(new PointerEvent("pointerenter", { ...options, bubbles: false }));
|
|
477
|
+
element.dispatchEvent(new PointerEvent("pointermove", options));
|
|
478
|
+
}
|
|
479
|
+
element.dispatchEvent(new MouseEvent("mouseover", options));
|
|
480
|
+
element.dispatchEvent(new MouseEvent("mouseenter", { ...options, bubbles: false }));
|
|
481
|
+
element.dispatchEvent(new MouseEvent("mousemove", options));
|
|
482
|
+
}
|
|
483
|
+
|
|
171
484
|
function setNativeValue(element, value) {
|
|
172
485
|
const prototype = element instanceof HTMLTextAreaElement
|
|
173
486
|
? HTMLTextAreaElement.prototype
|
|
@@ -184,20 +497,86 @@
|
|
|
184
497
|
element.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
|
|
185
498
|
}
|
|
186
499
|
|
|
187
|
-
function
|
|
500
|
+
function findOne(selector) {
|
|
501
|
+
const elements = findElements(selector);
|
|
502
|
+
if (!elements.length) return null;
|
|
503
|
+
if (elements.length > 1 && !Number.isInteger(selector.index)) throw new Error(`selector matched ${elements.length} elements; use ref or index to disambiguate`);
|
|
504
|
+
return elements[0];
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function findElements(selector, { allowStaleRef = false } = {}) {
|
|
508
|
+
if (selector.ref) {
|
|
509
|
+
const element = refElements.get(String(selector.ref));
|
|
510
|
+
if (!element) return [];
|
|
511
|
+
if (!element.isConnected) {
|
|
512
|
+
if (allowStaleRef) return [];
|
|
513
|
+
throw new Error("element reference is stale; inspect the page again");
|
|
514
|
+
}
|
|
515
|
+
return [element];
|
|
516
|
+
}
|
|
188
517
|
let elements = [];
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
518
|
+
try {
|
|
519
|
+
if (selector.css) elements = deepQuerySelectorAll(selector.css);
|
|
520
|
+
else if (selector.id) elements = deepQuerySelectorAll(`[id="${cssEscape(selector.id)}"]`);
|
|
521
|
+
else elements = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
throw new Error(`invalid CSS selector: ${String(error?.message || error).slice(0, 300)}`);
|
|
524
|
+
}
|
|
192
525
|
elements = elements.filter((element) => {
|
|
193
526
|
if (selector.name && element.getAttribute("name") !== selector.name) return false;
|
|
194
527
|
if (selector.label && !sameText(labelText(element), selector.label)) return false;
|
|
195
|
-
if (selector.text && !sameText(element
|
|
528
|
+
if (selector.text && !sameText(boundedNodeText(element, MAX_PAGE_FIELD_CHARS + 1), selector.text)) return false;
|
|
196
529
|
if (selector.role && !sameText(element.getAttribute("role") || implicitRole(element), selector.role)) return false;
|
|
197
530
|
if (selector.placeholder && !sameText(element.getAttribute("placeholder") || "", selector.placeholder)) return false;
|
|
198
531
|
return true;
|
|
199
532
|
});
|
|
200
|
-
|
|
533
|
+
if (Number.isInteger(selector.index)) return elements[selector.index] ? [elements[selector.index]] : [];
|
|
534
|
+
return elements;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isVisible(element) {
|
|
538
|
+
if (!element?.isConnected) return false;
|
|
539
|
+
if (typeof element.checkVisibility === "function" && !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
|
|
540
|
+
const style = getComputedStyle(element);
|
|
541
|
+
if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number(style.opacity) === 0) return false;
|
|
542
|
+
const rect = element.getBoundingClientRect();
|
|
543
|
+
return rect.width > 0 && rect.height > 0 && (!element.getClientRects || element.getClientRects().length > 0);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isEnabled(element) {
|
|
547
|
+
if (element.matches?.(":disabled")) return false;
|
|
548
|
+
if (element.disabled) return false;
|
|
549
|
+
return String(element.getAttribute("aria-disabled") || "").toLowerCase() !== "true"
|
|
550
|
+
&& !element.closest?.('[aria-disabled="true"]');
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function isEditable(element) {
|
|
554
|
+
if (!isEnabled(element)) return false;
|
|
555
|
+
if (element.isContentEditable) return true;
|
|
556
|
+
if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) return false;
|
|
557
|
+
if (element.readOnly) return false;
|
|
558
|
+
const type = String(element.type || "text").toLowerCase();
|
|
559
|
+
return !["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"].includes(type);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function boundingBox(element) {
|
|
563
|
+
if (!element?.isConnected) return null;
|
|
564
|
+
const rect = element.getBoundingClientRect();
|
|
565
|
+
if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) return null;
|
|
566
|
+
return {
|
|
567
|
+
x: Math.round(rect.x * 100) / 100,
|
|
568
|
+
y: Math.round(rect.y * 100) / 100,
|
|
569
|
+
width: Math.round(rect.width * 100) / 100,
|
|
570
|
+
height: Math.round(rect.height * 100) / 100,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function boxDistance(left, right) {
|
|
575
|
+
if (!left || !right) return Number.POSITIVE_INFINITY;
|
|
576
|
+
return Math.max(
|
|
577
|
+
Math.abs(left.x - right.x), Math.abs(left.y - right.y),
|
|
578
|
+
Math.abs(left.width - right.width), Math.abs(left.height - right.height),
|
|
579
|
+
);
|
|
201
580
|
}
|
|
202
581
|
|
|
203
582
|
function cssEscape(value) {
|
|
@@ -205,22 +584,104 @@
|
|
|
205
584
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
206
585
|
}
|
|
207
586
|
|
|
587
|
+
function normalizedText(value) {
|
|
588
|
+
return boundedPageText(value, MAX_WAIT_TEXT_CHARS).trim().replace(/\s+/g, " ").toLowerCase();
|
|
589
|
+
}
|
|
590
|
+
|
|
208
591
|
function sameText(left, right) {
|
|
209
|
-
return
|
|
592
|
+
return normalizedText(left) === normalizedText(right);
|
|
210
593
|
}
|
|
211
594
|
|
|
212
595
|
function labelText(element) {
|
|
213
|
-
if (element.labels?.length)
|
|
214
|
-
|
|
215
|
-
|
|
596
|
+
if (element.labels?.length) {
|
|
597
|
+
let text = "";
|
|
598
|
+
for (const label of [...element.labels].slice(0, 100)) {
|
|
599
|
+
text += ` ${boundedNodeText(label, Math.max(0, MAX_PAGE_FIELD_CHARS - text.length))}`;
|
|
600
|
+
if (text.length >= MAX_PAGE_FIELD_CHARS) break;
|
|
601
|
+
}
|
|
602
|
+
return boundedPageText(text.trim(), MAX_PAGE_FIELD_CHARS);
|
|
603
|
+
}
|
|
604
|
+
const parent = element.closest?.("label");
|
|
605
|
+
if (parent) return boundedNodeText(parent, MAX_PAGE_FIELD_CHARS);
|
|
216
606
|
const labelledBy = element.getAttribute("aria-labelledby");
|
|
217
|
-
if (labelledBy)
|
|
607
|
+
if (labelledBy) {
|
|
608
|
+
let text = "";
|
|
609
|
+
for (const id of labelledBy.split(/\s+/).slice(0, 100)) {
|
|
610
|
+
const labelled = rootById(element, id);
|
|
611
|
+
text += ` ${boundedNodeText(labelled, Math.max(0, MAX_PAGE_FIELD_CHARS - text.length))}`;
|
|
612
|
+
if (text.length >= MAX_PAGE_FIELD_CHARS) break;
|
|
613
|
+
}
|
|
614
|
+
return boundedPageText(text.trim(), MAX_PAGE_FIELD_CHARS);
|
|
615
|
+
}
|
|
218
616
|
return "";
|
|
219
617
|
}
|
|
220
618
|
|
|
221
619
|
function accessibleName(element) {
|
|
222
|
-
|
|
223
|
-
|
|
620
|
+
const direct = element.getAttribute("aria-label") || labelText(element) || element.getAttribute("title") || element.getAttribute("alt");
|
|
621
|
+
return boundedPageText(direct || boundedNodeText(element, 500), 500).trim().replace(/\s+/g, " ");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function boundedNodeText(node, maxChars) {
|
|
625
|
+
const limit = Math.max(0, Number(maxChars) || 0);
|
|
626
|
+
if (!node || limit === 0) return "";
|
|
627
|
+
if (typeof document.createTreeWalker !== "function") return boundedPageText(node.textContent || node.innerText, limit);
|
|
628
|
+
const walker = document.createTreeWalker(node, globalThis.NodeFilter?.SHOW_TEXT || 4);
|
|
629
|
+
let text = "";
|
|
630
|
+
let current;
|
|
631
|
+
while ((current = walker.nextNode()) && text.length < limit) {
|
|
632
|
+
const remaining = limit - text.length;
|
|
633
|
+
text += boundedPageText(current.data, remaining);
|
|
634
|
+
}
|
|
635
|
+
return text;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function pageContainsText(value) {
|
|
639
|
+
const needle = normalizedText(value);
|
|
640
|
+
if (!needle) return { found: true, truncated: false, scanned_chars: 0 };
|
|
641
|
+
const root = document.body || document.documentElement;
|
|
642
|
+
if (!root) return { found: false, truncated: false, scanned_chars: 0 };
|
|
643
|
+
if (typeof document.createTreeWalker !== "function") {
|
|
644
|
+
const text = boundedPageText(root.textContent || root.innerText, MAX_WAIT_TEXT_CHARS);
|
|
645
|
+
return { found: normalizedText(text).includes(needle), truncated: String(root.textContent || root.innerText || "").length > text.length, scanned_chars: text.length };
|
|
646
|
+
}
|
|
647
|
+
const walker = document.createTreeWalker(root, globalThis.NodeFilter?.SHOW_TEXT || 4);
|
|
648
|
+
let haystack = "";
|
|
649
|
+
let truncated = false;
|
|
650
|
+
let scannedChars = 0;
|
|
651
|
+
let node;
|
|
652
|
+
while ((node = walker.nextNode())) {
|
|
653
|
+
const remaining = MAX_WAIT_TEXT_CHARS - scannedChars;
|
|
654
|
+
if (remaining <= 0) { truncated = true; break; }
|
|
655
|
+
const raw = String(node.data || "");
|
|
656
|
+
const boundedRaw = raw.slice(0, remaining);
|
|
657
|
+
scannedChars += boundedRaw.length;
|
|
658
|
+
const chunk = normalizedText(boundedRaw);
|
|
659
|
+
if (chunk) {
|
|
660
|
+
haystack = haystack ? `${haystack} ${chunk}` : chunk;
|
|
661
|
+
if (haystack.includes(needle)) return { found: true, truncated: raw.length > boundedRaw.length, scanned_chars: scannedChars };
|
|
662
|
+
if (haystack.length > needle.length * 2 + 4096) haystack = haystack.slice(-(needle.length + 4096));
|
|
663
|
+
}
|
|
664
|
+
if (raw.length > boundedRaw.length) { truncated = true; break; }
|
|
665
|
+
}
|
|
666
|
+
return { found: haystack.includes(needle), truncated, scanned_chars: Math.min(MAX_WAIT_TEXT_CHARS, scannedChars) };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function boundedPageText(value, maxChars) {
|
|
670
|
+
return String(value || "").slice(0, Math.max(0, maxChars));
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function safePageUrl(value) {
|
|
674
|
+
const text = boundedPageText(value, MAX_PAGE_URL_CHARS);
|
|
675
|
+
try {
|
|
676
|
+
const parsed = new URL(text, location.href);
|
|
677
|
+
if (parsed.username || parsed.password) {
|
|
678
|
+
parsed.username = "";
|
|
679
|
+
parsed.password = "";
|
|
680
|
+
}
|
|
681
|
+
return boundedPageText(parsed.href, MAX_PAGE_URL_CHARS);
|
|
682
|
+
} catch {
|
|
683
|
+
return text;
|
|
684
|
+
}
|
|
224
685
|
}
|
|
225
686
|
|
|
226
687
|
function implicitRole(element) {
|
|
@@ -239,8 +700,12 @@
|
|
|
239
700
|
return "";
|
|
240
701
|
}
|
|
241
702
|
|
|
703
|
+
function delay(ms) {
|
|
704
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
705
|
+
}
|
|
706
|
+
|
|
242
707
|
Object.defineProperty(globalThis, "__machineBridgePageAutomation", {
|
|
243
|
-
value: Object.freeze({ inspect, action, fillForm, uploadFiles }),
|
|
708
|
+
value: Object.freeze({ inspect, prepareAction, action, fillForm, uploadFiles, checkWait }),
|
|
244
709
|
configurable: true,
|
|
245
710
|
});
|
|
246
711
|
})();
|