@capagents/forgeqe 2.3.17 → 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.
Files changed (76) hide show
  1. package/HOW_TO_USE.md +9 -2
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/dist/forgeqe.js +1097 -1038
  4. package/dist/web/THIRD_PARTY_NOTICES.md +2542 -0
  5. package/dist/web/assets/ApiTests-CSSed9hU.js +2 -0
  6. package/dist/web/assets/Chat-CFfiv7wI.js +2 -0
  7. package/dist/web/assets/Data-Qolfhsd7.js +4 -0
  8. package/dist/web/assets/Features-BM48eq6x.js +1 -0
  9. package/dist/web/assets/Jobs-BjVNQUaK.js +3 -0
  10. package/dist/web/assets/Quality-F0sKErYg.js +1 -0
  11. package/dist/web/assets/Record-CkwV9gN8.js +1 -0
  12. package/dist/web/assets/Regression-DKaNfFu1.js +2 -0
  13. package/dist/web/assets/Reports-o_82URX6.js +3 -0
  14. package/dist/web/assets/Response-YL9AIB8R.js +8 -0
  15. package/dist/web/assets/Runs-Dp-DzyW1.js +2 -0
  16. package/dist/web/assets/circle-dashed-DbpKfamm.js +1 -0
  17. package/dist/web/assets/context-engine-BIWf1ZIT.svg +1 -0
  18. package/dist/web/assets/dm-sans-latin-400-normal-BwCSEQnW.woff +0 -0
  19. package/dist/web/assets/dm-sans-latin-400-normal-CW0RaeGs.woff2 +0 -0
  20. package/dist/web/assets/dm-sans-latin-500-normal-B9HHJjqV.woff2 +0 -0
  21. package/dist/web/assets/dm-sans-latin-500-normal-Dr3UlScf.woff +0 -0
  22. package/dist/web/assets/dm-sans-latin-600-normal-Aqo67rzb.woff2 +0 -0
  23. package/dist/web/assets/dm-sans-latin-600-normal-BmdmIIQ2.woff +0 -0
  24. package/dist/web/assets/dm-sans-latin-ext-400-normal-BjWJ59Pq.woff +0 -0
  25. package/dist/web/assets/dm-sans-latin-ext-400-normal-BtiwyxMk.woff2 +0 -0
  26. package/dist/web/assets/dm-sans-latin-ext-500-normal-BJfUCQsA.woff2 +0 -0
  27. package/dist/web/assets/dm-sans-latin-ext-500-normal-DR84L5F-.woff +0 -0
  28. package/dist/web/assets/dm-sans-latin-ext-600-normal-4vooXBpG.woff2 +0 -0
  29. package/dist/web/assets/dm-sans-latin-ext-600-normal-DRtaDpgU.woff +0 -0
  30. package/dist/web/assets/external-link-pvVHJFAZ.js +1 -0
  31. package/dist/web/assets/file-chart-column-increasing-b-Yh6rmz.js +1 -0
  32. package/dist/web/assets/index-Bkx3fVbr.js +21 -0
  33. package/dist/web/assets/index-DSEYuBEd.css +2 -0
  34. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-BEIGL1Tu.woff2 +0 -0
  35. package/dist/web/assets/jetbrains-mono-cyrillic-400-normal-ugxPyKxw.woff +0 -0
  36. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  37. package/dist/web/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  38. package/dist/web/assets/jetbrains-mono-greek-400-normal-B9oWc5Lo.woff +0 -0
  39. package/dist/web/assets/jetbrains-mono-greek-400-normal-C190GLew.woff2 +0 -0
  40. package/dist/web/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  41. package/dist/web/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  42. package/dist/web/assets/jetbrains-mono-latin-400-normal-6-qcROiO.woff +0 -0
  43. package/dist/web/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  44. package/dist/web/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  45. package/dist/web/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  46. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-Bc8Ftmh3.woff2 +0 -0
  47. package/dist/web/assets/jetbrains-mono-latin-ext-400-normal-fXTG6kC5.woff +0 -0
  48. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  49. package/dist/web/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  50. package/dist/web/assets/jetbrains-mono-vietnamese-400-normal-CqNFfHCs.woff +0 -0
  51. package/dist/web/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  52. package/dist/web/assets/knowledge-base-CMsi5KtZ.svg +1 -0
  53. package/dist/web/assets/markdown-GKy06efH.js +10 -0
  54. package/dist/web/assets/model-CGrmjuJz.svg +1 -0
  55. package/dist/web/assets/report-mkgMJOFK.svg +1 -0
  56. package/dist/web/assets/rotate-ccw-BL6YOAPy.js +1 -0
  57. package/dist/web/assets/rule-DycAnn4u.svg +1 -0
  58. package/dist/web/assets/search-YLg3uzAx.js +1 -0
  59. package/dist/web/assets/status-CL_clA0J.js +3 -0
  60. package/dist/web/assets/sticky-note-DIQ2FWMw.js +1 -0
  61. package/dist/web/assets/terminal-BdTFlGZR.js +1 -0
  62. package/dist/web/assets/timer-NLZ0XmVl.js +1 -0
  63. package/dist/web/assets/trace-YEplLreO.svg +1 -0
  64. package/dist/web/assets/trash-DIUJbRpw.js +1 -0
  65. package/dist/web/index.html +15 -0
  66. package/docs/ARCHITECTURE.md +4 -1
  67. package/docs/user-guide.html +10 -2
  68. package/forgeqe.example.yaml +37 -0
  69. package/package.json +2 -1
  70. package/prompts/test-agent.md +2 -0
  71. package/py/forgeqe_engine/capture.py +33 -7
  72. package/py/forgeqe_engine/logins.py +5 -0
  73. package/py/forgeqe_engine/memory.py +80 -4
  74. package/py/forgeqe_engine/service.py +41 -5
  75. package/py/forgeqe_engine/userlike.py +150 -0
  76. 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 .memory import BASE, KEPT_ATTRS, candidate_locators, confidence, looks_generated
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
- return locs.map((l) => {
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") != "role"]
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
- results = dict(zip((id(l) for l in plain), ((res.get("result") or {}).get("value") or [])))
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") == "role":
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
- return { tag: el.tagName.toLowerCase(), attributes: attrs, classes, xpath: xp(el), text: String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120) };
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
- if role in ROLES and name and len(name) <= 80:
242
- add("role", f"{role}|{name}", f"page.getByRole({_q(role)}, {{ name: {_q(name)}, exact: true }})", role=role, name=name)
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
- return facts.get("name") or facts.get("text") or a.get("placeholder") or a.get("aria-label") or facts.get("tag") or ""
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
- stored = load_sessions(logins)
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 logins if l.session_file and Path(l.session_file).is_file()]
1027
- emit({"type": "log", "level": "info", "text": f"Signed-in session restored for {', '.join(names)}"})
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,