machine-bridge-mcp 0.12.2 → 0.14.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.
@@ -3,6 +3,9 @@
3
3
 
4
4
  const INTERACTIVE_SELECTOR = "a,button,input,select,textarea,[role],[contenteditable='true'],summary";
5
5
  const MAX_SHADOW_ROOTS = 200;
6
+ const elementRefs = new WeakMap();
7
+ const refElements = new Map();
8
+ let nextRef = 1;
6
9
 
7
10
  function collectRoots() {
8
11
  const roots = [document];
@@ -28,16 +31,37 @@
28
31
  return document.getElementById(id);
29
32
  }
30
33
 
34
+ function refFor(element) {
35
+ let ref = elementRefs.get(element);
36
+ if (ref) {
37
+ refElements.set(ref, element);
38
+ return ref;
39
+ }
40
+ ref = `e${nextRef++}`;
41
+ elementRefs.set(element, ref);
42
+ refElements.set(ref, element);
43
+ return ref;
44
+ }
45
+
46
+ function pruneDetachedRefs() {
47
+ for (const [ref, element] of refElements) {
48
+ if (!element?.isConnected) refElements.delete(ref);
49
+ }
50
+ }
51
+
31
52
  function inspect(params) {
53
+ pruneDetachedRefs();
32
54
  const maxElements = Number(params.maxElements) || 300;
33
55
  const includeValues = params.includeValues === true;
34
56
  const all = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
35
57
  const candidates = all.slice(0, maxElements);
36
58
  return {
59
+ snapshot_version: 1,
37
60
  document: {
38
61
  title: document.title,
39
62
  url: location.href,
40
63
  language: document.documentElement.lang || "",
64
+ ready_state: document.readyState,
41
65
  forms: deepQuerySelectorAll("form").length,
42
66
  open_shadow_roots: Math.max(0, collectRoots().length - 1),
43
67
  },
@@ -46,27 +70,46 @@
46
70
  };
47
71
  }
48
72
 
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) };
73
+ async function prepareAction(params) {
74
+ const element = await prepareElementForAction(params);
75
+ const result = actionTarget(element);
76
+ return { ok: true, element: describeElement(element, 0, false), point: result.point };
77
+ }
78
+
79
+ async function action(params) {
80
+ const element = await prepareElementForAction(params);
81
+ await applyOne(element, params.action, params.value, params.key);
82
+ return { ok: true, element: describeElement(element, 0, false), input_mode: "dom" };
83
+ }
84
+
85
+ async function prepareElementForAction(params) {
86
+ const timeoutMs = Number(params.elementTimeoutMs) || 10000;
87
+ const element = await waitForActionable(params.selector, params.action, timeoutMs);
88
+ if (["press", "type_text", "focus"].includes(params.action)) element.focus();
89
+ if (["click", "double_click", "hover"].includes(params.action)) {
90
+ scrollElementIntoView(element);
91
+ await waitForStableBox(element, timeoutMs);
92
+ await waitForPointerTarget(element, timeoutMs);
93
+ } else if (params.action === "scroll_into_view") {
94
+ scrollElementIntoView(element);
95
+ }
96
+ return element;
54
97
  }
55
98
 
