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.
@@ -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='true'],summary";
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 collectRoots() {
17
+ function scanPageElements(maxNodes = MAX_SCAN_NODES) {
8
18
  const roots = [document];
9
- for (let index = 0; index < roots.length && roots.length <= MAX_SHADOW_ROOTS; index += 1) {
10
- const root = roots[index];
11
- for (const element of root.querySelectorAll("*")) {
12
- if (element.shadowRoot && !roots.includes(element.shadowRoot)) roots.push(element.shadowRoot);
13
- if (roots.length > MAX_SHADOW_ROOTS) break;
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 deepQuerySelectorAll(selector) {
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
- for (const root of collectRoots()) results.push(...root.querySelectorAll(selector));
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
- const maxElements = Number(params.maxElements) || 300;
126
+ pruneDetachedRefs();
127
+ const maxElements = Math.max(0, Number(params.maxElements) || 0);
33
128
  const includeValues = params.includeValues === true;
34
- const all = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
35
- const candidates = all.slice(0, maxElements);
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
- forms: deepQuerySelectorAll("form").length,
42
- open_shadow_roots: Math.max(0, collectRoots().length - 1),
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: candidates.map((element, index) => describeElement(element, index, includeValues)),
45
- truncated: all.length > candidates.length,
158
+ elements: describedElements,
159
+ truncated: scan.truncated || interactiveCount > describedElements.length,
46
160
  };
47
161
  }
48
162
 
49
- function action(params) {
50
- const element = findElement(params.selector);
51
- if (!element) throw new Error("no element matched selector");
52
- applyOne(element, params.action, params.value, params.key);
53
- return { ok: true, element: describeElement(element, 0, false) };
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 fillForm(params) {
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
- const element = findElement(field.selector);
61
- if (!element) throw new Error(`no element matched fields[${index}] selector`);
62
- applyOne(element, field.action, field.value, "");
63
- results.push({ index, action: field.action, sensitive: field.sensitive === true, element: describeElement(element, index, false) });
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
- const submitter = params.submitSelector ? findElement(params.submitSelector) : deepQuerySelectorAll("button[type='submit'],input[type='submit']")[0];
67
- if (submitter) submitter.click();
68
- else {
69
- const field = params.fields.length ? findElement(params.fields[0].selector) : null;
70
- const form = field?.form || field?.closest?.("form") || deepQuerySelectorAll("form")[0];
71
- if (!form) throw new Error("no form or submit control found");
72
- if (typeof form.requestSubmit === "function") form.requestSubmit();
73
- else form.submit();
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 = findElement(params.selector);
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 = String(element.getAttribute("type") || "").toLowerCase();
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 = String(element.value || "").slice(0, 2000);
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 = `${element.id || ""} ${element.getAttribute("name") || ""} ${element.getAttribute("aria-label") || ""} ${element.getAttribute("placeholder") || ""}`.toLowerCase();
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 applyOne(element, operation, value, key) {
129
- element.scrollIntoView({ block: "center", inline: "nearest" });
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.value = option.value;
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 pressed = String(key || value || "Enter");
450
+ const event = keyboardEventInit(key || value || "Enter");
163
451
  element.focus();
164
- element.dispatchEvent(new KeyboardEvent("keydown", { key: pressed, bubbles: true, composed: true }));
165
- element.dispatchEvent(new KeyboardEvent("keyup", { key: pressed, bubbles: true, composed: true }));
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 findElement(selector) {
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
- if (selector.css) elements = deepQuerySelectorAll(selector.css);
190
- else if (selector.id) elements = deepQuerySelectorAll(`[id="${cssEscape(selector.id)}"]`);
191
- else elements = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
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.innerText || element.textContent || "", selector.text)) return false;
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
- return elements[Number.isInteger(selector.index) ? selector.index : 0] || null;
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 String(left || "").trim().replace(/\s+/g, " ").toLowerCase() === String(right || "").trim().replace(/\s+/g, " ").toLowerCase();
592
+ return normalizedText(left) === normalizedText(right);
210
593
  }
211
594
 
212
595
  function labelText(element) {
213
- if (element.labels?.length) return [...element.labels].map((label) => label.innerText || label.textContent || "").join(" ").trim();
214
- const parent = element.closest("label");
215
- if (parent) return (parent.innerText || parent.textContent || "").trim();
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) return labelledBy.split(/\s+/).map((id) => rootById(element, id)?.textContent || "").join(" ").trim();
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
- return (element.getAttribute("aria-label") || labelText(element) || element.getAttribute("title") || element.getAttribute("alt") || element.innerText || element.textContent || "")
223
- .trim().replace(/\s+/g, " ").slice(0, 500);
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
  })();