@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.
- package/HOW_TO_USE.md +1639 -0
- package/README.md +338 -2
- package/bin/forgeqe.d.ts +3 -0
- package/bin/forgeqe.js +188 -0
- package/docs/ARCHITECTURE.md +408 -0
- package/docs/security-data-flow.html +1870 -0
- package/docs/user-guide.html +2094 -0
- package/evals/domain/access-role-change.yaml +37 -0
- package/evals/domain/banking-payment-transfer.yaml +41 -0
- package/evals/domain/gdpr-account-erasure.yaml +30 -0
- package/evals/domain/insurance-claim-fnol.yaml +30 -0
- package/evals/domain/insurance-policy-renewal.yaml +37 -0
- package/evals/domain/lending-loan-application.yaml +36 -0
- package/forgeqe-architecture.html +718 -0
- package/forgeqe.example.yaml +203 -0
- package/knowledge/README.md +18 -0
- package/knowledge/business-rules.md +10 -0
- package/knowledge/compliance.md +16 -0
- package/knowledge/environments.md +5 -0
- package/knowledge/glossary.md +5 -0
- package/knowledge/product.md +16 -0
- package/knowledge/roles.md +9 -0
- package/knowledge/test-data.md +7 -0
- package/llms.example.json +51 -0
- package/package.json +63 -4
- package/prompts/README.md +34 -0
- package/prompts/api-scenarios.md +21 -0
- package/prompts/api-tests.md +39 -0
- package/prompts/brain.md +166 -0
- package/prompts/browser-capture.md +7 -0
- package/prompts/browser-case-report.md +7 -0
- package/prompts/browser-checks.md +14 -0
- package/prompts/browser-efficiency.md +7 -0
- package/prompts/browser-error-recovery.md +15 -0
- package/prompts/browser-extra.md +5 -0
- package/prompts/browser-goal-verification.md +13 -0
- package/prompts/browser-skill-select.md +6 -0
- package/prompts/domain-rule.md +13 -0
- package/prompts/eval-judge.md +19 -0
- package/prompts/explore-charters.md +27 -0
- package/prompts/find-tests.md +28 -0
- package/prompts/heal.md +40 -0
- package/prompts/pr-review.md +29 -0
- package/prompts/skill-rule.md +6 -0
- package/prompts/test-agent.md +34 -0
- package/prompts/test-reviewer.md +30 -0
- package/prompts/test-rules.md +28 -0
- package/py/forgeqe_engine/__init__.py +3 -0
- package/py/forgeqe_engine/__main__.py +3 -0
- package/py/forgeqe_engine/brand.py +453 -0
- package/py/forgeqe_engine/brand_assets.py +6 -0
- package/py/forgeqe_engine/browser.py +196 -0
- package/py/forgeqe_engine/capture.py +538 -0
- package/py/forgeqe_engine/checks.py +98 -0
- package/py/forgeqe_engine/cli.py +188 -0
- package/py/forgeqe_engine/diagnostics.py +609 -0
- package/py/forgeqe_engine/llm.py +195 -0
- package/py/forgeqe_engine/logins.py +251 -0
- package/py/forgeqe_engine/loops.py +224 -0
- package/py/forgeqe_engine/memory.py +1004 -0
- package/py/forgeqe_engine/network.py +244 -0
- package/py/forgeqe_engine/prompts.py +20 -0
- package/py/forgeqe_engine/replay.py +240 -0
- package/py/forgeqe_engine/report.py +173 -0
- package/py/forgeqe_engine/service.py +1335 -0
- package/py/forgeqe_engine/settings.py +93 -0
- package/py/forgeqe_engine/skills.py +88 -0
- package/py/forgeqe_engine/summary.py +239 -0
- package/py/forgeqe_engine/urls.py +81 -0
- package/py/forgeqe_engine/workflow_yaml.py +689 -0
- package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
- package/skills/dynamics-365/SKILL.md +21 -0
- package/skills/playwright-typescript/SKILL.md +35 -0
- package/skills/writing-skills/SKILL.md +30 -0
- package/src/api/plan.ts +246 -0
- package/src/api/run.ts +204 -0
- package/src/api/scenario.ts +502 -0
- package/src/api/schema.ts +170 -0
- package/src/api/spec.ts +624 -0
- package/src/api/store.ts +232 -0
- package/src/api/traffic.ts +281 -0
- package/src/api/types.ts +163 -0
- package/src/api/write.ts +272 -0
- package/src/auth/signin.ts +494 -0
- package/src/auth/store.ts +170 -0
- package/src/brain/index.ts +189 -0
- package/src/brain/prompt.ts +19 -0
- package/src/capabilities/docs.ts +101 -0
- package/src/capabilities/guide.ts +455 -0
- package/src/capabilities/registry.ts +343 -0
- package/src/capabilities/render.ts +106 -0
- package/src/capabilities/status.ts +137 -0
- package/src/card.ts +11 -0
- package/src/cases/ado.ts +325 -0
- package/src/cases/api.ts +117 -0
- package/src/cases/bugs.ts +223 -0
- package/src/cases/confluence.ts +111 -0
- package/src/cases/context.ts +223 -0
- package/src/cases/dashboards.ts +137 -0
- package/src/cases/failure.ts +97 -0
- package/src/cases/files.ts +295 -0
- package/src/cases/goal.ts +53 -0
- package/src/cases/http.ts +111 -0
- package/src/cases/index.ts +131 -0
- package/src/cases/jira.ts +122 -0
- package/src/cases/qtest.ts +108 -0
- package/src/cases/quality.ts +190 -0
- package/src/cases/rag.ts +100 -0
- package/src/cases/readiness.ts +284 -0
- package/src/cases/save.ts +149 -0
- package/src/cases/sprints.ts +148 -0
- package/src/cases/testrail.ts +176 -0
- package/src/cases/types.ts +107 -0
- package/src/cases/wiki.ts +105 -0
- package/src/cases/xray.ts +206 -0
- package/src/cases/zephyr.ts +153 -0
- package/src/childEnv.ts +86 -0
- package/src/cli/args.ts +114 -0
- package/src/cli/help.ts +167 -0
- package/src/cli/index.ts +1142 -0
- package/src/cli/terminal.ts +187 -0
- package/src/code/areas.ts +337 -0
- package/src/code/attention.ts +214 -0
- package/src/code/find.ts +540 -0
- package/src/code/handoff.ts +541 -0
- package/src/code/history.ts +170 -0
- package/src/code/impact.ts +231 -0
- package/src/code/index.ts +34 -0
- package/src/code/livebrowser.ts +67 -0
- package/src/code/loop.ts +603 -0
- package/src/code/notes.ts +75 -0
- package/src/code/profile.ts +226 -0
- package/src/code/prompt.ts +303 -0
- package/src/code/prreview.ts +649 -0
- package/src/code/prtests.ts +103 -0
- package/src/code/replay.ts +772 -0
- package/src/code/review.ts +202 -0
- package/src/code/runtime.ts +757 -0
- package/src/code/slices.ts +159 -0
- package/src/code/testrun.ts +187 -0
- package/src/code/traceability.ts +100 -0
- package/src/code/worktree.ts +153 -0
- package/src/commands.ts +101 -0
- package/src/config.ts +970 -0
- package/src/detail.ts +65 -0
- package/src/domain.ts +19 -0
- package/src/engine/bridge.ts +359 -0
- package/src/engine/mock.ts +102 -0
- package/src/engine/setup.ts +298 -0
- package/src/entra.ts +154 -0
- package/src/env.ts +24 -0
- package/src/evals/domain.ts +338 -0
- package/src/explore/charters.ts +151 -0
- package/src/explore/run.ts +280 -0
- package/src/explore/story.ts +106 -0
- package/src/guard.ts +51 -0
- package/src/heal/loop.ts +357 -0
- package/src/heal/prompt.ts +143 -0
- package/src/heal/report.ts +95 -0
- package/src/index.ts +12 -0
- package/src/init.ts +102 -0
- package/src/knowledge.ts +114 -0
- package/src/legacyEnv.ts +9 -0
- package/src/llm.ts +61 -0
- package/src/locale.ts +25 -0
- package/src/logins.ts +148 -0
- package/src/managed.ts +101 -0
- package/src/mcp/apitools.ts +335 -0
- package/src/mcp/captools.ts +25 -0
- package/src/mcp/casetools.ts +223 -0
- package/src/mcp/commands.ts +421 -0
- package/src/mcp/exploretools.ts +129 -0
- package/src/mcp/external.ts +172 -0
- package/src/mcp/git.ts +78 -0
- package/src/mcp/notifytools.ts +49 -0
- package/src/mcp/prtools.ts +261 -0
- package/src/mcp/qualitytools.ts +154 -0
- package/src/mcp/repotests.ts +286 -0
- package/src/mcp/server.ts +234 -0
- package/src/mcp/tools.ts +810 -0
- package/src/memory.ts +250 -0
- package/src/money.ts +39 -0
- package/src/monitor/alerts.ts +94 -0
- package/src/monitor/background.ts +68 -0
- package/src/monitor/run.ts +263 -0
- package/src/monitor/state.ts +105 -0
- package/src/notify/events.ts +144 -0
- package/src/notify/notice.ts +177 -0
- package/src/prices.ts +175 -0
- package/src/print.ts +54 -0
- package/src/privacy.ts +35 -0
- package/src/prompts.ts +165 -0
- package/src/quality/budgets.ts +249 -0
- package/src/quality/history.ts +149 -0
- package/src/quality/visual.ts +201 -0
- package/src/report/data.ts +475 -0
- package/src/report/html.ts +106 -0
- package/src/report/template.html +84 -0
- package/src/run.ts +243 -0
- package/src/sessionLog.ts +216 -0
- package/src/sessionLogins.ts +94 -0
- package/src/skills/registry.ts +217 -0
- package/src/skills/scaffold.ts +65 -0
- package/src/skills/select.ts +37 -0
- package/src/skills/view.ts +44 -0
- package/src/suite/report.ts +135 -0
- package/src/suite/run.ts +303 -0
- package/src/summary.ts +188 -0
- package/src/tools.ts +130 -0
- package/src/tui/App.tsx +983 -0
- package/src/tui/clipboard.ts +36 -0
- package/src/tui/components.tsx +263 -0
- package/src/tui/dialogs.ts +65 -0
- package/src/tui/main.tsx +20 -0
- package/src/tui/text.ts +28 -0
- package/src/tui/theme.ts +104 -0
- package/src/tui/transcript.tsx +344 -0
- package/src/types.ts +136 -0
- package/src/update.ts +311 -0
- 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})")
|