56
- function fillForm(params) {
99
+ async function fillForm(params) {
57
100
  const results = [];
101
+ const timeoutMs = Number(params.elementTimeoutMs) || 10000;
58
102
  for (let index = 0; index < params.fields.length; index += 1) {
59
103
  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, "");
104
+ const element = await waitForActionable(field.selector, field.action, timeoutMs);
105
+ await applyOne(element, field.action, field.value, "");
63
106
  results.push({ index, action: field.action, sensitive: field.sensitive === true, element: describeElement(element, index, false) });
64
107
  }
65
108
  if (params.submit) {
66
- const submitter = params.submitSelector ? findElement(params.submitSelector) : deepQuerySelectorAll("button[type='submit'],input[type='submit']")[0];
109
+ const submitter = params.submitSelector ? await waitForActionable(params.submitSelector, "click", timeoutMs) : deepQuerySelectorAll("button[type='submit'],input[type='submit']")[0];
67
110
  if (submitter) submitter.click();
68
111
  else {
69
- const field = params.fields.length ? findElement(params.fields[0].selector) : null;
112
+ const field = params.fields.length ? findOne(params.fields[0].selector) : null;
70
113
  const form = field?.form || field?.closest?.("form") || deepQuerySelectorAll("form")[0];
71
114
  if (!form) throw new Error("no form or submit control found");
72
115
  if (typeof form.requestSubmit === "function") form.requestSubmit();
@@ -76,8 +119,8 @@
76
119
  return { ok: true, fields: results, submitted: params.submit === true, values_exposed: false };
77
120
  }
78
121
 
79
- function uploadFiles(params) {
80
- const input = findElement(params.selector);
122
+ async function uploadFiles(params) {
123
+ const input = await waitForActionable(params.selector, "upload", Number(params.elementTimeoutMs) || 10000);
81
124
  if (!(input instanceof HTMLInputElement) || input.type !== "file") throw new Error("matched element is not a file input");
82
125
  const transfer = new DataTransfer();
83
126
  for (const item of params.files) {
@@ -88,14 +131,45 @@
88
131
  }
89
132
  input.files = transfer.files;
90
133
  dispatchValueEvents(input);
91
- return { ok: true, file_count: transfer.files.length, values_exposed: false };
134
+ return { ok: true, file_count: transfer.files.length, element: describeElement(input, 0, false), values_exposed: false };
135
+ }
136
+
137
+ function checkWait(params) {
138
+ const elements = params.selector ? findElements(params.selector, { allowStaleRef: true }) : [];
139
+ const visible = elements.filter(isVisible);
140
+ const enabled = elements.filter(isEnabled);
141
+ const editable = elements.filter(isEditable);
142
+ const stateMatched = !params.state
143
+ || (params.state === "attached" && elements.length > 0)
144
+ || (params.state === "detached" && elements.length === 0)
145
+ || (params.state === "visible" && visible.length > 0)
146
+ || (params.state === "hidden" && visible.length === 0)
147
+ || (params.state === "enabled" && enabled.length > 0)
148
+ || (params.state === "editable" && editable.length > 0)
149
+ || (params.state === "checked" && elements.some((element) => "checked" in element && element.checked === true))
150
+ || (params.state === "unchecked" && elements.some((element) => "checked" in element && element.checked === false));
151
+ const pageText = params.text ? normalizedText(document.body?.innerText || document.body?.textContent || "") : "";
152
+ const textMatched = !params.text || pageText.includes(normalizedText(params.text));
153
+ const loadMatched = !params.loadState
154
+ || (params.loadState === "domcontentloaded" && ["interactive", "complete"].includes(document.readyState))
155
+ || (params.loadState === "complete" && document.readyState === "complete");
156
+ return {
157
+ matched: stateMatched && textMatched && loadMatched,
158
+ selector_count: elements.length,
159
+ visible_count: visible.length,
160
+ state: params.state || "",
161
+ text_found: textMatched,
162
+ ready_state: document.readyState,
163
+ };
92
164
  }
93
165
 
94
166
  function describeElement(element, index, includeValues) {
95
167
  const tag = element.tagName.toLowerCase();
96
168
  const type = String(element.getAttribute("type") || "").toLowerCase();
97
169
  const sensitive = isSensitiveElement(element, tag, type);
170
+ const visible = isVisible(element);
98
171
  const item = {
172
+ ref: refFor(element),
99
173
  index,
100
174
  tag,
101
175
  type,
@@ -105,12 +179,16 @@
105
179
  field_name: element.getAttribute("name") || "",
106
180
  label: labelText(element),
107
181
  placeholder: element.getAttribute("placeholder") || "",
182
+ visible,
183
+ enabled: isEnabled(element),
184
+ editable: isEditable(element),
108
185
  disabled: Boolean(element.disabled),
109
186
  checked: "checked" in element ? Boolean(element.checked) : null,
110
187
  selected: tag === "select" ? element.value : null,
111
188
  href: tag === "a" ? element.href : "",
112
189
  sensitive,
113
190
  in_shadow_dom: element.getRootNode?.() instanceof ShadowRoot,
191
+ bounding_box: visible ? boundingBox(element) : null,
114
192
  };
115
193
  if (includeValues && !sensitive && "value" in element) item.value = String(element.value || "").slice(0, 2000);
116
194
  return item;
@@ -125,10 +203,106 @@
125
203
  return /(?:password|passwd|secret|token|api[-_ ]?key|otp|one[-_ ]?time|verification|cvc|cvv|security[-_ ]?code|card[-_ ]?number)/.test(identity);
126
204
  }
127
205
 
128
- function applyOne(element, operation, value, key) {
129
- element.scrollIntoView({ block: "center", inline: "nearest" });
206
+ async function waitForActionable(selector, actionName, timeoutMs) {
207
+ const deadline = Date.now() + Math.max(1, timeoutMs);
208
+ let lastProblem = "no element matched selector";
209
+ while (Date.now() <= deadline) {
210
+ const element = findOne(selector);
211
+ if (!element && selector?.ref) throw new Error("element reference is stale; inspect the page again");
212
+ if (element) {
213
+ lastProblem = actionabilityProblem(element, actionName);
214
+ if (!lastProblem) return element;
215
+ }
216
+ await delay(100);
217
+ }
218
+ throw new Error(`element was not actionable before timeout: ${lastProblem}`);
219
+ }
220
+
221
+ function actionabilityProblem(element, actionName) {
222
+ if (!element.isConnected) return "element is detached";
223
+ if (actionName === "upload") return isEnabled(element) ? "" : "file input is disabled";
224
+ if (actionName === "scroll_into_view") return "";
225
+ if (!isVisible(element)) return "element is not visible";
226
+ if (["click", "double_click", "hover", "fill", "type_text", "select", "check", "uncheck", "press", "submit"].includes(actionName) && !isEnabled(element)) return "element is disabled";
227
+ if (["fill", "type_text"].includes(actionName) && !isEditable(element)) return "element is not editable";
228
+ if (actionName === "select" && !(element instanceof HTMLSelectElement)) return "element is not a select control";
229
+ if (["check", "uncheck"].includes(actionName) && !("checked" in element)) return "element is not checkable";
230
+ return "";
231
+ }
232
+
233
+ async function waitForStableBox(element, timeoutMs) {
234
+ const deadline = Date.now() + Math.max(1, timeoutMs);
235
+ let previous = boundingBox(element);
236
+ while (Date.now() <= deadline) {
237
+ await delay(50);
238
+ const current = boundingBox(element);
239
+ if (current && previous && boxDistance(previous, current) <= 0.5) return current;
240
+ previous = current;
241
+ }
242
+ throw new Error("element did not become geometrically stable before timeout");
243
+ }
244
+
245
+ async function waitForPointerTarget(element, timeoutMs) {
246
+ const deadline = Date.now() + Math.max(1, timeoutMs);
247
+ let lastProblem = "element does not receive pointer events";
248
+ while (Date.now() <= deadline) {
249
+ const point = actionTarget(element).point;
250
+ if (!point) lastProblem = "element has no usable viewport box";
251
+ else if (point.x < 0 || point.y < 0 || point.x >= innerWidth || point.y >= innerHeight) lastProblem = "element is outside the viewport";
252
+ else if (receivesPointerEvents(element, point)) return;
253
+ else lastProblem = "element is obscured by another element";
254
+ await delay(100);
255
+ }
256
+ throw new Error(`element was not clickable before timeout: ${lastProblem}`);
257
+ }
258
+
259
+ function receivesPointerEvents(element, point) {
260
+ const hit = document.elementFromPoint(point.x, point.y);
261
+ if (!hit) return false;
262
+ if (hit === element || element.contains?.(hit)) return true;
263
+ const surfaces = targetSurfaces(element);
264
+ return surfaces.slice(1).some((host) => host === hit);
265
+ }
266
+
267
+ function targetSurfaces(element) {
268
+ const surfaces = [element];
269
+ let root = element.getRootNode?.();
270
+ while (root instanceof ShadowRoot) {
271
+ surfaces.push(root.host);
272
+ root = root.host.getRootNode?.();
273
+ }
274
+ return surfaces;
275
+ }
276
+
277
+ function scrollElementIntoView(element) {
278
+ element.scrollIntoView({ behavior: "instant", block: "center", inline: "nearest" });
279
+ }
280
+
281
+ function actionTarget(element) {
282
+ const box = boundingBox(element);
283
+ if (!box) return { point: null };
284
+ const viewportWidth = Number(globalThis.innerWidth);
285
+ const viewportHeight = Number(globalThis.innerHeight);
286
+ const left = Math.max(0, box.x);
287
+ const top = Math.max(0, box.y);
288
+ const right = Math.min(Number.isFinite(viewportWidth) ? viewportWidth : box.x + box.width, box.x + box.width);
289
+ const bottom = Math.min(Number.isFinite(viewportHeight) ? viewportHeight : box.y + box.height, box.y + box.height);
290
+ if (right <= left || bottom <= top) return { point: null };
291
+ return { point: { x: left + (right - left) / 2, y: top + (bottom - top) / 2 } };
292
+ }
293
+
294
+ async function applyOne(element, operation, value, key) {
295
+ if (!["click", "double_click", "hover", "scroll_into_view"].includes(operation)) scrollElementIntoView(element);
130
296
  if (operation === "click") { element.click(); return; }
297
+ if (operation === "double_click") {
298
+ element.click();
299
+ element.click();
300
+ element.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, composed: true, detail: 2 }));
301
+ return;
302
+ }
303
+ if (operation === "hover") { dispatchHoverEvents(element); return; }
131
304
  if (operation === "focus") { element.focus(); return; }
305
+ if (operation === "scroll_into_view") return;
132
306
  if (operation === "submit") {
133
307
  const form = element.form || element.closest("form");
134
308
  if (!form) throw new Error("matched element is not associated with a form");
@@ -136,13 +310,11 @@
136
310
  return;
137
311
  }
138
312
  if (operation === "check" || operation === "uncheck") {
139
- if (!("checked" in element)) throw new Error("matched element is not checkable");
140
313
  const wanted = operation === "check";
141
314
  if (Boolean(element.checked) !== wanted) element.click();
142
315
  return;
143
316
  }
144
317
  if (operation === "select") {
145
- if (!(element instanceof HTMLSelectElement)) throw new Error("matched element is not a select control");
146
318
  const text = String(value ?? "");
147
319
  const option = [...element.options].find((item) => item.value === text || item.text.trim() === text);
148
320
  if (!option) throw new Error("select option was not found");
@@ -151,23 +323,57 @@
151
323
  return;
152
324
  }
153
325
  if (operation === "fill") {
154
- if (!("value" in element) && !element.isContentEditable) throw new Error("matched element is not editable");
155
326
  element.focus();
156
327
  if (element.isContentEditable) element.textContent = String(value ?? "");
157
328
  else setNativeValue(element, String(value ?? ""));
158
329
  dispatchValueEvents(element);
159
330
  return;
160
331
  }
332
+ if (operation === "type_text") {
333
+ element.focus();
334
+ const text = String(value ?? "");
335
+ if (element.isContentEditable) element.textContent = `${element.textContent || ""}${text}`;
336
+ else if (typeof element.setRangeText === "function" && Number.isInteger(element.selectionStart) && Number.isInteger(element.selectionEnd)) {
337
+ element.setRangeText(text, element.selectionStart, element.selectionEnd, "end");
338
+ } else setNativeValue(element, `${String(element.value || "")}${text}`);
339
+ dispatchValueEvents(element);
340
+ return;
341
+ }
161
342
  if (operation === "press") {
162
- const pressed = String(key || value || "Enter");
343
+ const event = keyboardEventInit(key || value || "Enter");
163
344
  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 }));
