@capagents/forgeqe 0.0.0-stage → 1.0.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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +757 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +136 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,538 @@
1
+ """
2
+ Live capture for the test a passed run becomes, done while the page is still there:
3
+
4
+ - what each action changed on the page (an action that changed nothing is not part of the path),
5
+ - the control a user acts on, not the decorative node under the pointer,
6
+ - which locators find exactly that element on the live page (role and name through Chrome's accessibility tree,
7
+ the same names Playwright's getByRole uses; CSS, ids, and XPath in the page),
8
+ - the element and section behind each recorded check,
9
+ - click_selector, a real mouse click on an element found by CSS, so the agent never has to click from JavaScript.
10
+ """
11
+ # No `from __future__ import annotations`: the library matches the injected browser_session parameter by its real type.
12
+ import asyncio
13
+ import json
14
+ import logging
15
+ import time
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from browser_use import ActionResult, BrowserSession
19
+
20
+ from .memory import BASE, KEPT_ATTRS, candidate_locators, confidence, looks_generated
21
+
22
+ logger = logging.getLogger("forgeqe_engine.capture")
23
+
24
+ # Actions whose only evidence of working is a change on the page; fills and selects always count.
25
+ EFFECT_ACTIONS = frozenset({"click", "send_keys", "evaluate", "click_selector"})
26
+ EFFECT_WAIT_SECONDS = 1.5
27
+ CDP_TIMEOUT = 3.0
28
+
29
+ ACTIONABLE_TAGS = frozenset({"a", "button", "input", "select", "textarea", "summary", "option", "label"})
30
+ ACTIONABLE_ROLES = frozenset(
31
+ {"button", "link", "checkbox", "radio", "switch", "tab", "menuitem", "menuitemcheckbox", "menuitemradio", "option",
32
+ "combobox", "textbox", "searchbox", "treeitem", "slider", "spinbutton"}
33
+ )
34
+ MAX_CLIMB = 5
35
+
36
+ CAPTURE_SYSTEM_MESSAGE = """
37
+ ## Acting so the run can become a test
38
+ A passed run is turned into an automated test that repeats your actions with real clicks and typing. Act only through the browser actions (click, input, select_dropdown, send_keys, click_selector). Never click, submit, navigate, or change values from evaluate (element.click(), dispatchEvent, location.href = …, setting .value): a test cannot repeat that. Use evaluate only to read the page. Open by URL only the start page or a URL the task gives: adding #section or ?parameters to a URL to land where a step would take you skips that step, and a test cannot count it as done. If you open a wrong URL, go back to the start page and do the steps. When the control you need has no index, find it with find_elements or evaluate, then click it with click_selector and a CSS selector that matches only that element.
39
+ """.strip()
40
+
41
+ # A cheap fingerprint of what a user could notice: address, visible text, structure, open menus and dialogs, form state.
42
+ _STATE_JS = r"""(() => {
43
+ const hash = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0; return h };
44
+ const body = document.body;
45
+ const text = (body && body.innerText) || '';
46
+ const visible = (e) => e.getClientRects().length > 0;
47
+ const open = [...document.querySelectorAll('[aria-expanded="true"], dialog[open], [role="dialog"], [role="menu"], [role="listbox"], [aria-modal="true"]')].filter(visible).length;
48
+ const fields = [...document.querySelectorAll('input, textarea, select')];
49
+ const values = fields.map((e) => (e.type === 'checkbox' || e.type === 'radio') ? (e.checked ? '1' : '0') : String(e.value || '')).join('\u0001');
50
+ const selected = [...document.querySelectorAll('[aria-selected="true"], [aria-checked="true"], [aria-pressed="true"]')].length;
51
+ return { url: location.href, text: hash(text), len: text.length, nodes: document.getElementsByTagName('*').length, open, values: hash(values), selected };
52
+ })()"""
53
+
54
+
55
+ async def _cdp(session: Any):
56
+ return await asyncio.wait_for(session.get_or_create_cdp_session(), timeout=CDP_TIMEOUT)
57
+
58
+
59
+ async def _send(cdp: Any, domain: str, method: str, params: Dict[str, Any]) -> Dict[str, Any]:
60
+ fn = getattr(getattr(cdp.cdp_client.send, domain), method)
61
+ return await asyncio.wait_for(fn(params=params, session_id=cdp.session_id), timeout=CDP_TIMEOUT)
62
+
63
+
64
+ async def page_state(session: Any) -> Optional[Dict[str, Any]]:
65
+ """The page's fingerprint, or None when the page could not be asked (navigating, closed)."""
66
+ try:
67
+ cdp = await _cdp(session)
68
+ res = await _send(cdp, "Runtime", "evaluate", {"expression": _STATE_JS, "returnByValue": True})
69
+ value = (res.get("result") or {}).get("value")
70
+ return value if isinstance(value, dict) else None
71
+ except Exception as ex:
72
+ logger.debug("capture: page state unavailable: %s", ex)
73
+ return None
74
+
75
+
76
+ def page_changed(before: Optional[Dict[str, Any]], after: Optional[Dict[str, Any]]) -> Optional[bool]:
77
+ """True when anything a user could notice changed; None when either state is unknown."""
78
+ if not before or not after:
79
+ return None
80
+ return any(before.get(k) != after.get(k) for k in ("url", "text", "len", "nodes", "open", "values", "selected"))
81
+
82
+
83
+ async def effect_after(session: Any, before: Optional[Dict[str, Any]], wait: float = EFFECT_WAIT_SECONDS) -> Optional[bool]:
84
+ """Whether the page changed since `before`, polling briefly for menus, results, and navigations that come late."""
85
+ if before is None:
86
+ return None
87
+ deadline = time.monotonic() + wait
88
+ delay = 0.0
89
+ while True:
90
+ if delay:
91
+ await asyncio.sleep(delay)
92
+ after = await page_state(session)
93
+ if after is None:
94
+ return True if delay else None # the page went away mid-poll: most likely it navigated
95
+ if page_changed(before, after):
96
+ return True
97
+ if time.monotonic() >= deadline:
98
+ return False
99
+ delay = min(0.5, max(0.15, delay * 2 or 0.15))
100
+
101
+
102
+ def action_name(action: Any) -> str:
103
+ try:
104
+ data = action.model_dump(exclude_unset=True)
105
+ except Exception:
106
+ return ""
107
+ return next((k for k, v in data.items() if v is not None), "")
108
+
109
+
110
+ def install_effects(tools: Any, effects: List[Dict[str, Any]], captured: Dict[str, Any]) -> None:
111
+ """
112
+ Wrap tools.act so each action call leaves {action, effect} in `effects`, in call order; click_selector also leaves
113
+ the element it clicked (from `captured`). Effects are measured only for actions whose result is a change on the page.
114
+ """
115
+ original = tools.act
116
+
117
+ async def act(action: Any, browser_session: Any = None, *args: Any, **kwargs: Any):
118
+ session = browser_session if browser_session is not None else kwargs.get("browser_session")
119
+ name = action_name(action)
120
+ before = await page_state(session) if name in EFFECT_ACTIONS and session is not None else None
121
+ captured.pop("target", None)
122
+ result = await original(action, browser_session, *args, **kwargs)
123
+ entry: Dict[str, Any] = {"action": name, "effect": None}
124
+ if name in EFFECT_ACTIONS and session is not None and not getattr(result, "error", None):
125
+ entry["effect"] = await effect_after(session, before)
126
+ if captured.get("target") is not None:
127
+ entry["target"] = captured.pop("target")
128
+ effects.append(entry)
129
+ return result
130
+
131
+ tools.act = act
132
+
133
+
134
+ # ---------------------------------------------------------------------------------------------
135
+ # The element a user acts on, and locators verified on the live page
136
+ # ---------------------------------------------------------------------------------------------
137
+
138
+
139
+ def _actionable(node: Any) -> bool:
140
+ tag = str(getattr(node, "node_name", "") or "").lower()
141
+ attrs = getattr(node, "attributes", None) or {}
142
+ if tag == "a":
143
+ return bool(attrs.get("href")) or str(attrs.get("role") or "") in ACTIONABLE_ROLES
144
+ if tag in ACTIONABLE_TAGS:
145
+ return True
146
+ role = str(attrs.get("role") or getattr(getattr(node, "ax_node", None), "role", "") or "")
147
+ return role in ACTIONABLE_ROLES
148
+
149
+
150
+ def actionable_node(node: Any) -> Any:
151
+ """The node itself when a user acts on it, else its nearest ancestor that is a control (an icon inside a button → the button)."""
152
+ current, depth = node, 0
153
+ while current is not None and depth <= MAX_CLIMB:
154
+ if _actionable(current):
155
+ return current
156
+ current, depth = getattr(current, "parent_node", None), depth + 1
157
+ return node
158
+
159
+
160
+ # Count what each CSS/XPath/text/attribute locator matches, and whether it is the element (or inside it: a click there reaches it).
161
+ _VERIFY_FN = r"""function(locs) {
162
+ const el = this;
163
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
164
+ const q = (v) => '"' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
165
+ const visible = (e) => e.getClientRects().length > 0;
166
+ const byText = (t) => [...document.querySelectorAll('body *')].filter((e) => visible(e) && norm(e.textContent) === t && ![...e.children].some((c) => norm(c.textContent) === t));
167
+ const find = (l) => {
168
+ switch (l.type) {
169
+ case 'xpath': {
170
+ const r = document.evaluate(l.value, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
171
+ return Array.from({ length: r.snapshotLength }, (_, i) => r.snapshotItem(i));
172
+ }
173
+ case 'text': return byText(norm(l.value));
174
+ case 'placeholder': return [...document.querySelectorAll('[placeholder=' + q(l.value) + ']')];
175
+ case 'alt': return [...document.querySelectorAll('[alt=' + q(l.value) + ']')];
176
+ case 'title': return [...document.querySelectorAll('[title=' + q(l.value) + ']')];
177
+ case 'test_id': return [...document.querySelectorAll('[' + (l.attr || 'data-testid') + '=' + q(l.value) + ']')];
178
+ default: return l.css ? [...document.querySelectorAll(l.css)] : null;
179
+ }
180
+ };
181
+ return locs.map((l) => {
182
+ try {
183
+ const all = find(l);
184
+ if (!all) return null;
185
+ const hits = all.filter(visible).length ? all.filter(visible) : all;
186
+ return { count: hits.length, target: hits.length === 1 && (hits[0] === el || el.contains(hits[0])) };
187
+ } catch (e) { return { count: -1, target: false }; }
188
+ });
189
+ }"""
190
+
191
+
192
+ async def _object_for(cdp: Any, backend_node_id: int) -> Optional[str]:
193
+ res = await _send(cdp, "DOM", "resolveNode", {"backendNodeId": backend_node_id})
194
+ return ((res or {}).get("object") or {}).get("objectId")
195
+
196
+
197
+ async def _ax_matches(cdp: Any, role: str, name: str) -> List[int]:
198
+ """backend node ids of the elements Chrome's accessibility tree names `name` with `role`."""
199
+ doc = await _send(cdp, "DOM", "getDocument", {"depth": 0})
200
+ root = ((doc or {}).get("root") or {}).get("backendNodeId")
201
+ res = await _send(cdp, "Accessibility", "queryAXTree", {"backendNodeId": root, "accessibleName": name, "role": role})
202
+ return [n["backendDOMNodeId"] for n in (res or {}).get("nodes") or [] if n.get("backendDOMNodeId") and not n.get("ignored")]
203
+
204
+
205
+ async def verify_locators(session: Any, backend_node_id: Optional[int], locators: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
206
+ """
207
+ Each locator with `matches` (elements it finds on the live page) and `verified` (exactly one, and it is this
208
+ element or inside it). Locators that could not be checked are returned unchanged. Verified ones come first.
209
+ """
210
+ if not backend_node_id or not locators:
211
+ return locators
212
+ try:
213
+ cdp = await _cdp(session)
214
+ object_id = await _object_for(cdp, backend_node_id)
215
+ if not object_id:
216
+ return locators
217
+ plain = [l for l in locators if l.get("type") != "role"]
218
+ res = await _send(
219
+ cdp, "Runtime", "callFunctionOn",
220
+ {"objectId": object_id, "functionDeclaration": _VERIFY_FN, "arguments": [{"value": plain}], "returnByValue": True},
221
+ )
222
+ results = dict(zip((id(l) for l in plain), ((res.get("result") or {}).get("value") or [])))
223
+ for loc in locators:
224
+ if loc.get("type") == "role":
225
+ try:
226
+ hits = await _ax_matches(cdp, str(loc.get("role") or ""), str(loc.get("name") or ""))
227
+ except Exception:
228
+ continue
229
+ inside = len(hits) == 1 and (hits[0] == backend_node_id or await _contains(cdp, object_id, hits[0]))
230
+ loc["matches"], loc["verified"] = len(hits), bool(inside)
231
+ continue
232
+ r = results.get(id(loc))
233
+ if isinstance(r, dict) and r.get("count", -1) >= 0:
234
+ loc["matches"], loc["verified"] = int(r["count"]), bool(r.get("target"))
235
+ except Exception as ex:
236
+ logger.debug("capture: locator check failed: %s", ex)
237
+ return locators
238
+ for loc in locators:
239
+ if "verified" in loc:
240
+ loc["confidence"] = confidence(loc)
241
+ return sorted(locators, key=lambda l: (not l.get("verified"), -int(l.get("confidence") or 0), -BASE.get(str(l.get("type")), 0)))
242
+
243
+
244
+ async def _contains(cdp: Any, object_id: str, backend_node_id: int) -> bool:
245
+ other = await _object_for(cdp, backend_node_id)
246
+ if not other:
247
+ return False
248
+ res = await _send(
249
+ cdp, "Runtime", "callFunctionOn",
250
+ {"objectId": object_id, "functionDeclaration": "function(o){return this.contains(o)}", "arguments": [{"objectId": other}], "returnByValue": True},
251
+ )
252
+ return bool((res.get("result") or {}).get("value"))
253
+
254
+
255
+ # Facts (as memory.element_facts gives them) for an element reached through CDP rather than the agent's element list.
256
+ _FACTS_FN = r"""function(kept) {
257
+ const el = this;
258
+ const attrs = {};
259
+ for (const k of kept) { const v = el.getAttribute(k); if (v) attrs[k] = v; }
260
+ 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('/'); };
261
+ const classes = [...el.classList].filter((c) => /^[A-Za-z_-][\w-]*$/.test(c)).slice(0, 4);
262
+ return { tag: el.tagName.toLowerCase(), attributes: attrs, classes, xpath: xp(el), text: String(el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120) };
263
+ }"""
264
+
265
+
266
+ async def element_facts_cdp(session: Any, backend_node_id: int) -> Optional[Dict[str, Any]]:
267
+ try:
268
+ cdp = await _cdp(session)
269
+ object_id = await _object_for(cdp, backend_node_id)
270
+ if not object_id:
271
+ return None
272
+ res = await _send(
273
+ cdp, "Runtime", "callFunctionOn",
274
+ {"objectId": object_id, "functionDeclaration": _FACTS_FN, "arguments": [{"value": list(KEPT_ATTRS)}], "returnByValue": True},
275
+ )
276
+ facts = (res.get("result") or {}).get("value") or {}
277
+ # Build-generated class names (b87c397a13, css-1x2y3z) change with the site's next deploy.
278
+ facts["classes"] = [c for c in facts.get("classes") or [] if not looks_generated(c)]
279
+ ax = await _send(cdp, "Accessibility", "getPartialAXTree", {"backendNodeId": backend_node_id, "fetchRelatives": False})
280
+ node = next(iter((ax or {}).get("nodes") or []), {})
281
+ role = str(((node.get("role") or {}).get("value")) or "")
282
+ name = " ".join(str(((node.get("name") or {}).get("value")) or "").split())[:120]
283
+ # As memory.element_facts: the accessible name when there is one, the element's text only without it.
284
+ facts.update(role=facts["attributes"].get("role") or role, name=name, stable_hash=None, text="" if name else facts.get("text", ""))
285
+ return facts
286
+ except Exception as ex:
287
+ logger.debug("capture: element facts failed: %s", ex)
288
+ return None
289
+
290
+
291
+ async def capture_element(session: Any, backend_node_id: int, facts: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
292
+ """{facts, locators (verified first)} for one element on the live page."""
293
+ facts = facts or await element_facts_cdp(session, backend_node_id)
294
+ if not facts:
295
+ return None
296
+ locs = candidate_locators(facts)
297
+ for loc in locs:
298
+ loc.setdefault("matches", 1)
299
+ loc["confidence"] = confidence(loc)
300
+ return {"facts": facts, "locators": await verify_locators(session, backend_node_id, locs)}
301
+
302
+
303
+ async def flow_target(session: Any, node: Any, target: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
304
+ """
305
+ The memory target for an action, re-pointed at the control a user acts on, with its locators checked on the live
306
+ page. The memory element (and its score) stays the one the agent targeted.
307
+ """
308
+ if target is None or node is None:
309
+ return target
310
+ from .memory import element_facts
311
+
312
+ control = actionable_node(node)
313
+ try:
314
+ facts = element_facts(control) if control is not node else target["facts"]
315
+ except Exception:
316
+ facts = target["facts"]
317
+ locs = candidate_locators(facts) if control is not node else [dict(l) for l in target.get("locators") or []]
318
+ if control is not node:
319
+ for loc in locs:
320
+ loc.setdefault("matches", 1)
321
+ loc["confidence"] = confidence(loc)
322
+ checked = await verify_locators(session, getattr(control, "backend_node_id", None), locs)
323
+ out = dict(target, facts=facts, locators=checked)
324
+ overlay = await overlay_of(session, getattr(control, "backend_node_id", None))
325
+ if overlay:
326
+ out["overlay"] = overlay
327
+ out["current"] = {(l["type"], l["value"]): l.get("matches", 1) for l in checked if l.get("verified")} or target.get("current") or {}
328
+ if control is not node:
329
+ out["climbed"] = True
330
+ return out
331
+
332
+
333
+ # ---------------------------------------------------------------------------------------------
334
+ # Overlays: the dialog or banner an action's element sits in
335
+ # ---------------------------------------------------------------------------------------------
336
+
337
+ # A dialog, or a fixed bar or box that reads like a cookie, consent, sign-in, or newsletter prompt. A sticky site
338
+ # header is not one: it stays while the user works, so its controls are part of the flow.
339
+ _OVERLAY_FN = r"""function() {
340
+ const PROMPT = /cookie|consent|privacy|gdpr|onetrust|sign[ -]?in|log[ -]?in|subscribe|newsletter|notification|genius|app/i;
341
+ const kind = (e) => {
342
+ const role = e.getAttribute('role');
343
+ if (e.tagName === 'DIALOG' || role === 'dialog' || role === 'alertdialog' || e.getAttribute('aria-modal') === 'true') return 'dialog';
344
+ const cs = getComputedStyle(e);
345
+ if (cs.position !== 'fixed') return null;
346
+ const hint = (e.id + ' ' + (typeof e.className === 'string' ? e.className : '') + ' ' + (e.getAttribute('aria-label') || '') + ' ' + String(e.innerText || '').slice(0, 300));
347
+ return PROMPT.test(hint) ? 'banner' : null;
348
+ };
349
+ for (let e = this; e && e !== document.body && e.nodeType === 1; e = e.parentElement) {
350
+ const k = kind(e);
351
+ if (!k) continue;
352
+ const heading = e.querySelector('h1, h2, h3, [role=heading]');
353
+ const name = (e.getAttribute('aria-label') || (heading && heading.textContent) || '').replace(/\s+/g, ' ').trim().slice(0, 80);
354
+ const text = (e.id + ' ' + (typeof e.className === 'string' ? e.className : '') + ' ' + name + ' ' + String(e.innerText || '').slice(0, 400)).toLowerCase();
355
+ const what = /cookie|consent|gdpr|onetrust|privacy/.test(text) ? 'cookie banner' : /sign[ -]?in|log[ -]?in|account/.test(text) ? 'sign-in prompt' : k === 'dialog' ? 'dialog' : 'banner';
356
+ return { kind: what, name, id: e.id || '' };
357
+ }
358
+ return null;
359
+ }"""
360
+
361
+
362
+ async def overlay_of(session: Any, backend_node_id: Optional[int]) -> Optional[Dict[str, Any]]:
363
+ """The overlay (dialog, cookie banner, sign-in prompt) the element sits in, or None for an element of the page itself."""
364
+ if not backend_node_id:
365
+ return None
366
+ try:
367
+ cdp = await _cdp(session)
368
+ object_id = await _object_for(cdp, backend_node_id)
369
+ if not object_id:
370
+ return None
371
+ res = await _send(cdp, "Runtime", "callFunctionOn", {"objectId": object_id, "functionDeclaration": _OVERLAY_FN, "returnByValue": True})
372
+ value = (res.get("result") or {}).get("value")
373
+ return value if isinstance(value, dict) else None
374
+ except Exception as ex:
375
+ logger.debug("capture: overlay check failed: %s", ex)
376
+ return None
377
+
378
+
379
+ # ---------------------------------------------------------------------------------------------
380
+ # The element behind a check
381
+ # ---------------------------------------------------------------------------------------------
382
+
383
+ # The element showing the text: the smallest visible one, raised to a near ancestor with a test id or id (a short
384
+ # wrapper a test can name). When it sits in an item that repeats on the page (a result card, a row), the item's
385
+ # selector, how many there are, and the element's selector inside one item: what a check about "every item" needs.
386
+ _LOCATE_TEXT_JS = r"""(function(want){
387
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim().toLowerCase();
388
+ const w = norm(want);
389
+ const visible = (e) => e.getClientRects().length > 0;
390
+ const TID = ['data-testid', 'data-test-id', 'data-test', 'data-qa', 'data-cy'];
391
+ const tidOf = (e) => { for (const a of TID) { const v = e.getAttribute(a); if (v) return [a, v]; } return null; };
392
+ const q = (v) => '"' + String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
393
+ const sel = (e) => { const t = tidOf(e); return t ? '[' + t[0] + '=' + q(t[1]) + ']' : null; };
394
+ let best = null;
395
+ for (const e of document.querySelectorAll('body *')) {
396
+ if (!visible(e) || ['SCRIPT', 'STYLE', 'NOSCRIPT'].includes(e.tagName)) continue;
397
+ if (!norm(e.innerText).includes(w)) continue;
398
+ if ([...e.children].some((c) => visible(c) && norm(c.innerText).includes(w))) continue;
399
+ if (!best || norm(e.innerText).length < norm(best.innerText).length) best = e;
400
+ }
401
+ if (!best) return null;
402
+ // A heading, link, control, or cell already names itself; only a bare wrapper (div, span, …) is raised.
403
+ const NAMED = ['H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'A', 'BUTTON', 'LABEL', 'INPUT', 'SELECT', 'TEXTAREA', 'TD', 'TH', 'LI', 'OPTION'];
404
+ const BLOCK = ['SECTION', 'ARTICLE', 'MAIN', 'ASIDE', 'NAV', 'FORM', 'TABLE', 'UL', 'OL', 'DIALOG', 'HEADER', 'FOOTER'];
405
+ let el = best;
406
+ if (!NAMED.includes(best.tagName) && !tidOf(best) && !best.id) {
407
+ for (let e = best.parentElement, i = 1; e && e !== document.body && i <= 3; e = e.parentElement, i++) {
408
+ if (BLOCK.includes(e.tagName) || norm(e.innerText).length > Math.max(3 * w.length, w.length + 40)) break;
409
+ if (tidOf(e) || e.id) { el = e; break; }
410
+ }
411
+ }
412
+ let item = null, itemSelector = null, itemCount = 0;
413
+ for (let e = el.parentElement; e && e !== document.body; e = e.parentElement) {
414
+ const s = sel(e);
415
+ if (!s) continue;
416
+ const n = document.querySelectorAll(s).length;
417
+ if (n > 1) { item = e; itemSelector = s; itemCount = n; break; }
418
+ }
419
+ let inside = null;
420
+ if (item) {
421
+ const s = sel(el);
422
+ if (s && item.querySelectorAll(s).length === 1) inside = s;
423
+ }
424
+ const section = el.closest('section, article, main, aside, nav, form, table, dialog, [role=region], [role=dialog], [role=main], [id]:not(body):not(html)');
425
+ return {
426
+ el,
427
+ section: !item && section && section !== el && section.contains(el) && norm(section.innerText).length < 5000 ? section : null,
428
+ item: itemSelector ? { selector: itemSelector, count: itemCount, inside } : null,
429
+ };
430
+ })"""
431
+
432
+ # Locators that spell out the text a check found: wrong for live data, which differs on the next run.
433
+ _VALUE_LOCATORS = frozenset({"text", "role", "label", "title", "alt", "xpath"})
434
+
435
+
436
+ async def locate_check(session: Any, text: str, dynamic: bool = False) -> Optional[Dict[str, Any]]:
437
+ """
438
+ The element showing `text`, with locators checked on the live page, and either its section or the repeating item
439
+ it belongs to (with the item's selector, count, and the element's selector inside one item). For live data the
440
+ element's locators that spell out this run's text are left out.
441
+ """
442
+ try:
443
+ cdp = await _cdp(session)
444
+ res = await _send(
445
+ cdp, "Runtime", "evaluate",
446
+ {"expression": f"{_LOCATE_TEXT_JS}({json.dumps(text)})", "returnByValue": False},
447
+ )
448
+ obj = (res.get("result") or {}).get("objectId")
449
+ if not obj:
450
+ return None
451
+ props = await _send(cdp, "Runtime", "getProperties", {"objectId": obj, "ownProperties": True})
452
+ ids: Dict[str, int] = {}
453
+ item: Optional[Dict[str, Any]] = None
454
+ for p in (props or {}).get("result") or []:
455
+ oid = (p.get("value") or {}).get("objectId")
456
+ if p.get("name") in ("el", "section") and oid:
457
+ desc = await _send(cdp, "DOM", "describeNode", {"objectId": oid})
458
+ backend = ((desc or {}).get("node") or {}).get("backendNodeId")
459
+ if backend:
460
+ ids[p["name"]] = int(backend)
461
+ elif p.get("name") == "item" and oid:
462
+ val = await _send(cdp, "Runtime", "callFunctionOn", {"objectId": oid, "functionDeclaration": "function(){return this}", "returnByValue": True})
463
+ item = (val.get("result") or {}).get("value") or None
464
+ if "el" not in ids:
465
+ return None
466
+ element = await capture_element(session, ids["el"])
467
+ if not element:
468
+ return None
469
+ if dynamic:
470
+ stable = [l for l in element["locators"] if l.get("type") not in _VALUE_LOCATORS]
471
+ element["locators"] = stable
472
+ out: Dict[str, Any] = {"element": element}
473
+ if item:
474
+ out["item"] = item
475
+ if "section" in ids:
476
+ section = await capture_element(session, ids["section"])
477
+ if section:
478
+ out["section"] = section
479
+ return out
480
+ except Exception as ex:
481
+ logger.debug("capture: check element not located: %s", ex)
482
+ return None
483
+
484
+
485
+ def public_locators(locs: List[Dict[str, Any]], limit: int = 4) -> List[Dict[str, Any]]:
486
+ keep = ("type", "value", "playwright", "confidence", "verified", "matches", "role", "name", "css", "attr")
487
+ chosen = [l for l in locs if l.get("verified")] or locs
488
+ return [{k: l[k] for k in keep if k in l} for l in chosen[:limit]]
489
+
490
+
491
+ # ---------------------------------------------------------------------------------------------
492
+ # click_selector
493
+ # ---------------------------------------------------------------------------------------------
494
+
495
+
496
+ def register_click_selector(tools: Any, captured: Dict[str, Any]) -> None:
497
+ """A real mouse click on the one visible element a CSS selector matches; the element is captured for the test."""
498
+
499
+ @tools.action(
500
+ "Click the element that a CSS selector matches, with a real mouse click. Use it when the control you need has no "
501
+ "index; the selector must match exactly one visible element. Never click from evaluate instead."
502
+ )
503
+ async def click_selector(selector: str, browser_session: BrowserSession) -> ActionResult:
504
+ try:
505
+ cdp = await _cdp(browser_session)
506
+ doc = await _send(cdp, "DOM", "getDocument", {"depth": 0})
507
+ found = await _send(cdp, "DOM", "querySelectorAll", {"nodeId": doc["root"]["nodeId"], "selector": selector})
508
+ node_ids = (found or {}).get("nodeIds") or []
509
+ boxes = []
510
+ for nid in node_ids[:20]:
511
+ try:
512
+ quads = await _send(cdp, "DOM", "getContentQuads", {"nodeId": nid})
513
+ except Exception:
514
+ continue
515
+ if (quads or {}).get("quads"):
516
+ boxes.append(nid)
517
+ if len(boxes) != 1:
518
+ return ActionResult(error=f"{selector!r} matches {len(boxes)} visible elements; give a selector that matches exactly one.")
519
+ nid = boxes[0]
520
+ await _send(cdp, "DOM", "scrollIntoViewIfNeeded", {"nodeId": nid})
521
+ quad = (await _send(cdp, "DOM", "getContentQuads", {"nodeId": nid}))["quads"][0]
522
+ x, y = sum(quad[0::2]) / 4, sum(quad[1::2]) / 4
523
+ desc = await _send(cdp, "DOM", "describeNode", {"nodeId": nid})
524
+ backend = int(desc["node"]["backendNodeId"])
525
+ hit = await _send(cdp, "DOM", "getNodeForLocation", {"x": int(x), "y": int(y), "includeUserAgentShadowDOM": False})
526
+ if hit.get("backendNodeId") and hit["backendNodeId"] != backend:
527
+ obj = await _object_for(cdp, backend)
528
+ if obj and not await _contains(cdp, obj, int(hit["backendNodeId"])):
529
+ return ActionResult(error=f"{selector!r} is covered by another element at its centre; close what covers it first.")
530
+ for kind in ("mouseMoved", "mousePressed", "mouseReleased"):
531
+ params: Dict[str, Any] = {"type": kind, "x": x, "y": y}
532
+ if kind != "mouseMoved":
533
+ params.update(button="left", clickCount=1)
534
+ await _send(cdp, "Input", "dispatchMouseEvent", params)
535
+ captured["target"] = await capture_element(browser_session, backend)
536
+ return ActionResult(extracted_content=f"Clicked {selector}", long_term_memory=f"Clicked {selector}")
537
+ except Exception as ex:
538
+ return ActionResult(error=f"click_selector failed: {ex}")
@@ -0,0 +1,98 @@
1
+ """
2
+ What the browser agent checked: each expected result, and the exact text on the page that shows it.
3
+
4
+ A passed run becomes a test, and the test asserts these checks; ForgeQE's Playwright replay finds each text on
5
+ the final page and hands the coding agent a locator that matched there.
6
+ """
7
+ import json
8
+ import logging
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from browser_use import ActionResult, BrowserSession
12
+
13
+ from .capture import locate_check, public_locators
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ CHECK_ACTION = "record_check"
18
+
19
+ CHECKS_SYSTEM_MESSAGE = """
20
+ ## Recording checks (required before done with success)
21
+ A passed run becomes an automated test that asserts what you checked. For each result the task asks you to verify, call record_check once before done: `expected` is that result in a few words, and `text` is the shortest exact text on the page that shows it (a heading, a value, a label, the chosen option), copied as the page shows it. Set `dynamic` to true when that text is live data that will differ on another run (today's date, a price, a count, today's featured item), so the test asserts the rule rather than this value. Record only what the task asks you to verify.
22
+ When the task lists expected results, record exactly one check per expected result, with `expected` copied from that line. ForgeQE finds the element showing `text`, so pick the text that points at what the line is about:
23
+ - A result about the items in a list (how many there are, what every item shows): copy text from inside one item, such as one result's name or its rating, never a summary line like "2,463 properties found". ForgeQE then finds the repeating item and the field in it.
24
+ - A page or section that opens: its heading or another label that names it.
25
+
26
+ ## When an expected result does not hold
27
+ This is a test: an expected result that does not hold is a valid result, the test failing, not a mistake for you to work around. Do every step of the task first, each through the page's own controls as the step says.
28
+ Settle each expected result with one read of the element it is about (the infobox row, the heading, the result card): when that element shows something else, record holds=false straight away. Do not try other spellings, number formats, or searches for the expected value. When the page does not show what an expected result says (another value, another text), record it once with record_check holds=false, text being exactly what the page shows instead, then go on with the remaining steps and expected results. Do not search the page again for the expected value, look for it elsewhere, or redo steps to make it hold. Finish with done success=false, starting with "Expected result not met:" and naming each one with what the page showed. Use success=false for the task itself only when a step could not be done.
29
+ """.strip()
30
+
31
+ # Visible text, plus the values of form fields, which visible text leaves out.
32
+ _SHOWS_JS = """(function(want){
33
+ const norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
34
+ const w = norm(want);
35
+ if (!w) return false;
36
+ if (norm(document.body && document.body.innerText).includes(w)) return true;
37
+ return [...document.querySelectorAll('input, textarea, select')].some((e) => norm(e.value).includes(w));
38
+ })(%s)"""
39
+
40
+
41
+ async def page_shows(browser_session: BrowserSession, text: str) -> Optional[bool]:
42
+ """Whether the current page shows `text`; None when the page could not be asked."""
43
+ try:
44
+ cdp = await browser_session.get_or_create_cdp_session()
45
+ result = await cdp.cdp_client.send.Runtime.evaluate(
46
+ params={"expression": _SHOWS_JS % json.dumps(text), "returnByValue": True},
47
+ session_id=cdp.session_id,
48
+ )
49
+ value = (result.get("result") or {}).get("value")
50
+ return value if isinstance(value, bool) else None
51
+ except Exception as ex:
52
+ logger.debug("record_check: could not read the page: %s", ex)
53
+ return None
54
+
55
+
56
+ def register_checks(tools: Any, checks: List[Dict[str, Any]]) -> None:
57
+ """Add record_check to the agent's tools; each check it records lands in `checks`."""
58
+
59
+ @tools.action(
60
+ "Record one result the task asks you to verify, before done: expected (the result in a few words), "
61
+ "text (the shortest exact text on the page that shows it, copied as shown), and dynamic=true when that text "
62
+ "is live data that will differ on another run (today's date, a price, a count, today's featured item). "
63
+ "holds=false when the page does not show the expected result: then text is what the page shows instead."
64
+ )
65
+ async def record_check(expected: str, text: str, browser_session: BrowserSession, dynamic: bool = False, holds: bool = True) -> ActionResult:
66
+ expected, text = " ".join(str(expected or "").split())[:200], " ".join(str(text or "").split())[:200]
67
+ if not text:
68
+ return ActionResult(error="Give the text on the page that shows the result.")
69
+ if await page_shows(browser_session, text) is False:
70
+ return ActionResult(error=f"The page does not show {text!r}. Record the text exactly as the page shows it.")
71
+ url = ""
72
+ try:
73
+ url = await browser_session.get_current_page_url()
74
+ except Exception:
75
+ pass
76
+ check: Dict[str, Any] = {"expected": expected or text, "text": text, "url": url}
77
+ if dynamic:
78
+ check["dynamic"] = True
79
+ if not holds:
80
+ # A failed expectation is a test result: `text` is what the page shows instead.
81
+ check["holds"] = False
82
+ # The element showing the text and its section, with locators checked on this page, for the test's assertion.
83
+ found = await locate_check(browser_session, text, dynamic=dynamic)
84
+ if found:
85
+ el = found["element"]
86
+ check["element"] = {"tag": el["facts"].get("tag"), "role": el["facts"].get("role"), "name": el["facts"].get("name"), "locators": public_locators(el["locators"])}
87
+ if found.get("item"):
88
+ check["item"] = found["item"]
89
+ if found.get("section"):
90
+ sec = found["section"]
91
+ check["container"] = {"tag": sec["facts"].get("tag"), "name": sec["facts"].get("name"), "locators": public_locators(sec["locators"], 2)}
92
+ checks[:] = [c for c in checks if c["expected"].lower() != expected.lower()]
93
+ checks.append(check)
94
+ if not holds:
95
+ return ActionResult(
96
+ extracted_content=f"Recorded: expected result not met: {expected} (the page shows {text!r}). That is this test's result; carry on with the remaining steps and checks, then finish."
97
+ )
98
+ return ActionResult(extracted_content=f"Recorded check: {expected or text} ({text!r})")