@capagents/forgeqe 2.3.15 → 2.4.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/HOW_TO_USE.md +9 -2
- package/README.md +2 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/forgeqe.js +1099 -1038
- package/dist/web/THIRD_PARTY_NOTICES.md +2542 -0
- package/dist/web/assets/ApiTests-CSSed9hU.js +2 -0
- package/dist/web/assets/Chat-CFfiv7wI.js +2 -0
- package/dist/web/assets/Data-Qolfhsd7.js +4 -0
- package/dist/web/assets/Features-BM48eq6x.js +1 -0
- package/dist/web/assets/Jobs-BjVNQUaK.js +3 -0
- package/dist/web/assets/Quality-F0sKErYg.js +1 -0
- package/dist/web/assets/Record-CkwV9gN8.js +1 -0
- package/dist/web/assets/Regression-DKaNfFu1.js +2 -0
- package/dist/web/assets/Reports-o_82URX6.js +3 -0
- package/dist/web/assets/Response-YL9AIB8R.js +8 -0
- package/dist/web/assets/Runs-Dp-DzyW1.js +2 -0
- package/dist/web/assets/circle-dashed-DbpKfamm.js +1 -0
- package/dist/web/assets/context-engine-BIWf1ZIT.svg +1 -0
- package/dist/web/assets/dm-sans-latin-400-normal-BwCSEQnW.woff +0 -0
- package/dist/web/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-500-normal-Dr3UlScf.woff +0 -0
- package/dist/web/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff +0 -0
- package/dist/web/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff +0 -0
- package/dist/web/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff +0 -0
- package/dist/web/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2 +0 -0
- package/dist/web/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff +0 -0
- package/dist/web/assets/external-link-pvVHJFAZ.js +1 -0
- package/dist/web/assets/file-chart-column-increasing-b-Yh6rmz.js +1 -0
- package/dist/web/assets/index-Bkx3fVbr.js +21 -0
- package/dist/web/assets/index-DSEYuBEd.css +2 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
- package/dist/web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
- package/dist/web/assets/knowledge-base-CMsi5KtZ.svg +1 -0
- package/dist/web/assets/markdown-GKy06efH.js +10 -0
- package/dist/web/assets/model-CGrmjuJz.svg +1 -0
- package/dist/web/assets/report-mkgMJOFK.svg +1 -0
- package/dist/web/assets/rotate-ccw-BL6YOAPy.js +1 -0
- package/dist/web/assets/rule-DycAnn4u.svg +1 -0
- package/dist/web/assets/search-YLg3uzAx.js +1 -0
- package/dist/web/assets/status-CL_clA0J.js +3 -0
- package/dist/web/assets/sticky-note-DIQ2FWMw.js +1 -0
- package/dist/web/assets/terminal-BdTFlGZR.js +1 -0
- package/dist/web/assets/timer-NLZ0XmVl.js +1 -0
- package/dist/web/assets/trace-YEplLreO.svg +1 -0
- package/dist/web/assets/trash-DIUJbRpw.js +1 -0
- package/dist/web/index.html +15 -0
- package/docs/ARCHITECTURE.md +4 -1
- package/docs/user-guide.html +10 -2
- package/forgeqe.example.yaml +37 -0
- package/package.json +3 -3
- package/prompts/test-agent.md +2 -0
- package/py/forgeqe_engine/capture.py +33 -7
- package/py/forgeqe_engine/logins.py +5 -0
- package/py/forgeqe_engine/memory.py +80 -4
- package/py/forgeqe_engine/service.py +41 -5
- package/py/forgeqe_engine/userlike.py +150 -0
- package/py/forgeqe_mobile/hyperexecute.py +1 -1
|
@@ -18,7 +18,8 @@ from typing import Any, Dict, List, Optional
|
|
|
18
18
|
|
|
19
19
|
from browser_use import ActionResult, BrowserSession
|
|
20
20
|
|
|
21
|
-
from .
|
|
21
|
+
from . import userlike
|
|
22
|
+
from .memory import BASE, KEPT_ATTRS, candidate_locators, confidence, looks_generated, playwright_role_ok
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger("forgeqe_engine.capture")
|
|
24
25
|
|
|
@@ -175,8 +176,9 @@ def install_effects(tools: Any, effects: List[Dict[str, Any]], captured: Dict[st
|
|
|
175
176
|
before = await page_state(session) if name in EFFECT_ACTIONS and session is not None else None
|
|
176
177
|
captured.pop("target", None)
|
|
177
178
|
captured.pop("api", None)
|
|
179
|
+
userlike.reset()
|
|
178
180
|
result = await original(action, browser_session, *args, **kwargs)
|
|
179
|
-
entry: Dict[str, Any] = {"action": name, "effect": None}
|
|
181
|
+
entry: Dict[str, Any] = {"action": name, "effect": None, **userlike.take()}
|
|
180
182
|
if name in EFFECT_ACTIONS and session is not None and not getattr(result, "error", None):
|
|
181
183
|
entry["effect"] = await effect_after(session, before)
|
|
182
184
|
if captured.get("target") is not None:
|
|
@@ -236,7 +238,7 @@ _VERIFY_FN = r"""function(locs) {
|
|
|
236
238
|
default: return l.css ? [...document.querySelectorAll(l.css)] : null;
|
|
237
239
|
}
|
|
238
240
|
};
|
|
239
|
-
|
|
241
|
+
const results = locs.map((l) => {
|
|
240
242
|
try {
|
|
241
243
|
const all = find(l);
|
|
242
244
|
if (!all) return null;
|
|
@@ -244,6 +246,8 @@ _VERIFY_FN = r"""function(locs) {
|
|
|
244
246
|
return { count: hits.length, target: hits.length === 1 && (hits[0] === el || el.contains(hits[0])) };
|
|
245
247
|
} catch (e) { return { count: -1, target: false }; }
|
|
246
248
|
});
|
|
249
|
+
// What Playwright's getByRole reads for the element's role: its tag, href and role attribute.
|
|
250
|
+
return { results, el: { tag: el.tagName.toLowerCase(), href: el.getAttribute('href') || '', role: el.getAttribute('role') || '' } };
|
|
247
251
|
}"""
|
|
248
252
|
|
|
249
253
|
|
|
@@ -272,14 +276,20 @@ async def verify_locators(session: Any, backend_node_id: Optional[int], locators
|
|
|
272
276
|
object_id = await _object_for(cdp, backend_node_id)
|
|
273
277
|
if not object_id:
|
|
274
278
|
return locators
|
|
275
|
-
plain = [l for l in locators if l.get("type")
|
|
279
|
+
plain = [l for l in locators if l.get("type") not in ("role", "role_pattern")]
|
|
276
280
|
res = await _send(
|
|
277
281
|
cdp, "Runtime", "callFunctionOn",
|
|
278
282
|
{"objectId": object_id, "functionDeclaration": _VERIFY_FN, "arguments": [{"value": plain}], "returnByValue": True},
|
|
279
283
|
)
|
|
280
|
-
|
|
284
|
+
value = (res.get("result") or {}).get("value") or {}
|
|
285
|
+
el = value.get("el") or {}
|
|
286
|
+
results = dict(zip((id(l) for l in plain), value.get("results") or []))
|
|
281
287
|
for loc in locators:
|
|
282
|
-
if loc.get("type")
|
|
288
|
+
if loc.get("type") in ("role", "role_pattern"):
|
|
289
|
+
if el and not playwright_role_ok(str(el.get("tag") or ""), {"href": el.get("href") or "", "role": el.get("role") or ""}, str(loc.get("role") or "")):
|
|
290
|
+
# Chrome calls it that; Playwright would not find it by this role.
|
|
291
|
+
loc["matches"], loc["verified"] = 0, False
|
|
292
|
+
continue
|
|
283
293
|
try:
|
|
284
294
|
hits = await _ax_matches(cdp, str(loc.get("role") or ""), str(loc.get("name") or ""))
|
|
285
295
|
except Exception:
|
|
@@ -317,7 +327,23 @@ _FACTS_FN = r"""function(kept) {
|
|
|
317
327
|
for (const k of kept) { const v = el.getAttribute(k); if (v) attrs[k] = v; }
|
|
318
328
|
const xp = (e) => { const parts = []; for (; e && e.nodeType === 1; e = e.parentElement) { let i = 1; for (let s = e.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === e.tagName) i++; parts.unshift(e.tagName.toLowerCase() + '[' + i + ']'); } return parts.join('/'); };
|
|
319
329
|
const classes = [...el.classList].filter((c) => /^[A-Za-z_-][\w-]*$/.test(c)).slice(0, 4);
|
|
320
|
-
|
|
330
|
+
let scope = '';
|
|
331
|
+
if (!el.id) for (let e = el.parentElement, d = 0; e && d < 6 && e !== document.body; e = e.parentElement, d++) {
|
|
332
|
+
if (e.id && !/\d{4,}|^(ember|mui-|radix-|react-select-|headlessui-|yui_|j_id|__)/i.test(e.id)) { scope = /^[A-Za-z_-][\w-]*$/.test(e.id) ? '#' + e.id : '[id="' + e.id.replace(/"/g, '\\"') + '"]'; break; }
|
|
333
|
+
}
|
|
334
|
+
// In a frame: what names it from the page that holds it (same origin), or at least its address.
|
|
335
|
+
let frame = null;
|
|
336
|
+
if (window !== window.top) {
|
|
337
|
+
let fe = null;
|
|
338
|
+
try { fe = window.frameElement; } catch (e) {}
|
|
339
|
+
frame = {};
|
|
340
|
+
if (fe) for (const k of ['src', 'id', 'name', 'title']) { const v = fe.getAttribute(k); if (v) frame[k] = v.slice(0, 300); }
|
|
341
|
+
if (!frame.src) frame.src = String(location.href).slice(0, 300);
|
|
342
|
+
}
|
|
343
|
+
const out = { tag: el.tagName.toLowerCase(), attributes: attrs, classes, xpath: xp(el), text: String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120) };
|
|
344
|
+
if (scope) out.scope = scope;
|
|
345
|
+
if (frame) out.frame = frame;
|
|
346
|
+
return out;
|
|
321
347
|
}"""
|
|
322
348
|
|
|
323
349
|
|
|
@@ -137,6 +137,11 @@ def _host_matches(host: str, login_hosts: List[str]) -> bool:
|
|
|
137
137
|
return any(host == h or h.endswith("." + host) or host.endswith("." + h) for h in login_hosts)
|
|
138
138
|
|
|
139
139
|
|
|
140
|
+
def can_sign_in(login: Login, missing: List[str]) -> bool:
|
|
141
|
+
"""Whether the agent can sign in to this login's site itself: it has a username and a password to type."""
|
|
142
|
+
return all(env and env not in missing for env in (login.username_env, login.password_env))
|
|
143
|
+
|
|
144
|
+
|
|
140
145
|
def load_sessions(logins: List[Login]) -> Optional[Dict[str, Any]]:
|
|
141
146
|
"""Cookies and local storage saved by earlier runs, merged into one storage state; None when there are none."""
|
|
142
147
|
cookies: List[Dict[str, Any]] = []
|
|
@@ -61,6 +61,7 @@ BASE = {
|
|
|
61
61
|
"title": 0.72,
|
|
62
62
|
"text": 0.70,
|
|
63
63
|
"css": 0.50,
|
|
64
|
+
"role_pattern": 0.80,
|
|
64
65
|
"xpath": 0.30,
|
|
65
66
|
}
|
|
66
67
|
# Kinds precise enough to recognise the same element on a later visit; xpath is the last resort.
|
|
@@ -101,6 +102,37 @@ def _clean(text: Any, limit: int = 120) -> str:
|
|
|
101
102
|
return re.sub(r"\s+", " ", str(text or "")).strip()[:limit]
|
|
102
103
|
|
|
103
104
|
|
|
105
|
+
# Icon fonts (Font Awesome, Material Icons, Glyphicons) draw their icons with private-use characters from CSS. Chrome's
|
|
106
|
+
# accessible name and the agent's labels include them; Playwright's getByText never sees CSS text, and a person
|
|
107
|
+
# reading the handoff sees an empty name. They are kept out of names and text, and matched as "any non-word character".
|
|
108
|
+
_ICONS = re.compile("[\ue000-\uf8ff\U000f0000-\U000ffffd\U00100000-\U0010fffd]")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def strip_icons(text: Any) -> str:
|
|
112
|
+
return re.sub(r"\s+", " ", _ICONS.sub(" ", str(text or ""))).strip()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def has_icons(text: Any) -> bool:
|
|
116
|
+
return bool(_ICONS.search(str(text or "")))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _js_regex(text: str) -> str:
|
|
120
|
+
return re.sub(r"[.*+?^${}()|[\]\\/]", lambda m: "\\" + m.group(0), text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def playwright_role_ok(tag: str, attrs: Dict[str, str], role: str) -> bool:
|
|
124
|
+
"""
|
|
125
|
+
Whether Playwright's getByRole gives the element this role. Chrome's accessibility tree, which a role is read from,
|
|
126
|
+
makes a link of an <a> with no href (a click handler is enough); Playwright follows ARIA, where it is not one.
|
|
127
|
+
"""
|
|
128
|
+
explicit = str(attrs.get("role") or "").split()
|
|
129
|
+
if explicit:
|
|
130
|
+
return explicit[0] == role
|
|
131
|
+
if tag in ("a", "area") and role == "link":
|
|
132
|
+
return bool(attrs.get("href"))
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
104
136
|
def _sha(text: str, n: int) -> str:
|
|
105
137
|
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:n]
|
|
106
138
|
|
|
@@ -187,6 +219,25 @@ def element_facts(node: Any) -> Dict[str, Any]:
|
|
|
187
219
|
stable_hash = node.compute_stable_hash()
|
|
188
220
|
except Exception:
|
|
189
221
|
stable_hash = None
|
|
222
|
+
scope = ""
|
|
223
|
+
frame: Dict[str, str] = {}
|
|
224
|
+
try:
|
|
225
|
+
up, depth = getattr(node, "parent_node", None), 0
|
|
226
|
+
while up is not None and depth < 40:
|
|
227
|
+
up_tag = str(getattr(up, "node_name", "") or "").lower()
|
|
228
|
+
up_attrs = getattr(up, "attributes", None) or {}
|
|
229
|
+
# The nearest ancestor with an id names one item of a list (a cart row, a result card): a locator scoped to
|
|
230
|
+
# it is unique where the element's own classes repeat on every item.
|
|
231
|
+
if not scope and depth < 6 and not attrs.get("id"):
|
|
232
|
+
up_id = str(up_attrs.get("id") or "")
|
|
233
|
+
if up_id and not looks_generated(up_id):
|
|
234
|
+
scope = f"#{up_id}" if _css_ident(up_id) else f"[id={_q(up_id)}]"
|
|
235
|
+
if up_tag in ("iframe", "frame"):
|
|
236
|
+
frame = {k: str(up_attrs[k])[:300] for k in ("src", "id", "name", "title") if up_attrs.get(k)}
|
|
237
|
+
break
|
|
238
|
+
up, depth = getattr(up, "parent_node", None), depth + 1
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
190
241
|
classes: List[str] = []
|
|
191
242
|
cls = str(raw_attrs.get("class") or "")
|
|
192
243
|
if cls:
|
|
@@ -206,6 +257,8 @@ def element_facts(node: Any) -> Dict[str, Any]:
|
|
|
206
257
|
"classes": classes[:4],
|
|
207
258
|
"xpath": xpath,
|
|
208
259
|
"stable_hash": stable_hash,
|
|
260
|
+
**({"scope": scope} if scope else {}),
|
|
261
|
+
**({"frame": frame} if frame else {}),
|
|
209
262
|
}
|
|
210
263
|
|
|
211
264
|
|
|
@@ -238,8 +291,14 @@ def candidate_locators(f: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
238
291
|
v = a["id"]
|
|
239
292
|
css = f"#{v}" if _css_ident(v) else f"[id={_q(v)}]"
|
|
240
293
|
add("id", v, f"page.locator({_q(css)})", css=css)
|
|
241
|
-
|
|
242
|
-
|
|
294
|
+
plain = strip_icons(name)
|
|
295
|
+
if role in ROLES and plain and len(name) <= 80 and playwright_role_ok(tag, a, role):
|
|
296
|
+
if plain == name:
|
|
297
|
+
add("role", f"{role}|{name}", f"page.getByRole({_q(role)}, {{ name: {_q(name)}, exact: true }})", role=role, name=name)
|
|
298
|
+
else:
|
|
299
|
+
# The name has an icon character: match its words, with any icon or space around them.
|
|
300
|
+
pattern = f"^\\W*{_js_regex(plain)}\\W*$"
|
|
301
|
+
add("role_pattern", f"{role}|{pattern}", f"page.getByRole({_q(role)}, {{ name: /{pattern}/ }})", role=role, name=name)
|
|
243
302
|
if a.get("name"):
|
|
244
303
|
css = f"{tag}[name={_q(a['name'])}]"
|
|
245
304
|
add("name", a["name"], f"page.locator({_q(css)})", css=css)
|
|
@@ -259,13 +318,18 @@ def candidate_locators(f: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
|
259
318
|
if tag == "a" and href and not href.startswith(("javascript:", "#")) and len(href) <= 200:
|
|
260
319
|
css = f"a[href={_q(href)}]"
|
|
261
320
|
add("href", href, f"page.locator({_q(css)})", css=css)
|
|
262
|
-
text = f.get("text") or (name if tag not in FORM_TAGS else "")
|
|
321
|
+
text = strip_icons(f.get("text") or (name if tag not in FORM_TAGS else ""))
|
|
263
322
|
if text and len(text) <= 60 and tag not in FORM_TAGS:
|
|
264
323
|
add("text", text, f"page.getByText({_q(text)}, {{ exact: true }})")
|
|
265
324
|
classes = f.get("classes") or []
|
|
266
325
|
if classes:
|
|
267
326
|
css = tag + "".join(f".{c}" for c in classes[:2])
|
|
268
327
|
add("css", css, f"page.locator({_q(css)})", css=css)
|
|
328
|
+
if f.get("scope"):
|
|
329
|
+
# Within the item it belongs to: one of a control that repeats on every row or card.
|
|
330
|
+
own = tag + "".join(f".{c}" for c in classes[:2]) if classes else (f"{tag}[href={_q(href)}]" if tag == "a" and href else tag)
|
|
331
|
+
css = f"{f['scope']} {own}"
|
|
332
|
+
add("css", css, f"page.locator({_q(css)})", css=css, scoped=True)
|
|
269
333
|
if f.get("xpath"):
|
|
270
334
|
xp = "/" + str(f["xpath"]).lstrip("/")
|
|
271
335
|
add("xpath", xp, f"page.locator({_q('xpath=' + xp)})")
|
|
@@ -300,7 +364,15 @@ def _public_locator(loc: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
300
364
|
|
|
301
365
|
def element_label(facts: Dict[str, Any]) -> str:
|
|
302
366
|
a = facts.get("attributes") or {}
|
|
303
|
-
|
|
367
|
+
label = strip_icons(facts.get("name")) or strip_icons(facts.get("text")) or a.get("placeholder") or a.get("aria-label") or a.get("title") or ""
|
|
368
|
+
if label:
|
|
369
|
+
return label
|
|
370
|
+
tag = facts.get("tag") or ""
|
|
371
|
+
if has_icons(facts.get("name")) or has_icons(facts.get("text")):
|
|
372
|
+
# A control that shows only an icon: say so, with what names it in the page's code.
|
|
373
|
+
classes = facts.get("classes") or []
|
|
374
|
+
return f"icon-only {tag}{'.' + classes[0] if classes else ''}"
|
|
375
|
+
return tag
|
|
304
376
|
|
|
305
377
|
|
|
306
378
|
# ---------------------------------------------------------------------------------------------
|
|
@@ -1084,6 +1156,10 @@ def compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1084
1156
|
out["scripted"] = True
|
|
1085
1157
|
if step.get("overlay"):
|
|
1086
1158
|
out["overlay"] = step["overlay"]
|
|
1159
|
+
# What the run met that a test must handle, and what it did in a way a test cannot repeat (see userlike).
|
|
1160
|
+
for k in ("covered_by", "bypass", "frame"):
|
|
1161
|
+
if step.get(k):
|
|
1162
|
+
out[k] = step[k]
|
|
1087
1163
|
if step.get("note"):
|
|
1088
1164
|
out["note"] = step["note"]
|
|
1089
1165
|
return out
|
|
@@ -17,11 +17,12 @@ import time
|
|
|
17
17
|
from dataclasses import dataclass, field
|
|
18
18
|
from datetime import datetime, timezone
|
|
19
19
|
from pathlib import Path
|
|
20
|
+
from urllib.parse import urlparse
|
|
20
21
|
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
|
21
22
|
|
|
22
|
-
from . import brand
|
|
23
|
+
from . import brand, userlike
|
|
23
24
|
from .diagnostics import Diagnostics, box_for
|
|
24
|
-
from .logins import load_sessions, parse_logins, prompt_block, save_sessions, use_logins
|
|
25
|
+
from .logins import _host_matches, can_sign_in, load_sessions, parse_logins, prompt_block, save_sessions, use_logins
|
|
25
26
|
from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, page_id
|
|
26
27
|
from .prompts import CONVENTIONS, text as prompt_text, use_prompts
|
|
27
28
|
from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
|
|
@@ -114,6 +115,11 @@ class RunRequest:
|
|
|
114
115
|
memory: Dict[str, Any] = field(default_factory=dict)
|
|
115
116
|
# [{name, domains, username_env, password_env, totp_env, session_file}]
|
|
116
117
|
logins: List[Dict[str, Any]] = field(default_factory=list)
|
|
118
|
+
# Start the browser with the logins' saved sessions; false for a test case, which a test runs from a fresh browser.
|
|
119
|
+
restore_sessions: bool = True
|
|
120
|
+
# Set during the run: the logins whose saved session the browser started with, and each one's sites.
|
|
121
|
+
sessions_restored: List[str] = field(default_factory=list)
|
|
122
|
+
sessions_restored_hosts: Dict[str, List[str]] = field(default_factory=dict)
|
|
117
123
|
# Number of steps in the test case this goal came from; the agent then reports a verdict for each.
|
|
118
124
|
case_steps: int = 0
|
|
119
125
|
# The test case and a key per step (changes when the step is reworded): an edited case replays its unchanged steps.
|
|
@@ -150,6 +156,7 @@ class RunRequest:
|
|
|
150
156
|
def from_dict(cls, d: Dict[str, Any]) -> "RunRequest":
|
|
151
157
|
return cls(
|
|
152
158
|
logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
|
|
159
|
+
restore_sessions=d.get("restore_sessions") is not False,
|
|
153
160
|
case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
|
|
154
161
|
case_id=str(d.get("case_id") or ""),
|
|
155
162
|
case_step_keys=[str(k) for k in (d.get("case_step_keys") or []) if str(k)],
|
|
@@ -467,6 +474,8 @@ class MemoryRun:
|
|
|
467
474
|
used_checkpoint: str = ""
|
|
468
475
|
# Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
|
|
469
476
|
no_effect: List[str] = field(default_factory=list)
|
|
477
|
+
# A click refused because something covered its element, by element key: kept for the click that then worked.
|
|
478
|
+
covered: Dict[str, Dict[str, str]] = field(default_factory=dict)
|
|
470
479
|
# The test case this run is for, and a key per case step.
|
|
471
480
|
case_id: str = ""
|
|
472
481
|
case_keys: List[str] = field(default_factory=list)
|
|
@@ -808,6 +817,8 @@ def _record_memory_results(
|
|
|
808
817
|
if target:
|
|
809
818
|
mem.store.record_outcome(target["page"], target["key"], ok)
|
|
810
819
|
if not ok:
|
|
820
|
+
if target and eff.get("covered_by"):
|
|
821
|
+
mem.covered[target["key"]] = eff["covered_by"]
|
|
811
822
|
continue
|
|
812
823
|
if eff.get("effect") is False and name in ("click", "send_keys", "evaluate", "click_selector"):
|
|
813
824
|
mem.no_effect.append(line)
|
|
@@ -833,6 +844,12 @@ def _record_memory_results(
|
|
|
833
844
|
step["effect"] = bool(eff["effect"])
|
|
834
845
|
if name == "click" and target and target.get("overlay"):
|
|
835
846
|
step["overlay"] = target["overlay"]
|
|
847
|
+
if target and target["key"] in mem.covered:
|
|
848
|
+
step["covered_by"] = mem.covered.pop(target["key"])
|
|
849
|
+
if eff.get("bypass"):
|
|
850
|
+
step["bypass"] = list(eff["bypass"])
|
|
851
|
+
if target and (target.get("facts") or {}).get("frame"):
|
|
852
|
+
step["frame"] = target["facts"]["frame"]
|
|
836
853
|
if name == "evaluate" and eff.get("effect") is True:
|
|
837
854
|
step["scripted"] = True
|
|
838
855
|
if name == "api_request" and eff.get("api"):
|
|
@@ -956,11 +973,20 @@ def _write_artifacts(
|
|
|
956
973
|
slim["start_url"] = start_url or ""
|
|
957
974
|
slim["driver"] = "engine"
|
|
958
975
|
slim["run_id"] = out_dir.name
|
|
976
|
+
used = sessions_used(req.sessions_restored_hosts, collected_steps)
|
|
977
|
+
if used:
|
|
978
|
+
slim["sessions_restored"] = used
|
|
959
979
|
run_path.write_text(json.dumps(slim, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
|
|
960
980
|
files.insert(0, str(run_path.resolve()))
|
|
961
981
|
return files
|
|
962
982
|
|
|
963
983
|
|
|
984
|
+
def sessions_used(restored: Dict[str, List[str]], steps: List[Dict[str, Any]]) -> List[str]:
|
|
985
|
+
"""The restored sessions of sites the run went to: what it started with that a test's browser does not have."""
|
|
986
|
+
visited = {(urlparse(str(s.get("url") or "")).hostname or "") for s in steps} - {""}
|
|
987
|
+
return [name for name, hosts in restored.items() if any(_host_matches(v, hosts) for v in visited)]
|
|
988
|
+
|
|
989
|
+
|
|
964
990
|
async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Event) -> Dict[str, Any]:
|
|
965
991
|
"""
|
|
966
992
|
Run a single library Agent task and write artifacts into ``req.out_dir``.
|
|
@@ -994,6 +1020,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
994
1020
|
|
|
995
1021
|
recorded_checks: List[Dict[str, Any]] = []
|
|
996
1022
|
brand.install()
|
|
1023
|
+
userlike.install()
|
|
997
1024
|
headless = resolve_browser_headless(req.headed)
|
|
998
1025
|
timeout_sec = float(os.getenv("BROWSER_USE_TIMEOUT_SECONDS", "600"))
|
|
999
1026
|
step_timeout = int(os.getenv("BROWSER_USE_STEP_TIMEOUT", "180"))
|
|
@@ -1020,11 +1047,20 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1020
1047
|
sensitive, missing = use_logins(logins)
|
|
1021
1048
|
for env in missing:
|
|
1022
1049
|
emit({"type": "log", "level": "warn", "text": f"Saved login: {env} is not set, so that value is not available to the agent"})
|
|
1023
|
-
|
|
1050
|
+
# A test case runs from the state its test starts from: signed out. A login the agent cannot sign in to itself (no
|
|
1051
|
+
# username or password here) keeps its saved session, the only way in; the handoff then says the run started with it.
|
|
1052
|
+
restoring = logins if req.restore_sessions else [l for l in logins if not can_sign_in(l, missing)]
|
|
1053
|
+
stored = load_sessions(restoring)
|
|
1024
1054
|
if stored:
|
|
1025
1055
|
browser_kw["storage_state"] = stored
|
|
1026
|
-
names = [l.name for l in
|
|
1027
|
-
|
|
1056
|
+
names = [l.name for l in restoring if l.session_file and Path(l.session_file).is_file()]
|
|
1057
|
+
req.sessions_restored = names
|
|
1058
|
+
req.sessions_restored_hosts = {l.name: l.hosts() for l in restoring if l.name in names}
|
|
1059
|
+
why = "" if req.restore_sessions else " (no username and password to sign in with, so the run starts signed in; a test must sign in itself)"
|
|
1060
|
+
emit({"type": "log", "level": "info", "text": f"Signed-in session restored for {', '.join(names)}{why}"})
|
|
1061
|
+
skipped = [l.name for l in logins if l not in restoring and l.session_file and Path(l.session_file).is_file()]
|
|
1062
|
+
if skipped:
|
|
1063
|
+
emit({"type": "log", "level": "info", "text": f"Saved sessions not restored for {', '.join(skipped)}: a test case runs from the state its test starts from, and the agent signs in"})
|
|
1028
1064
|
emit(
|
|
1029
1065
|
{
|
|
1030
1066
|
"type": "log",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The browser agent acts as a user can, or the run says where it did not.
|
|
3
|
+
|
|
4
|
+
The library clicks an element that something else covers from JavaScript (element.click()), types to whatever has focus
|
|
5
|
+
when it cannot reach a field, and clicks from JavaScript when the mouse click fails. A user cannot do any of these, and
|
|
6
|
+
neither can a test: Playwright waits for the element to be the one under the mouse. So:
|
|
7
|
+
|
|
8
|
+
- A click on a covered element fails, naming what covers it. The agent then closes or moves past it as a user would,
|
|
9
|
+
and that step is in the flow a test repeats.
|
|
10
|
+
- What the library still does from a script is kept for the step it belongs to (take()), and the handoff says so.
|
|
11
|
+
|
|
12
|
+
One engine process runs one browser run, so the record is per process.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("forgeqe_engine.userlike")
|
|
20
|
+
|
|
21
|
+
_SCRIPT_CLICK = "this.click()"
|
|
22
|
+
|
|
23
|
+
_bypass: List[str] = []
|
|
24
|
+
_covered: Optional[Dict[str, str]] = None
|
|
25
|
+
|
|
26
|
+
# What covers the point: the element there, raised to the overlay or dialog it belongs to (the thing to close).
|
|
27
|
+
_COVER_FN = r"""function(x, y) {
|
|
28
|
+
const at = document.elementFromPoint(x, y);
|
|
29
|
+
if (!at) return null;
|
|
30
|
+
let top = at;
|
|
31
|
+
for (let e = at; e && e !== document.body && e.nodeType === 1; e = e.parentElement) {
|
|
32
|
+
const cs = getComputedStyle(e);
|
|
33
|
+
const role = e.getAttribute('role');
|
|
34
|
+
if (e.tagName === 'DIALOG' || role === 'dialog' || role === 'alertdialog' || e.getAttribute('aria-modal') === 'true' || cs.position === 'fixed' || /\bmodal\b|overlay|popup|backdrop|consent|cookie/i.test(String(e.className || '') + ' ' + e.id)) top = e;
|
|
35
|
+
}
|
|
36
|
+
const cls = typeof top.className === 'string' ? top.className.trim().split(/\s+/).slice(0, 3).join('.') : '';
|
|
37
|
+
const tag = top.tagName.toLowerCase();
|
|
38
|
+
const html = '<' + tag + (top.id ? ' id="' + top.id + '"' : '') + (cls ? ' class="' + cls.replace(/\./g, ' ') + '"' : '') + '>';
|
|
39
|
+
const text = String(top.innerText || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
40
|
+
const frame = tag === 'iframe' ? String(top.getAttribute('src') || '').slice(0, 200) : '';
|
|
41
|
+
return { element: html, css: tag + (top.id ? '#' + top.id : cls ? '.' + cls : ''), text, frame };
|
|
42
|
+
}"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Covered(Exception):
|
|
46
|
+
"""The element is under another one: a user's click lands on that one."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def reset() -> None:
|
|
50
|
+
global _covered
|
|
51
|
+
_bypass.clear()
|
|
52
|
+
_covered = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def take() -> Dict[str, Any]:
|
|
56
|
+
"""What happened in the action since reset(): script actions, and what covered the element."""
|
|
57
|
+
out: Dict[str, Any] = {}
|
|
58
|
+
if _bypass:
|
|
59
|
+
out["bypass"] = list(dict.fromkeys(_bypass))
|
|
60
|
+
if _covered:
|
|
61
|
+
out["covered_by"] = dict(_covered)
|
|
62
|
+
reset()
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def covered_message(cover: Dict[str, str]) -> str:
|
|
67
|
+
what = cover.get("element") or "another element"
|
|
68
|
+
words = f' ("{cover["text"][:80]}")' if cover.get("text") else ""
|
|
69
|
+
return (
|
|
70
|
+
f"The element is covered by {what}{words}, which would take the click: a user cannot click it now, and neither "
|
|
71
|
+
"can a test. Close or dismiss what covers it as a user would (its close, accept, or continue control; Escape for a "
|
|
72
|
+
"dialog), or wait for it to go, then click the element again."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def _what_covers(cdp_session: Any, x: float, y: float) -> Optional[Dict[str, str]]:
|
|
77
|
+
try:
|
|
78
|
+
doc = await cdp_session.cdp_client.send.Runtime.evaluate(
|
|
79
|
+
params={"expression": "document.documentElement", "returnByValue": False}, session_id=cdp_session.session_id
|
|
80
|
+
)
|
|
81
|
+
object_id = ((doc or {}).get("result") or {}).get("objectId")
|
|
82
|
+
if not object_id:
|
|
83
|
+
return None
|
|
84
|
+
res = await cdp_session.cdp_client.send.Runtime.callFunctionOn(
|
|
85
|
+
params={"objectId": object_id, "functionDeclaration": _COVER_FN, "arguments": [{"value": x}, {"value": y}], "returnByValue": True},
|
|
86
|
+
session_id=cdp_session.session_id,
|
|
87
|
+
)
|
|
88
|
+
value = ((res or {}).get("result") or {}).get("value")
|
|
89
|
+
if not isinstance(value, dict):
|
|
90
|
+
return None
|
|
91
|
+
from .memory import strip_icons
|
|
92
|
+
|
|
93
|
+
value["text"] = strip_icons(value.get("text"))
|
|
94
|
+
return value
|
|
95
|
+
except Exception as ex:
|
|
96
|
+
logger.debug("userlike: what covers the element not read: %s", ex)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def install() -> None:
|
|
101
|
+
"""Patch the library once per process (see the module's description)."""
|
|
102
|
+
try:
|
|
103
|
+
from browser_use.browser.watchdogs.default_action_watchdog import DefaultActionWatchdog
|
|
104
|
+
except ImportError:
|
|
105
|
+
return
|
|
106
|
+
if getattr(DefaultActionWatchdog, "__forgeqe_userlike__", False):
|
|
107
|
+
return
|
|
108
|
+
DefaultActionWatchdog.__forgeqe_userlike__ = True # type: ignore[attr-defined]
|
|
109
|
+
|
|
110
|
+
check = DefaultActionWatchdog._check_element_occlusion
|
|
111
|
+
|
|
112
|
+
async def occlusion(self: Any, backend_node_id: int, x: float, y: float, cdp_session: Any) -> bool:
|
|
113
|
+
global _covered
|
|
114
|
+
occluded = await check(self, backend_node_id, x, y, cdp_session)
|
|
115
|
+
if not occluded:
|
|
116
|
+
return False
|
|
117
|
+
cover = await _what_covers(cdp_session, x, y)
|
|
118
|
+
if not cover:
|
|
119
|
+
# Nothing to name (the element could not be resolved): the library clicks from a script, recorded below.
|
|
120
|
+
return True
|
|
121
|
+
_covered = cover
|
|
122
|
+
raise Covered(covered_message(cover))
|
|
123
|
+
|
|
124
|
+
DefaultActionWatchdog._check_element_occlusion = occlusion # type: ignore[method-assign]
|
|
125
|
+
|
|
126
|
+
type_to_page = getattr(DefaultActionWatchdog, "_type_to_page", None)
|
|
127
|
+
if type_to_page is not None:
|
|
128
|
+
|
|
129
|
+
async def typed(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
130
|
+
_bypass.append("typed to the page (into whatever had focus), because the field itself could not be reached")
|
|
131
|
+
return await type_to_page(self, *args, **kwargs)
|
|
132
|
+
|
|
133
|
+
DefaultActionWatchdog._type_to_page = typed # type: ignore[method-assign]
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
from cdp_use.cdp.runtime.library import RuntimeClient
|
|
137
|
+
except ImportError:
|
|
138
|
+
return
|
|
139
|
+
call = RuntimeClient.callFunctionOn
|
|
140
|
+
|
|
141
|
+
async def call_function_on(self: Any, params: Any = None, *args: Any, **kwargs: Any) -> Any:
|
|
142
|
+
try:
|
|
143
|
+
code = str((params or {}).get("functionDeclaration") or "")
|
|
144
|
+
if _SCRIPT_CLICK in code:
|
|
145
|
+
_bypass.append("clicked it from a script (element.click()), not with the mouse")
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
return await call(self, params, *args, **kwargs)
|
|
149
|
+
|
|
150
|
+
RuntimeClient.callFunctionOn = call_function_on # type: ignore[method-assign]
|
|
@@ -165,7 +165,7 @@ def write_project(project: Path, files: Sequence[Tuple[Path, str]], tests: Seque
|
|
|
165
165
|
"framework": {"name": "raw", "args": args},
|
|
166
166
|
"env": {"MAESTRO": True, "MAESTRO_LOGS_DIR": "MaestroLogs", "MAESTRO_CLI_NO_ANALYTICS": "1"},
|
|
167
167
|
"pre": ["chmod +x ./setup.sh ./run.sh", "./setup.sh"],
|
|
168
|
-
"testDiscovery": {"value": names, "mode": "static", "type": "raw"},
|
|
168
|
+
"testDiscovery": {"command": "echo ForgeQE", "value": names, "mode": "static", "type": "raw"},
|
|
169
169
|
"testRunnerCommand": "./run.sh $test",
|
|
170
170
|
"frameworkStatusOnly": True,
|
|
171
171
|
"report": True,
|