345
+ element.dispatchEvent(new KeyboardEvent("keydown", { ...event, bubbles: true, composed: true }));
346
+ element.dispatchEvent(new KeyboardEvent("keyup", { ...event, bubbles: true, composed: true }));
166
347
  return;
167
348
  }
168
349
  throw new Error(`unsupported element action: ${operation}`);
169
350
  }
170
351
 
352
+ function keyboardEventInit(value) {
353
+ const parts = String(value || "Enter").split("+").map((part) => part.trim()).filter(Boolean);
354
+ const rawKey = parts.pop() || "Enter";
355
+ const modifiers = new Set(parts.map((part) => part === "Ctrl" ? "Control" : part === "Cmd" || part === "Command" ? "Meta" : part));
356
+ return {
357
+ key: rawKey === "Space" ? " " : rawKey,
358
+ altKey: modifiers.has("Alt"),
359
+ ctrlKey: modifiers.has("Control"),
360
+ metaKey: modifiers.has("Meta"),
361
+ shiftKey: modifiers.has("Shift"),
362
+ };
363
+ }
364
+
365
+ function dispatchHoverEvents(element) {
366
+ const options = { bubbles: true, composed: true };
367
+ if (typeof PointerEvent === "function") {
368
+ element.dispatchEvent(new PointerEvent("pointerover", options));
369
+ element.dispatchEvent(new PointerEvent("pointerenter", { ...options, bubbles: false }));
370
+ element.dispatchEvent(new PointerEvent("pointermove", options));
371
+ }
372
+ element.dispatchEvent(new MouseEvent("mouseover", options));
373
+ element.dispatchEvent(new MouseEvent("mouseenter", { ...options, bubbles: false }));
374
+ element.dispatchEvent(new MouseEvent("mousemove", options));
375
+ }
376
+
171
377
  function setNativeValue(element, value) {
172
378
  const prototype = element instanceof HTMLTextAreaElement
173
379
  ? HTMLTextAreaElement.prototype
@@ -184,11 +390,31 @@
184
390
  element.dispatchEvent(new Event("change", { bubbles: true, composed: true }));
185
391
  }
