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.
- package/CHANGELOG.md +38 -0
- package/README.md +44 -13
- package/SECURITY.md +7 -3
- package/browser-extension/devtools-input.js +136 -0
- package/browser-extension/manifest.json +3 -2
- package/browser-extension/page-automation.js +309 -29
- package/browser-extension/service-worker.js +108 -5
- package/docs/AGENT_CONTEXT.md +16 -6
- package/docs/ARCHITECTURE.md +7 -5
- package/docs/AUDIT.md +13 -1
- package/docs/CLIENTS.md +1 -1
- package/docs/ENGINEERING.md +1 -0
- package/docs/LOCAL_AUTOMATION.md +14 -10
- package/docs/LOGGING.md +3 -1
- package/docs/OPERATIONS.md +17 -5
- package/docs/TESTING.md +8 -7
- package/package.json +10 -4
- package/src/local/agent-context.mjs +76 -19
- package/src/local/app-automation.mjs +15 -1
- package/src/local/browser-bridge.mjs +186 -88
- package/src/local/browser-command.mjs +126 -0
- package/src/local/capability-observer.mjs +56 -0
- package/src/local/cli.mjs +21 -3
- package/src/local/default-instructions.mjs +21 -91
- package/src/local/network-proxy.mjs +34 -0
- package/src/local/process-sessions.mjs +7 -0
- package/src/local/project-package.mjs +234 -0
- package/src/local/relay-connection.mjs +26 -0
- package/src/local/runtime.mjs +23 -20
- package/src/shared/tool-catalog.json +212 -6
- package/src/worker/index.ts +14 -8
|
@@ -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
|
|
50
|
-
const element =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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 =
|
|
61
|
-
|
|
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 ?
|
|
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 ?
|
|
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 =
|
|
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
|
|
129
|
-
|
|
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
|
|
343
|
+
const event = keyboardEventInit(key || value || "Enter");
|
|
163
344
|
element.focus();
|
|
164
|
-
element.dispatchEvent(new KeyboardEvent("keydown", {
|
|
165
|
-
element.dispatchEvent(new KeyboardEvent("keyup", {
|
|
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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
359
|
+
let result;
|
|
297
360
|
try {
|
|
298
|
-
|
|
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
|
-
|
|
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
|
+
}
|