186
392
 
187
- function findElement(selector) {
393
+ function findOne(selector) {
394
+ const elements = findElements(selector);
395
+ if (!elements.length) return null;
396
+ if (elements.length > 1 && !Number.isInteger(selector.index)) throw new Error(`selector matched ${elements.length} elements; use ref or index to disambiguate`);
397
+ return elements[0];
398
+ }
399
+
400
+ function findElements(selector, { allowStaleRef = false } = {}) {
401
+ if (selector.ref) {
402
+ const element = refElements.get(String(selector.ref));
403
+ if (!element) return [];
404
+ if (!element.isConnected) {
405
+ if (allowStaleRef) return [];
406
+ throw new Error("element reference is stale; inspect the page again");
407
+ }
408
+ return [element];
409
+ }
188
410
  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);
411
+ try {
412
+ if (selector.css) elements = deepQuerySelectorAll(selector.css);
413
+ else if (selector.id) elements = deepQuerySelectorAll(`[id="${cssEscape(selector.id)}"]`);
414
+ else elements = deepQuerySelectorAll(INTERACTIVE_SELECTOR);
415
+ } catch (error) {
416
+ throw new Error(`invalid CSS selector: ${String(error?.message || error).slice(0, 300)}`);
417
+ }
192
418
  elements = elements.filter((element) => {
193
419
  if (selector.name && element.getAttribute("name") !== selector.name) return false;
194
420
  if (selector.label && !sameText(labelText(element), selector.label)) return false;
@@ -197,7 +423,53 @@
197
423
  if (selector.placeholder && !sameText(element.getAttribute("placeholder") || "", selector.placeholder)) return false;
198
424
  return true;
199
425
  });
200
- return elements[Number.isInteger(selector.index) ? selector.index : 0] || null;
426
+ if (Number.isInteger(selector.index)) return elements[selector.index] ? [elements[selector.index]] : [];
427
+ return elements;
428
+ }
429
+
430
+ function isVisible(element) {
431
+ if (!element?.isConnected) return false;
432
+ if (typeof element.checkVisibility === "function" && !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
433
+ const style = getComputedStyle(element);
434
+ if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number(style.opacity) === 0) return false;
435
+ const rect = element.getBoundingClientRect();
436
+ return rect.width > 0 && rect.height > 0 && (!element.getClientRects || element.getClientRects().length > 0);
437
+ }
438
+
439
+ function isEnabled(element) {
440
+ if (element.matches?.(":disabled")) return false;
441
+ if (element.disabled) return false;
442
+ return String(element.getAttribute("aria-disabled") || "").toLowerCase() !== "true"
443
+ && !element.closest?.('[aria-disabled="true"]');
444
+ }
445
+
446
+ function isEditable(element) {
447
+ if (!isEnabled(element)) return false;
448
+ if (element.isContentEditable) return true;
449
+ if (!(element instanceof HTMLInputElement) && !(element instanceof HTMLTextAreaElement)) return false;
450
+ if (element.readOnly) return false;
451
+ const type = String(element.type || "text").toLowerCase();
452
+ return !["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"].includes(type);
453
+ }
454
+
455
+ function boundingBox(element) {
456
+ if (!element?.isConnected) return null;
457
+ const rect = element.getBoundingClientRect();
458
+ if (![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite)) return null;
459
+ return {
460
+ x: Math.round(rect.x * 100) / 100,
461
+ y: Math.round(rect.y * 100) / 100,
462
+ width: Math.round(rect.width * 100) / 100,
463
+ height: Math.round(rect.height * 100) / 100,
464
+ };
465
+ }
466
+
467
+ function boxDistance(left, right) {
468
+ if (!left || !right) return Number.POSITIVE_INFINITY;
469
+ return Math.max(
470
+ Math.abs(left.x - right.x), Math.abs(left.y - right.y),
471
+ Math.abs(left.width - right.width), Math.abs(left.height - right.height),
472
+ );
201
473
  }
202
474
 
203
475
  function cssEscape(value) {
@@ -205,8 +477,12 @@
205
477
  return String(value).replace(/["\\]/g, "\\$&");
206
478
  }
207
479
 
480
+ function normalizedText(value) {
481
+ return String(value || "").trim().replace(/\s+/g, " ").toLowerCase();
482
+ }
483
+
208
484
  function sameText(left, right) {
209
- return String(left || "").trim().replace(/\s+/g, " ").toLowerCase() === String(right || "").trim().replace(/\s+/g, " ").toLowerCase();
485
+ return normalizedText(left) === normalizedText(right);
210
486
  }
211
487
 
212
488
  function labelText(element) {
@@ -239,8 +515,12 @@
239
515
  return "";
240
516
  }
241
517
 
518
+ function delay(ms) {
519
+ return new Promise((resolve) => setTimeout(resolve, ms));
520
+ }
521
+
242
522
  Object.defineProperty(globalThis, "__machineBridgePageAutomation", {
243
- value: Object.freeze({ inspect, action, fillForm, uploadFiles }),
523
+ value: Object.freeze({ inspect, prepareAction, action, fillForm, uploadFiles, checkWait }),
244
524
  configurable: true,
245
525
  });
246
526
  })();
@@ -1,3 +1,5 @@
1
+ importScripts("devtools-input.js");
2
+
1
3
  let socket = null;
2
4
  let reconnectTimer = null;
3
5
  let reconnectAttempt = 0;
@@ -121,6 +123,16 @@ function connect(endpoint, token) {
121
123
  return;
122
124
  }
123
125
  reconnectAttempt = 0;
126
+ const manifest = chrome.runtime.getManifest();
127
+ ws.send(JSON.stringify({
128
+ type: "hello",
129
+ role: "extension",
130
+ protocol: 2,
131
+ version: manifest.version_name || manifest.version,
132
+ capabilities: [
133
+ "semantic_snapshot_refs", "actionability_waits", "trusted_input", "tab_management", "explicit_waits",
134
+ ],
135
+ }));
124
136
  setConnectionState("connected");
125
137
  clearInterval(keepaliveTimer);
126
138
  keepaliveTimer = setInterval(() => { if (socket === ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" })); }, 20000);
@@ -156,7 +168,7 @@ async function handleMessage(ws, raw) {
156
168
  activeRequests.set(message.id, state);
157
169
  try {
158
170
  throwIfCancelled(state);
159
- const result = await dispatch(message.method, message.params || {});
171
+ const result = await dispatch(message.method, message.params || {}, state);
160
172
  throwIfCancelled(state);
161
173
  sendResponse(ws, message.id, true, result);
162
174
  } catch (error) {
@@ -179,8 +191,10 @@ function sendResponse(ws, id, ok, result, error = "") {
179
191
  try { ws.send(payload); } catch {}
180
192
  }
181
193
 
182
- async function dispatch(method, params) {
194
+ async function dispatch(method, params, state) {
183
195
  if (method === "list_tabs") return listTabs(params);
196
+ if (method === "manage_tabs") return manageTabs(params);
197
+ if (method === "wait") return browserWait(params, state);
184
198
  if (method === "get_source") return getSource(params);
185
199
  if (method === "inspect_page") return inspectPage(params);
186
200
  if (method === "action") return browserAction(params);
@@ -210,6 +224,55 @@ async function listTabs(params) {
210
224
  };
211
225
  }
212
226
 
227
+
228
+ async function manageTabs(params) {
229
+ if (params.action === "new") {
230
+ const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: params.active !== false });
231
+ return { action: "new", ...publicTab(tab) };
232
+ }
233
+ const tab = await chrome.tabs.get(params.tabId);
234
+ if (params.action === "activate") {
235
+ await chrome.tabs.update(tab.id, { active: true });
236
+ await chrome.windows.update(tab.windowId, { focused: true });
237
+ return { action: "activate", ...publicTab(await chrome.tabs.get(tab.id)) };
238
+ }
239
+ if (params.action === "close") {
240
+ const result = { action: "close", closed: true, ...publicTab(tab) };
241
+ await chrome.tabs.remove(tab.id);
242
+ return result;
243
+ }
244
+ throw new Error("unsupported browser tab action");
245
+ }
246
+
247
+ async function browserWait(params, state) {
248
+ const tab = await resolveTab(params.tabId);
249
+ const deadline = Date.now() + Math.max(1, Number(params.timeoutMs) || 30000);
250
+ let last = null;
251
+ while (Date.now() <= deadline) {
252
+ throwIfCancelled(state);
253
+ const current = await chrome.tabs.get(tab.id);
254
+ const urlMatched = !params.urlContains || String(current.url || "").includes(params.urlContains);
255
+ const needsPage = Boolean(params.selector || params.text || params.loadState);
256
+ let page = { matched: true, ready_state: current.status || "" };
257
+ if (needsPage) {
258
+ try {
259
+ assertPageTab(current);
260
+ const [execution] = await executePageAutomation(scriptTarget(current.id, params.frameId, false), "checkWait", params);
261
+ page = execution?.result || { matched: false };
262
+ } catch (error) {
263
+ const message = String(error?.message || error).replace(/\s+/g, " ").slice(0, 500);
264
+ if (message.includes("invalid CSS selector") || message.includes("selector matched")) throw new Error(message);
265
+ page = { matched: false, error: message };
266
+ }
267
+ }
268
+ last = { url_matched: urlMatched, ...page };
269
+ if (urlMatched && page.matched === true) {
270
+ return { ok: true, tab_id: current.id, title: current.title || "", url: current.url || "", condition: last };
271
+ }
272
+ await delay(200);
273
+ }
274
+ throw new Error(`browser wait timed out; last condition: ${JSON.stringify(last || {})}`);
275
+ }
213
276
  function scriptTarget(tabId, frameId, allFrames) {
214
277
  if (Number.isInteger(frameId)) return { tabId, frameIds: [frameId] };
215
278
  if (allFrames) return { tabId, allFrames: true };
@@ -293,17 +356,53 @@ async function browserAction(params) {
293
356
  }
294
357
  assertPageTab(tab);
295
358
  const waiter = beginTabWait(tab.id, params.waitFor);
296
- let execution;
359
+ let result;
297
360
  try {
298
- [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
361
+ result = await performPageAction(tab, params);
299
362
  await waiter.promise;
300
363
  } catch (error) {
301
364
  waiter.cancel();
302
365
  throw error;
303
366
  }
304
- return { tab_id: tab.id, title: tab.title || "", url: tab.url || "", ...execution.result };
367
+ const current = await chrome.tabs.get(tab.id).catch(() => tab);
368
+ return { tab_id: current.id, title: current.title || "", url: current.url || "", ...result };
305
369
  }
306
370
 
371
+ async function performPageAction(tab, params) {
372
+ const trustedActions = new Set(["click", "double_click", "hover", "press", "type_text"]);
373
+ if (params.inputMode === "trusted" && !trustedActions.has(params.action)) {
374
+ throw new Error("trusted input is unavailable for this action");
375
+ }
376
+ const wantsTrusted = params.inputMode !== "dom" && trustedActions.has(params.action);
377
+ const topFrame = !Number.isInteger(params.frameId) || params.frameId === 0;
378
+ if (wantsTrusted && !topFrame && params.inputMode === "trusted") {
379
+ throw new Error("trusted input currently requires the top frame; use input_mode=dom for a subframe");
380
+ }
381
+ if (wantsTrusted && topFrame) {
382
+ const [prepared] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "prepareAction", params);
383
+ try {
384
+ const api = globalThis.__machineBridgeDevtoolsInput;
385
+ if (!api?.perform) throw new Error("trusted input module is unavailable");
386
+ await api.perform(tab.id, params.action, {
387
+ point: prepared.result?.point,
388
+ key: params.key || params.value || "Enter",
389
+ text: params.value || "",
390
+ });
391
+ return { ...prepared.result, input_mode: "trusted", trusted_input_fallback: false };
392
+ } catch (error) {
393
+ if (params.inputMode === "trusted") throw error;
394
+ const [fallback] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
395
+ return {
396
+ ...fallback.result,
397
+ input_mode: "dom",
398
+ trusted_input_fallback: true,
399
+ fallback_reason: String(error?.message || error).slice(0, 500),
400
+ };
401
+ }
402
+ }
403
+ const [execution] = await executePageAutomation(scriptTarget(tab.id, params.frameId, false), "action", params);
404
+ return { ...execution.result, input_mode: "dom", trusted_input_fallback: false };
405
+ }
307
406
  async function fillForm(params) {
308
407
  const tab = await resolveTab(params.tabId);
309
408
  assertPageTab(tab);
@@ -404,3 +503,7 @@ function executePageAutomation(target, method, params) {
404
503
  args: [method, params],
405
504
  }));
406
505
  }
506
+
507
+ function delay(ms) {
508
+ return new Promise((resolve) => setTimeout(resolve, ms));
509
+ }