@capagents/forgeqe 0.0.0-stage → 1.0.1
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 +781 -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 +146 -0
- package/src/update.ts +311 -0
- package/src/usage.ts +158 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Page memory: what ForgeQE learned on earlier runs, used to act faster and call the LLM less.
|
|
3
|
+
|
|
4
|
+
Layout under the memory directory:
|
|
5
|
+
|
|
6
|
+
pages/<host>/<page>.json every interactive element seen on that page, each with ranked locators
|
|
7
|
+
flows/<goal>-<hash>.json the actions of the last successful run of a goal, replayable without the LLM
|
|
8
|
+
|
|
9
|
+
A locator's confidence (1-99 %) multiplies four factors: how stable its kind is (test id > id >
|
|
10
|
+
role+name > ... > xpath), how unique it was on the page, how consistently it reappeared on later
|
|
11
|
+
visits, and how often acting through it worked during replay.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from collections import Counter
|
|
20
|
+
from contextlib import contextmanager
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, Iterable, Iterator, List, Optional, Set, Tuple
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
from .logins import secret_ref, text_envs
|
|
27
|
+
|
|
28
|
+
VERSION = 1
|
|
29
|
+
MAX_ELEMENTS_PER_PAGE = 400
|
|
30
|
+
MAX_LOCATORS_PER_ELEMENT = 10
|
|
31
|
+
MAX_SAMPLE_URLS = 5
|
|
32
|
+
MAX_FLOW_STEPS = 80
|
|
33
|
+
FLOW_STEP_LOCATORS = 6
|
|
34
|
+
|
|
35
|
+
ROLES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"button", "link", "textbox", "searchbox", "combobox", "checkbox", "radio", "switch", "option",
|
|
38
|
+
"menuitem", "menuitemcheckbox", "menuitemradio", "tab", "listbox", "slider", "spinbutton",
|
|
39
|
+
"heading", "img", "gridcell", "cell", "row", "columnheader", "treeitem", "dialog",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
TEST_ID_ATTRS = ("data-testid", "data-test-id", "data-test", "data-qa", "data-cy")
|
|
43
|
+
KEPT_ATTRS = ("id", "name", "type", "placeholder", "aria-label", "title", "alt", "href", "role", *TEST_ID_ATTRS)
|
|
44
|
+
FORM_TAGS = frozenset({"input", "textarea", "select"})
|
|
45
|
+
TEXT_TAGS = frozenset({"a", "button", "label", "li", "span", "div", "td", "th", "summary", "option", "p"})
|
|
46
|
+
|
|
47
|
+
# Stability of each locator kind before page evidence is applied.
|
|
48
|
+
BASE = {
|
|
49
|
+
"test_id": 0.96,
|
|
50
|
+
"id": 0.90,
|
|
51
|
+
"role": 0.88,
|
|
52
|
+
"name": 0.86,
|
|
53
|
+
"label": 0.84,
|
|
54
|
+
"placeholder": 0.82,
|
|
55
|
+
"alt": 0.78,
|
|
56
|
+
"href": 0.74,
|
|
57
|
+
"title": 0.72,
|
|
58
|
+
"text": 0.70,
|
|
59
|
+
"css": 0.50,
|
|
60
|
+
"xpath": 0.30,
|
|
61
|
+
}
|
|
62
|
+
# Kinds precise enough to recognise the same element on a later visit; xpath is the last resort.
|
|
63
|
+
STRONG = ("test_id", "id", "name", "role", "label", "placeholder", "href")
|
|
64
|
+
INDEXED = (*STRONG, "xpath")
|
|
65
|
+
GENERATED_FACTOR = 0.4
|
|
66
|
+
|
|
67
|
+
_GENERATED = (
|
|
68
|
+
re.compile(r"\d{4,}"),
|
|
69
|
+
re.compile(r"(?=[0-9a-f]*\d)(?=[0-9a-f]*[a-f])[0-9a-f]{8,}", re.I),
|
|
70
|
+
re.compile(r":[rR][0-9a-zA-Z]*:"),
|
|
71
|
+
re.compile(r"^(ember|ext-gen|mui-|react-select-|headlessui-|radix-|yui_|j_id|__)", re.I),
|
|
72
|
+
# CSS-in-JS: emotion (css-1q2w3e), styled-components (Button-sc-1x2y3z-0, sc-aXZVg), jsx-123
|
|
73
|
+
re.compile(r"^(css|jsx|sc)-[a-z0-9]{5,}$", re.I),
|
|
74
|
+
re.compile(r"-sc-[a-z0-9]{5,}", re.I),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Actions that only read the page; replay skips them and flows do not record them.
|
|
78
|
+
READ_ONLY_ACTIONS = frozenset(
|
|
79
|
+
{"extract", "find_text", "search_page", "find_elements", "dropdown_options", "screenshot", "read_file", "write_file", "replace_file", "done", "record_check"}
|
|
80
|
+
)
|
|
81
|
+
ELEMENT_ACTIONS = frozenset({"click", "input", "select_dropdown", "scroll", "upload_file"})
|
|
82
|
+
REPLAYABLE_ACTIONS = frozenset({"navigate", "search", "go_back", "wait", "click", "input", "select_dropdown", "scroll", "send_keys"})
|
|
83
|
+
SECRET_ENV_VARS = ("BROWSER_USE_DEFAULT_LOGIN_PASSWORD", "BROWSER_USE_DEFAULT_LOGIN_USERNAME")
|
|
84
|
+
|
|
85
|
+
LocKey = Tuple[str, str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def now_iso() -> str:
|
|
89
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _q(value: str) -> str:
|
|
93
|
+
return json.dumps(value, ensure_ascii=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _clean(text: Any, limit: int = 120) -> str:
|
|
97
|
+
return re.sub(r"\s+", " ", str(text or "")).strip()[:limit]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _sha(text: str, n: int) -> str:
|
|
101
|
+
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:n]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def looks_generated(value: str) -> bool:
|
|
105
|
+
"""Framework-generated ids and hashes change between builds or page loads."""
|
|
106
|
+
return any(p.search(value or "") for p in _GENERATED)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _css_ident(value: str) -> bool:
|
|
110
|
+
return bool(re.fullmatch(r"[A-Za-z_][\w-]*", value or ""))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------------------------
|
|
114
|
+
# Pages
|
|
115
|
+
# ---------------------------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _template_segment(seg: str) -> str:
|
|
119
|
+
if seg.isdigit() or (looks_generated(seg) and not re.search(r"\.[a-z]{2,5}$", seg)):
|
|
120
|
+
return "*"
|
|
121
|
+
if len(seg) >= 24 and re.search(r"\d", seg):
|
|
122
|
+
return "*"
|
|
123
|
+
return seg
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def page_ref(url: str) -> Optional[Tuple[str, str]]:
|
|
127
|
+
"""(host, path template) for a page URL. Ids in the path become `*`, the query string is dropped."""
|
|
128
|
+
try:
|
|
129
|
+
p = urlparse(url or "")
|
|
130
|
+
except Exception:
|
|
131
|
+
return None
|
|
132
|
+
if p.scheme not in ("http", "https") or not p.hostname:
|
|
133
|
+
return None
|
|
134
|
+
host = p.hostname.lower()
|
|
135
|
+
if p.port and p.port not in (80, 443):
|
|
136
|
+
host = f"{host}:{p.port}"
|
|
137
|
+
path = "/" + "/".join(_template_segment(s) for s in p.path.split("/") if s)
|
|
138
|
+
frag = p.fragment or ""
|
|
139
|
+
if frag.startswith("/") or frag.startswith("!/"):
|
|
140
|
+
path += "#" + "/".join(_template_segment(s) for s in frag.split("?")[0].split("/"))
|
|
141
|
+
return host, path
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def page_id(url: str) -> Optional[str]:
|
|
145
|
+
ref = page_ref(url)
|
|
146
|
+
return f"{ref[0]}{ref[1]}" if ref else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _page_slug(path: str) -> str:
|
|
150
|
+
if path == "/":
|
|
151
|
+
return "index"
|
|
152
|
+
return f"{re.sub(r'[^A-Za-z0-9]+', '_', path).strip('_')[:60] or 'page'}-{_sha(path, 8)}"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _host_dir(host: str) -> str:
|
|
156
|
+
return re.sub(r"[^A-Za-z0-9.-]+", "_", host)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------------------------
|
|
160
|
+
# Elements and locators
|
|
161
|
+
# ---------------------------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def element_facts(node: Any) -> Dict[str, Any]:
|
|
165
|
+
"""Identity-relevant facts of a library DOM node (EnhancedDOMTreeNode)."""
|
|
166
|
+
raw_attrs = getattr(node, "attributes", None) or {}
|
|
167
|
+
attrs = {k: str(v) for k, v in raw_attrs.items() if k in KEPT_ATTRS and v not in (None, "")}
|
|
168
|
+
ax = getattr(node, "ax_node", None)
|
|
169
|
+
tag = str(getattr(node, "node_name", "") or "").lower()
|
|
170
|
+
role = _clean(attrs.get("role") or getattr(ax, "role", None) or "", 40)
|
|
171
|
+
name = _clean(getattr(ax, "name", None) or "")
|
|
172
|
+
text = ""
|
|
173
|
+
if not name and tag in TEXT_TAGS and hasattr(node, "get_all_children_text"):
|
|
174
|
+
try:
|
|
175
|
+
text = _clean(node.get_all_children_text(max_depth=4))
|
|
176
|
+
except Exception:
|
|
177
|
+
text = ""
|
|
178
|
+
try:
|
|
179
|
+
xpath = str(node.xpath or "")
|
|
180
|
+
except Exception:
|
|
181
|
+
xpath = ""
|
|
182
|
+
try:
|
|
183
|
+
stable_hash = node.compute_stable_hash()
|
|
184
|
+
except Exception:
|
|
185
|
+
stable_hash = None
|
|
186
|
+
classes: List[str] = []
|
|
187
|
+
cls = str(raw_attrs.get("class") or "")
|
|
188
|
+
if cls:
|
|
189
|
+
try:
|
|
190
|
+
from browser_use.dom.views import filter_dynamic_classes
|
|
191
|
+
|
|
192
|
+
cls = filter_dynamic_classes(cls)
|
|
193
|
+
except Exception:
|
|
194
|
+
pass
|
|
195
|
+
classes = [c for c in cls.split() if _css_ident(c) and not looks_generated(c)]
|
|
196
|
+
return {
|
|
197
|
+
"tag": tag,
|
|
198
|
+
"role": role,
|
|
199
|
+
"name": name,
|
|
200
|
+
"text": text,
|
|
201
|
+
"attributes": attrs,
|
|
202
|
+
"classes": classes[:4],
|
|
203
|
+
"xpath": xpath,
|
|
204
|
+
"stable_hash": stable_hash,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def candidate_locators(f: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
209
|
+
"""Every locator we can build for one element: semantic first, css and xpath last."""
|
|
210
|
+
out: List[Dict[str, Any]] = []
|
|
211
|
+
a: Dict[str, str] = f.get("attributes") or {}
|
|
212
|
+
tag = f.get("tag") or "*"
|
|
213
|
+
role, name = f.get("role") or "", f.get("name") or ""
|
|
214
|
+
|
|
215
|
+
def add(kind: str, value: str, playwright: str, **extra: Any) -> None:
|
|
216
|
+
if not value:
|
|
217
|
+
return
|
|
218
|
+
base = BASE[kind]
|
|
219
|
+
generated = kind in ("test_id", "id", "name", "href", "css") and looks_generated(value)
|
|
220
|
+
if generated:
|
|
221
|
+
base *= GENERATED_FACTOR
|
|
222
|
+
loc = {"type": kind, "value": value, "playwright": playwright, "base": round(base, 3), **extra}
|
|
223
|
+
if generated:
|
|
224
|
+
loc["generated"] = True
|
|
225
|
+
out.append(loc)
|
|
226
|
+
|
|
227
|
+
tid_attr = next((k for k in TEST_ID_ATTRS if a.get(k)), None)
|
|
228
|
+
if tid_attr:
|
|
229
|
+
v = a[tid_attr]
|
|
230
|
+
# An attribute selector, not getByTestId: a repository can point getByTestId at another attribute (data-test, data-qa).
|
|
231
|
+
css = f"[{tid_attr}={_q(v)}]"
|
|
232
|
+
add("test_id", v, f"page.locator({_q(css)})", attr=tid_attr, css=css)
|
|
233
|
+
if a.get("id"):
|
|
234
|
+
v = a["id"]
|
|
235
|
+
css = f"#{v}" if _css_ident(v) else f"[id={_q(v)}]"
|
|
236
|
+
add("id", v, f"page.locator({_q(css)})", css=css)
|
|
237
|
+
if role in ROLES and name and len(name) <= 80:
|
|
238
|
+
add("role", f"{role}|{name}", f"page.getByRole({_q(role)}, {{ name: {_q(name)}, exact: true }})", role=role, name=name)
|
|
239
|
+
if a.get("name"):
|
|
240
|
+
css = f"{tag}[name={_q(a['name'])}]"
|
|
241
|
+
add("name", a["name"], f"page.locator({_q(css)})", css=css)
|
|
242
|
+
if tag in FORM_TAGS:
|
|
243
|
+
label = a.get("aria-label") or (name if name and name not in (a.get("placeholder"), a.get("title")) else "")
|
|
244
|
+
if label and len(label) <= 80:
|
|
245
|
+
add("label", label, f"page.getByLabel({_q(label)}, {{ exact: true }})")
|
|
246
|
+
if a.get("placeholder"):
|
|
247
|
+
add("placeholder", a["placeholder"], f"page.getByPlaceholder({_q(a['placeholder'])}, {{ exact: true }})")
|
|
248
|
+
elif a.get("aria-label") and len(a["aria-label"]) <= 80 and not (role in ROLES and name == a["aria-label"]):
|
|
249
|
+
add("label", a["aria-label"], f"page.getByLabel({_q(a['aria-label'])}, {{ exact: true }})")
|
|
250
|
+
if a.get("alt"):
|
|
251
|
+
add("alt", a["alt"], f"page.getByAltText({_q(a['alt'])}, {{ exact: true }})")
|
|
252
|
+
if a.get("title"):
|
|
253
|
+
add("title", a["title"], f"page.getByTitle({_q(a['title'])}, {{ exact: true }})")
|
|
254
|
+
href = a.get("href") or ""
|
|
255
|
+
if tag == "a" and href and not href.startswith(("javascript:", "#")) and len(href) <= 200:
|
|
256
|
+
css = f"a[href={_q(href)}]"
|
|
257
|
+
add("href", href, f"page.locator({_q(css)})", css=css)
|
|
258
|
+
text = f.get("text") or (name if tag not in FORM_TAGS else "")
|
|
259
|
+
if text and len(text) <= 60 and tag not in FORM_TAGS:
|
|
260
|
+
add("text", text, f"page.getByText({_q(text)}, {{ exact: true }})")
|
|
261
|
+
classes = f.get("classes") or []
|
|
262
|
+
if classes:
|
|
263
|
+
css = tag + "".join(f".{c}" for c in classes[:2])
|
|
264
|
+
add("css", css, f"page.locator({_q(css)})", css=css)
|
|
265
|
+
if f.get("xpath"):
|
|
266
|
+
xp = "/" + str(f["xpath"]).lstrip("/")
|
|
267
|
+
add("xpath", xp, f"page.locator({_q('xpath=' + xp)})")
|
|
268
|
+
return out
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def confidence(loc: Dict[str, Any]) -> int:
|
|
272
|
+
"""1-99: kind stability × uniqueness on the page × consistency across visits × replay success."""
|
|
273
|
+
matches = int(loc.get("matches") or 1)
|
|
274
|
+
unique = 1.0 if matches <= 1 else 0.5 / (matches - 1)
|
|
275
|
+
seen = max(1, int(loc.get("seen") or 1))
|
|
276
|
+
missed = int(loc.get("missed") or 0)
|
|
277
|
+
stability = (0.75 + 0.25 * min(seen, 4) / 4) * (seen / (seen + missed))
|
|
278
|
+
ok, bad = int(loc.get("success") or 0), int(loc.get("failure") or 0)
|
|
279
|
+
reliability = (ok + 1) / (ok + bad + 1)
|
|
280
|
+
return max(1, min(99, round(100 * float(loc.get("base") or 0.3) * unique * stability * reliability)))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def rank(locs: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
284
|
+
ranked = []
|
|
285
|
+
for loc in locs:
|
|
286
|
+
loc["confidence"] = confidence(loc)
|
|
287
|
+
ranked.append(loc)
|
|
288
|
+
ranked.sort(key=lambda l: (-l["confidence"], -BASE.get(l["type"], 0)))
|
|
289
|
+
return ranked[:MAX_LOCATORS_PER_ELEMENT]
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _public_locator(loc: Dict[str, Any]) -> Dict[str, Any]:
|
|
293
|
+
keep = ("type", "value", "playwright", "confidence", "verified", "role", "name", "css", "attr")
|
|
294
|
+
return {k: loc[k] for k in keep if k in loc}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def element_label(facts: Dict[str, Any]) -> str:
|
|
298
|
+
a = facts.get("attributes") or {}
|
|
299
|
+
return facts.get("name") or facts.get("text") or a.get("placeholder") or a.get("aria-label") or facts.get("tag") or ""
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------------------------
|
|
303
|
+
# Store
|
|
304
|
+
# ---------------------------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def goal_key(goal: str, start_url: Optional[str]) -> str:
|
|
308
|
+
norm = re.sub(r"\s+", " ", (goal or "").strip().lower()).rstrip(" .!")
|
|
309
|
+
return f"{norm}|{(start_url or '').strip().rstrip('/')}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class MemoryStore:
|
|
313
|
+
"""Loads page files lazily, updates them in memory during a run, and writes them on flush()."""
|
|
314
|
+
|
|
315
|
+
def __init__(self, root: str | Path, run_id: Optional[str] = None) -> None:
|
|
316
|
+
self.root = Path(root).expanduser()
|
|
317
|
+
self.run_id = run_id or datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")
|
|
318
|
+
self._pages: Dict[str, Dict[str, Any]] = {}
|
|
319
|
+
self._files: Dict[str, Path] = {}
|
|
320
|
+
self._index: Dict[str, Dict[LocKey, str]] = {}
|
|
321
|
+
self._loaded: Dict[str, Any] = {}
|
|
322
|
+
self._base: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
|
323
|
+
self._base_visits: Dict[str, int] = {}
|
|
324
|
+
self._dirty: Set[str] = set()
|
|
325
|
+
self.touched_pages: Set[str] = set()
|
|
326
|
+
self.touched_elements: Set[Tuple[str, str]] = set()
|
|
327
|
+
self.new_elements = 0
|
|
328
|
+
|
|
329
|
+
# -- pages ------------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
def _page(self, host: str, path: str) -> Tuple[str, Dict[str, Any]]:
|
|
332
|
+
pid = f"{host}{path}"
|
|
333
|
+
if pid in self._pages:
|
|
334
|
+
return pid, self._pages[pid]
|
|
335
|
+
file = self.root / "pages" / _host_dir(host) / f"{_page_slug(path)}.json"
|
|
336
|
+
page: Optional[Dict[str, Any]] = None
|
|
337
|
+
if file.is_file():
|
|
338
|
+
try:
|
|
339
|
+
page = json.loads(file.read_text(encoding="utf-8"))
|
|
340
|
+
except Exception:
|
|
341
|
+
page = None
|
|
342
|
+
if not isinstance(page, dict) or not isinstance(page.get("elements"), dict):
|
|
343
|
+
now = now_iso()
|
|
344
|
+
page = {
|
|
345
|
+
"version": VERSION, "host": host, "path": path, "title": "", "first_seen": now, "last_seen": now,
|
|
346
|
+
"visits": 0, "sample_urls": [], "elements": {},
|
|
347
|
+
}
|
|
348
|
+
self._pages[pid] = page
|
|
349
|
+
self._files[pid] = file
|
|
350
|
+
self._loaded[pid] = int(page.get("revision") or 0)
|
|
351
|
+
self._base[pid] = {key: _counts(entry) for key, entry in page["elements"].items()}
|
|
352
|
+
self._base_visits[pid] = int(page.get("visits") or 0)
|
|
353
|
+
index: Dict[LocKey, str] = {}
|
|
354
|
+
for key, entry in page["elements"].items():
|
|
355
|
+
for loc in entry.get("locators") or []:
|
|
356
|
+
if loc.get("type") in INDEXED:
|
|
357
|
+
index.setdefault((loc["type"], loc["value"]), key)
|
|
358
|
+
self._index[pid] = index
|
|
359
|
+
return pid, page
|
|
360
|
+
|
|
361
|
+
def page_for_url(self, url: str) -> Optional[Dict[str, Any]]:
|
|
362
|
+
ref = page_ref(url)
|
|
363
|
+
return self._page(*ref)[1] if ref else None
|
|
364
|
+
|
|
365
|
+
def observe(self, url: str, title: str, selector_map: Dict[int, Any]) -> Dict[int, Dict[str, Any]]:
|
|
366
|
+
"""Record every element in a library selector map. Returns index → {key, page, facts, locators, current}."""
|
|
367
|
+
ref = page_ref(url)
|
|
368
|
+
if not ref or not selector_map:
|
|
369
|
+
return {}
|
|
370
|
+
pid, page = self._page(*ref)
|
|
371
|
+
now = now_iso()
|
|
372
|
+
if page.get("last_run") != self.run_id:
|
|
373
|
+
page["visits"] = int(page.get("visits") or 0) + 1
|
|
374
|
+
page["last_run"] = self.run_id
|
|
375
|
+
page["last_seen"] = now
|
|
376
|
+
if title and title != ref[0] and not title.startswith(("http:", "https:")):
|
|
377
|
+
page["title"] = _clean(title, 200)
|
|
378
|
+
clean_url = (url or "").split("#")[0] if "#/" not in (url or "") else url
|
|
379
|
+
samples = [u for u in page.get("sample_urls") or [] if u != clean_url]
|
|
380
|
+
page["sample_urls"] = (samples + [clean_url])[-MAX_SAMPLE_URLS:]
|
|
381
|
+
|
|
382
|
+
snapshot: List[Tuple[int, Dict[str, Any], List[Dict[str, Any]]]] = []
|
|
383
|
+
for idx, node in selector_map.items():
|
|
384
|
+
try:
|
|
385
|
+
facts = element_facts(node)
|
|
386
|
+
except Exception:
|
|
387
|
+
continue
|
|
388
|
+
snapshot.append((int(idx), facts, candidate_locators(facts)))
|
|
389
|
+
counts = Counter((l["type"], l["value"]) for _, _, locs in snapshot for l in locs)
|
|
390
|
+
|
|
391
|
+
out: Dict[int, Dict[str, Any]] = {}
|
|
392
|
+
used: Set[str] = set()
|
|
393
|
+
for idx, facts, locs in snapshot:
|
|
394
|
+
for loc in locs:
|
|
395
|
+
loc["matches"] = counts[(loc["type"], loc["value"])]
|
|
396
|
+
key = self._identify(pid, page, facts, locs, used)
|
|
397
|
+
used.add(key)
|
|
398
|
+
entry = self._merge(pid, page, key, facts, locs, now)
|
|
399
|
+
out[idx] = {
|
|
400
|
+
"key": key,
|
|
401
|
+
"page": pid,
|
|
402
|
+
"facts": facts,
|
|
403
|
+
"locators": entry["locators"],
|
|
404
|
+
"current": {(l["type"], l["value"]): l["matches"] for l in locs},
|
|
405
|
+
}
|
|
406
|
+
self.touched_elements.add((pid, key))
|
|
407
|
+
self._prune(page)
|
|
408
|
+
self._dirty.add(pid)
|
|
409
|
+
self.touched_pages.add(pid)
|
|
410
|
+
return out
|
|
411
|
+
|
|
412
|
+
def _identify(self, pid: str, page: Dict[str, Any], facts: Dict[str, Any], locs: List[Dict[str, Any]], used: Set[str]) -> str:
|
|
413
|
+
elements = page["elements"]
|
|
414
|
+
strong = [l for l in locs if l["type"] in STRONG and l["matches"] == 1 and not l.get("generated")]
|
|
415
|
+
index = self._index[pid]
|
|
416
|
+
for loc in strong:
|
|
417
|
+
key = index.get((loc["type"], loc["value"]))
|
|
418
|
+
if key and key not in used and key in elements and elements[key].get("tag") == facts["tag"]:
|
|
419
|
+
return key
|
|
420
|
+
# Sites reuse the same popup container for different widgets, so a matching xpath only
|
|
421
|
+
# identifies the element when its role and accessible name or text agree too.
|
|
422
|
+
label = facts["name"] or facts["text"] or ""
|
|
423
|
+
for loc in (l for l in locs if l["type"] == "xpath"):
|
|
424
|
+
key = index.get((loc["type"], loc["value"]))
|
|
425
|
+
entry = elements.get(key) if key and key not in used else None
|
|
426
|
+
if (
|
|
427
|
+
entry
|
|
428
|
+
and entry.get("tag") == facts["tag"]
|
|
429
|
+
and (entry.get("role") or "") == (facts["role"] or "")
|
|
430
|
+
and (entry.get("name") or entry.get("text") or "") == label
|
|
431
|
+
):
|
|
432
|
+
return key
|
|
433
|
+
if strong:
|
|
434
|
+
basis = f"{facts['tag']}|{strong[0]['type']}={strong[0]['value']}"
|
|
435
|
+
else:
|
|
436
|
+
basis = f"{facts['tag']}|xpath={facts['xpath']}|{facts['name'] or facts['text']}"
|
|
437
|
+
key = _sha(basis, 12)
|
|
438
|
+
if key in used:
|
|
439
|
+
key = _sha(f"{basis}|xpath={facts['xpath']}", 12)
|
|
440
|
+
return key
|
|
441
|
+
|
|
442
|
+
def _merge(self, pid: str, page: Dict[str, Any], key: str, facts: Dict[str, Any], locs: List[Dict[str, Any]], now: str) -> Dict[str, Any]:
|
|
443
|
+
elements = page["elements"]
|
|
444
|
+
entry = elements.get(key)
|
|
445
|
+
if entry is None:
|
|
446
|
+
entry = {"key": key, "first_seen": now, "visits": 0, "interactions": 0, "success": 0, "failure": 0, "locators": []}
|
|
447
|
+
elements[key] = entry
|
|
448
|
+
self.new_elements += 1
|
|
449
|
+
first_this_run = entry.get("last_run") != self.run_id
|
|
450
|
+
stored: Dict[LocKey, Dict[str, Any]] = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
|
|
451
|
+
changed = False
|
|
452
|
+
current: Set[LocKey] = set()
|
|
453
|
+
for loc in locs:
|
|
454
|
+
k = (loc["type"], loc["value"])
|
|
455
|
+
current.add(k)
|
|
456
|
+
s = stored.get(k)
|
|
457
|
+
if s is None:
|
|
458
|
+
s = {kk: vv for kk, vv in loc.items()}
|
|
459
|
+
s.update(seen=1, missed=0, success=0, failure=0, first_seen=now)
|
|
460
|
+
stored[k] = s
|
|
461
|
+
changed = True
|
|
462
|
+
else:
|
|
463
|
+
if first_this_run:
|
|
464
|
+
s["seen"] = int(s.get("seen") or 0) + 1
|
|
465
|
+
s.update(matches=loc["matches"], base=loc["base"], playwright=loc["playwright"])
|
|
466
|
+
s["last_seen"] = now
|
|
467
|
+
if first_this_run:
|
|
468
|
+
for k, s in stored.items():
|
|
469
|
+
if k not in current:
|
|
470
|
+
s["missed"] = int(s.get("missed") or 0) + 1
|
|
471
|
+
changed = True
|
|
472
|
+
entry["visits"] = int(entry.get("visits") or 0) + 1
|
|
473
|
+
entry.update(
|
|
474
|
+
tag=facts["tag"],
|
|
475
|
+
role=facts["role"] or None,
|
|
476
|
+
name=facts["name"] or None,
|
|
477
|
+
text=facts["text"] or None,
|
|
478
|
+
attributes=facts["attributes"],
|
|
479
|
+
xpath=facts["xpath"],
|
|
480
|
+
stable_hash=facts["stable_hash"],
|
|
481
|
+
last_seen=now,
|
|
482
|
+
last_run=self.run_id,
|
|
483
|
+
)
|
|
484
|
+
entry["locators"] = list(stored.values())
|
|
485
|
+
self._rerank(entry, now if changed or "last_updated" not in entry else None)
|
|
486
|
+
index = self._index[pid]
|
|
487
|
+
for loc in entry["locators"]:
|
|
488
|
+
if loc["type"] in INDEXED:
|
|
489
|
+
index.setdefault((loc["type"], loc["value"]), key)
|
|
490
|
+
return entry
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def _rerank(entry: Dict[str, Any], updated: Optional[str]) -> None:
|
|
494
|
+
entry["locators"] = rank(entry.get("locators") or [])
|
|
495
|
+
top = entry["locators"][0] if entry["locators"] else None
|
|
496
|
+
entry["best"] = top["playwright"] if top else None
|
|
497
|
+
entry["confidence"] = top["confidence"] if top else 0
|
|
498
|
+
if updated:
|
|
499
|
+
entry["last_updated"] = updated
|
|
500
|
+
|
|
501
|
+
@staticmethod
|
|
502
|
+
def _prune(page: Dict[str, Any]) -> None:
|
|
503
|
+
elements = page["elements"]
|
|
504
|
+
if len(elements) <= MAX_ELEMENTS_PER_PAGE:
|
|
505
|
+
return
|
|
506
|
+
order = sorted(elements.values(), key=lambda e: (e.get("interactions", 0) > 0, e.get("last_seen", "")))
|
|
507
|
+
for entry in order[: len(elements) - MAX_ELEMENTS_PER_PAGE]:
|
|
508
|
+
elements.pop(entry["key"], None)
|
|
509
|
+
|
|
510
|
+
def record_outcome(self, pid: str, key: str, ok: bool, locator: Optional[LocKey] = None, stale: Iterable[LocKey] = ()) -> None:
|
|
511
|
+
"""An action on this element worked or failed; `locator` is the one replay resolved it with."""
|
|
512
|
+
page = self._pages.get(pid)
|
|
513
|
+
entry = (page or {}).get("elements", {}).get(key)
|
|
514
|
+
if not entry:
|
|
515
|
+
return
|
|
516
|
+
now = now_iso()
|
|
517
|
+
entry["interactions"] = int(entry.get("interactions") or 0) + 1
|
|
518
|
+
entry["success" if ok else "failure"] = int(entry.get("success" if ok else "failure") or 0) + 1
|
|
519
|
+
entry["last_used"] = now
|
|
520
|
+
self.touched_elements.add((pid, key))
|
|
521
|
+
by_key = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
|
|
522
|
+
if locator and locator in by_key:
|
|
523
|
+
field = "success" if ok else "failure"
|
|
524
|
+
by_key[locator][field] = int(by_key[locator].get(field) or 0) + 1
|
|
525
|
+
for k in stale:
|
|
526
|
+
if k in by_key:
|
|
527
|
+
by_key[k]["failure"] = int(by_key[k].get("failure") or 0) + 1
|
|
528
|
+
self._rerank(entry, now)
|
|
529
|
+
self._dirty.add(pid)
|
|
530
|
+
|
|
531
|
+
def record_stale(self, pid: str, key: str, stale: Iterable[LocKey]) -> None:
|
|
532
|
+
"""Replay found this recorded element's action on a different element; its locators in `stale` no longer find anything."""
|
|
533
|
+
entry = (self._pages.get(pid) or {}).get("elements", {}).get(key)
|
|
534
|
+
if not entry:
|
|
535
|
+
return
|
|
536
|
+
gone = set(stale)
|
|
537
|
+
changed = False
|
|
538
|
+
for loc in entry.get("locators") or []:
|
|
539
|
+
if (loc["type"], loc["value"]) in gone:
|
|
540
|
+
loc["failure"] = int(loc.get("failure") or 0) + 1
|
|
541
|
+
loc["missed"] = int(loc.get("missed") or 0) + 1
|
|
542
|
+
changed = True
|
|
543
|
+
if changed:
|
|
544
|
+
self.touched_elements.add((pid, key))
|
|
545
|
+
self._rerank(entry, now_iso())
|
|
546
|
+
self._dirty.add(pid)
|
|
547
|
+
|
|
548
|
+
def element(self, pid: str, key: str) -> Optional[Dict[str, Any]]:
|
|
549
|
+
if pid not in self._pages:
|
|
550
|
+
host_end = pid.find("/")
|
|
551
|
+
if host_end < 0:
|
|
552
|
+
return None
|
|
553
|
+
self._page(pid[:host_end], pid[host_end:])
|
|
554
|
+
return self._pages[pid]["elements"].get(key)
|
|
555
|
+
|
|
556
|
+
def flush(self) -> None:
|
|
557
|
+
"""Write changed pages. When another run saved the same page since this one loaded it (parallel suites), the
|
|
558
|
+
saved copy is merged in first, so neither run's work is lost."""
|
|
559
|
+
if not self._dirty:
|
|
560
|
+
return
|
|
561
|
+
with _locked(self.root):
|
|
562
|
+
for pid in list(self._dirty):
|
|
563
|
+
page = self._pages[pid]
|
|
564
|
+
disk = _read_json(self._files[pid])
|
|
565
|
+
if disk and isinstance(disk.get("elements"), dict) and int(disk.get("revision") or 0) != self._loaded.get(pid):
|
|
566
|
+
self._merge_saved(pid, page, disk)
|
|
567
|
+
page["element_count"] = len(page["elements"])
|
|
568
|
+
page["last_updated"] = now_iso()
|
|
569
|
+
page["revision"] = max(int(page.get("revision") or 0), int((disk or {}).get("revision") or 0)) + 1
|
|
570
|
+
_write_json(self._files[pid], page)
|
|
571
|
+
self._loaded[pid] = page["revision"]
|
|
572
|
+
self._base[pid] = {key: _counts(entry) for key, entry in page["elements"].items()}
|
|
573
|
+
self._base_visits[pid] = int(page.get("visits") or 0)
|
|
574
|
+
self._dirty.clear()
|
|
575
|
+
|
|
576
|
+
def _merge_saved(self, pid: str, page: Dict[str, Any], disk: Dict[str, Any]) -> None:
|
|
577
|
+
"""Fold a copy saved by another run into this run's page: counters become saved + this run's increase."""
|
|
578
|
+
mine = {key for p, key in self.touched_elements if p == pid}
|
|
579
|
+
base = self._base.get(pid, {})
|
|
580
|
+
for key, saved in disk["elements"].items():
|
|
581
|
+
if key not in mine or key not in page["elements"]:
|
|
582
|
+
page["elements"][key] = saved
|
|
583
|
+
continue
|
|
584
|
+
entry = page["elements"][key]
|
|
585
|
+
start = base.get(key) or _counts({})
|
|
586
|
+
for f in _ELEMENT_COUNTS:
|
|
587
|
+
entry[f] = int(saved.get(f) or 0) + int(entry.get(f) or 0) - start["e"][f]
|
|
588
|
+
ours = {(l["type"], l["value"]): l for l in entry.get("locators") or []}
|
|
589
|
+
for sl in saved.get("locators") or []:
|
|
590
|
+
k = (sl["type"], sl["value"])
|
|
591
|
+
loc = ours.get(k)
|
|
592
|
+
if loc is None:
|
|
593
|
+
ours[k] = sl
|
|
594
|
+
continue
|
|
595
|
+
was = start["l"].get(k) or {}
|
|
596
|
+
for f in _LOCATOR_COUNTS:
|
|
597
|
+
loc[f] = int(sl.get(f) or 0) + int(loc.get(f) or 0) - int(was.get(f) or 0)
|
|
598
|
+
entry["locators"] = list(ours.values())
|
|
599
|
+
self._rerank(entry, None)
|
|
600
|
+
page["visits"] = int(disk.get("visits") or 0) + int(page.get("visits") or 0) - self._base_visits.get(pid, 0)
|
|
601
|
+
self._prune(page)
|
|
602
|
+
|
|
603
|
+
def stats(self) -> Dict[str, int]:
|
|
604
|
+
return {"pages": len(self.touched_pages), "elements": len(self.touched_elements), "new_elements": self.new_elements}
|
|
605
|
+
|
|
606
|
+
# -- flows ------------------------------------------------------------------------------
|
|
607
|
+
|
|
608
|
+
def flow_file(self, goal: str, start_url: Optional[str]) -> Path:
|
|
609
|
+
key = goal_key(goal, start_url)
|
|
610
|
+
slug = re.sub(r"[^a-z0-9]+", "-", key.split("|", 1)[0])[:50].strip("-") or "goal"
|
|
611
|
+
return self.root / "flows" / f"{slug}-{_sha(key, 10)}.json"
|
|
612
|
+
|
|
613
|
+
def load_flow(self, goal: str, start_url: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
614
|
+
file = self.flow_file(goal, start_url)
|
|
615
|
+
if not file.is_file():
|
|
616
|
+
return None
|
|
617
|
+
try:
|
|
618
|
+
flow = json.loads(file.read_text(encoding="utf-8"))
|
|
619
|
+
except Exception:
|
|
620
|
+
return None
|
|
621
|
+
return flow if isinstance(flow, dict) and isinstance(flow.get("steps"), list) else None
|
|
622
|
+
|
|
623
|
+
def checkpoint_file(self, goal: str, start_url: Optional[str]) -> Path:
|
|
624
|
+
return self.root / "checkpoints" / self.flow_file(goal, start_url).name
|
|
625
|
+
|
|
626
|
+
def save_checkpoint(self, goal: str, start_url: Optional[str], data: Dict[str, Any]) -> None:
|
|
627
|
+
"""The steps that worked so far in a run still going, so a run cut off before it finished can be picked up."""
|
|
628
|
+
_write_json(self.checkpoint_file(goal, start_url), {"goal": goal, "start_url": start_url or "", **data, "at": now_iso()})
|
|
629
|
+
|
|
630
|
+
def load_checkpoint(self, goal: str, start_url: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
631
|
+
data = _read_json(self.checkpoint_file(goal, start_url))
|
|
632
|
+
return data if data and isinstance(data.get("steps"), list) and data["steps"] else None
|
|
633
|
+
|
|
634
|
+
def clear_checkpoint(self, goal: str, start_url: Optional[str], run_ids: Iterable[str]) -> None:
|
|
635
|
+
"""Remove the checkpoint when one of these runs wrote it: a run that finishes leaves its own result instead."""
|
|
636
|
+
file = self.checkpoint_file(goal, start_url)
|
|
637
|
+
data = _read_json(file)
|
|
638
|
+
if data is not None and str(data.get("run_id") or "") in {r for r in run_ids if r}:
|
|
639
|
+
file.unlink(missing_ok=True)
|
|
640
|
+
|
|
641
|
+
def save_flow_result(
|
|
642
|
+
self,
|
|
643
|
+
goal: str,
|
|
644
|
+
start_url: Optional[str],
|
|
645
|
+
*,
|
|
646
|
+
status: str,
|
|
647
|
+
steps: List[Dict[str, Any]],
|
|
648
|
+
summary: str = "",
|
|
649
|
+
final_url: str = "",
|
|
650
|
+
replayed: int = 0,
|
|
651
|
+
llm_steps: int = 0,
|
|
652
|
+
resume: Optional[Dict[str, Any]] = None,
|
|
653
|
+
) -> Optional[str]:
|
|
654
|
+
"""
|
|
655
|
+
Update the goal's flow after a run. Only successful runs replace its steps. Returns "saved" or "updated".
|
|
656
|
+
|
|
657
|
+
A failed run with `resume` (the steps that worked before it went wrong) keeps them under "resume", so the
|
|
658
|
+
next run of this task replays them and the agent picks up where the failed run went wrong.
|
|
659
|
+
"""
|
|
660
|
+
if status == "stopped":
|
|
661
|
+
return None
|
|
662
|
+
with _locked(self.root):
|
|
663
|
+
return self._save_flow_result(goal, start_url, status=status, steps=steps, summary=summary,
|
|
664
|
+
final_url=final_url, replayed=replayed, llm_steps=llm_steps, resume=resume)
|
|
665
|
+
|
|
666
|
+
def _save_flow_result(self, goal: str, start_url: Optional[str], *, status: str, steps: List[Dict[str, Any]],
|
|
667
|
+
summary: str, final_url: str, replayed: int, llm_steps: int,
|
|
668
|
+
resume: Optional[Dict[str, Any]] = None) -> Optional[str]:
|
|
669
|
+
file = self.flow_file(goal, start_url)
|
|
670
|
+
flow = self.load_flow(goal, start_url)
|
|
671
|
+
created = flow is None
|
|
672
|
+
keep_resume = status == "fail" and bool(resume and resume.get("steps"))
|
|
673
|
+
if created and not keep_resume and (status != "complete" or not steps):
|
|
674
|
+
return None
|
|
675
|
+
now = now_iso()
|
|
676
|
+
if flow is None:
|
|
677
|
+
flow = {"version": VERSION, "goal": goal, "start_url": start_url or "", "created": now,
|
|
678
|
+
"runs": 0, "successes": 0, "failures": 0, "replays": 0, "steps": []}
|
|
679
|
+
flow["runs"] = int(flow.get("runs") or 0) + 1
|
|
680
|
+
if replayed:
|
|
681
|
+
flow["replays"] = int(flow.get("replays") or 0) + 1
|
|
682
|
+
if status == "complete" and steps:
|
|
683
|
+
flow["successes"] = int(flow.get("successes") or 0) + 1
|
|
684
|
+
flow["steps"] = steps[:MAX_FLOW_STEPS]
|
|
685
|
+
flow["host"] = _flow_host(steps, start_url)
|
|
686
|
+
flow["last_success"] = now
|
|
687
|
+
flow["summary"] = (summary or "")[:2000]
|
|
688
|
+
flow["final_url"] = final_url or ""
|
|
689
|
+
flow["llm_steps"] = llm_steps
|
|
690
|
+
flow["replayed_steps"] = replayed
|
|
691
|
+
flow.pop("resume", None)
|
|
692
|
+
elif status != "complete":
|
|
693
|
+
flow["failures"] = int(flow.get("failures") or 0) + 1
|
|
694
|
+
if keep_resume and resume:
|
|
695
|
+
flow["resume"] = {**resume, "steps": list(resume["steps"])[:MAX_FLOW_STEPS], "at": now}
|
|
696
|
+
flow.setdefault("host", _flow_host(resume["steps"], start_url))
|
|
697
|
+
flow["last_status"] = status
|
|
698
|
+
flow["last_run"] = now
|
|
699
|
+
flow["updated"] = now
|
|
700
|
+
_write_json(file, flow)
|
|
701
|
+
if created and status != "complete":
|
|
702
|
+
return None
|
|
703
|
+
return "saved" if created else "updated"
|
|
704
|
+
|
|
705
|
+
def recent_flows(self, host: str, *, exclude: Optional[Path] = None, limit: int = 2) -> List[Dict[str, Any]]:
|
|
706
|
+
folder = self.root / "flows"
|
|
707
|
+
if not folder.is_dir():
|
|
708
|
+
return []
|
|
709
|
+
found = []
|
|
710
|
+
for file in folder.glob("*.json"):
|
|
711
|
+
if exclude is not None and file.resolve() == exclude.resolve():
|
|
712
|
+
continue
|
|
713
|
+
try:
|
|
714
|
+
flow = json.loads(file.read_text(encoding="utf-8"))
|
|
715
|
+
except Exception:
|
|
716
|
+
continue
|
|
717
|
+
if isinstance(flow, dict) and flow.get("host") == host and flow.get("successes") and flow.get("steps"):
|
|
718
|
+
found.append(flow)
|
|
719
|
+
found.sort(key=lambda f: str(f.get("last_success") or ""), reverse=True)
|
|
720
|
+
return found[:limit]
|
|
721
|
+
|
|
722
|
+
def pages_for_host(self, host: str) -> List[Dict[str, Any]]:
|
|
723
|
+
folder = self.root / "pages" / _host_dir(host)
|
|
724
|
+
pages = []
|
|
725
|
+
for file in folder.glob("*.json") if folder.is_dir() else []:
|
|
726
|
+
try:
|
|
727
|
+
page = json.loads(file.read_text(encoding="utf-8"))
|
|
728
|
+
except Exception:
|
|
729
|
+
continue
|
|
730
|
+
if isinstance(page, dict):
|
|
731
|
+
pages.append(page)
|
|
732
|
+
pages.sort(key=lambda p: -int(p.get("visits") or 0))
|
|
733
|
+
return pages
|
|
734
|
+
|
|
735
|
+
def prompt_hints(self, start_url: Optional[str], goal: str, *, max_chars: int = 2500) -> str:
|
|
736
|
+
"""Short system-prompt section: earlier successful paths on this site and the pages seen there."""
|
|
737
|
+
ref = page_ref(start_url or "")
|
|
738
|
+
if not ref:
|
|
739
|
+
return ""
|
|
740
|
+
host = ref[0]
|
|
741
|
+
lines: List[str] = []
|
|
742
|
+
flows = self.recent_flows(host, exclude=self.flow_file(goal, start_url))
|
|
743
|
+
if flows:
|
|
744
|
+
lines.append("Earlier successful runs of other tasks on this site. Reuse the steps that also apply to this task (a banner, a sign-in, a search form), skip the rest; the live page wins when it differs:")
|
|
745
|
+
for flow in flows:
|
|
746
|
+
lines.append(f'- "{_clean(flow.get("goal"), 140)}"')
|
|
747
|
+
for step in flow["steps"][:15]:
|
|
748
|
+
lines.append(f" {_clean(step.get('line') or step.get('action'), 140)}")
|
|
749
|
+
pages = self.pages_for_host(host)[:6]
|
|
750
|
+
if pages:
|
|
751
|
+
lines.append("Pages seen on this site before, with the controls that already worked:")
|
|
752
|
+
for page in pages:
|
|
753
|
+
sample = (page.get("sample_urls") or [""])[-1]
|
|
754
|
+
lines.append(f"- {page.get('path')} — {_clean(page.get('title'), 60)} ({_clean(sample, 160)})")
|
|
755
|
+
controls = _worked_controls(page)
|
|
756
|
+
if controls:
|
|
757
|
+
lines.append(f" {controls}")
|
|
758
|
+
if not lines:
|
|
759
|
+
return ""
|
|
760
|
+
text = (
|
|
761
|
+
"## Site memory from earlier ForgeQE runs\n"
|
|
762
|
+
"Do these steps in one turn where the page already shows the controls. "
|
|
763
|
+
"Do not spend a turn exploring a page listed here.\n"
|
|
764
|
+
+ "\n".join(lines)
|
|
765
|
+
)
|
|
766
|
+
return text if len(text) <= max_chars else text[: max_chars - 20] + "\n…(memory truncated)"
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _worked_controls(page: Dict[str, Any], limit: int = 6) -> str:
|
|
770
|
+
"""Controls a previous run actually used, so the model can act on them instead of searching the page."""
|
|
771
|
+
elements = [e for e in (page.get("elements") or {}).values() if isinstance(e, dict) and int(e.get("success") or 0) > 0]
|
|
772
|
+
elements.sort(key=lambda e: (-int(e.get("success") or 0), -int(e.get("confidence") or 0)))
|
|
773
|
+
bits = []
|
|
774
|
+
for entry in elements[:limit]:
|
|
775
|
+
attrs = entry.get("attributes") or {}
|
|
776
|
+
label = entry.get("name") or entry.get("text") or attrs.get("placeholder") or attrs.get("aria-label") or entry.get("tag")
|
|
777
|
+
loc = (entry.get("locators") or [{}])[0]
|
|
778
|
+
kind = loc.get("type") or "locator"
|
|
779
|
+
conf = loc.get("confidence") or entry.get("confidence") or 0
|
|
780
|
+
bits.append(f"{_clean(label, 40)} ({kind} {conf}%)")
|
|
781
|
+
return "use these, do not look for them: " + ", ".join(bits) if bits else ""
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def _flow_host(steps: List[Dict[str, Any]], start_url: Optional[str]) -> str:
|
|
785
|
+
ref = page_ref(start_url or "")
|
|
786
|
+
if ref:
|
|
787
|
+
return ref[0]
|
|
788
|
+
for step in steps:
|
|
789
|
+
url = (step.get("params") or {}).get("url") if step.get("action") == "navigate" else None
|
|
790
|
+
ref = page_ref(url or "") if url else None
|
|
791
|
+
if ref:
|
|
792
|
+
return ref[0]
|
|
793
|
+
if step.get("page"):
|
|
794
|
+
return str(step["page"]).split("/", 1)[0]
|
|
795
|
+
return ""
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
_ELEMENT_COUNTS = ("visits", "interactions", "success", "failure")
|
|
799
|
+
_LOCATOR_COUNTS = ("seen", "missed", "success", "failure")
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _counts(entry: Dict[str, Any]) -> Dict[str, Any]:
|
|
803
|
+
return {
|
|
804
|
+
"e": {f: int(entry.get(f) or 0) for f in _ELEMENT_COUNTS},
|
|
805
|
+
"l": {(l["type"], l["value"]): {f: int(l.get(f) or 0) for f in _LOCATOR_COUNTS} for l in entry.get("locators") or []},
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@contextmanager
|
|
810
|
+
def _locked(root: Path) -> Iterator[None]:
|
|
811
|
+
"""Exclusive lock on the memory folder for the read-merge-write at the end of a run."""
|
|
812
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
with open(root / ".lock", "a+") as handle:
|
|
814
|
+
try:
|
|
815
|
+
import fcntl
|
|
816
|
+
except ImportError:
|
|
817
|
+
import msvcrt
|
|
818
|
+
|
|
819
|
+
handle.seek(0)
|
|
820
|
+
while True:
|
|
821
|
+
try:
|
|
822
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
823
|
+
break
|
|
824
|
+
except OSError:
|
|
825
|
+
continue
|
|
826
|
+
try:
|
|
827
|
+
yield
|
|
828
|
+
finally:
|
|
829
|
+
handle.seek(0)
|
|
830
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
831
|
+
return
|
|
832
|
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
833
|
+
try:
|
|
834
|
+
yield
|
|
835
|
+
finally:
|
|
836
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _read_json(file: Path) -> Optional[Dict[str, Any]]:
|
|
840
|
+
if not file.is_file():
|
|
841
|
+
return None
|
|
842
|
+
try:
|
|
843
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
844
|
+
except Exception:
|
|
845
|
+
return None
|
|
846
|
+
return data if isinstance(data, dict) else None
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _write_json(file: Path, data: Dict[str, Any]) -> None:
|
|
850
|
+
file.parent.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
tmp = file.with_suffix(f".{os.getpid()}.tmp")
|
|
852
|
+
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False, default=str), encoding="utf-8")
|
|
853
|
+
os.replace(tmp, file)
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
# ---------------------------------------------------------------------------------------------
|
|
857
|
+
# Flow steps
|
|
858
|
+
# ---------------------------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _secret_env(text: Any) -> Optional[str]:
|
|
862
|
+
if not text:
|
|
863
|
+
return None
|
|
864
|
+
for name in (*SECRET_ENV_VARS, *text_envs()):
|
|
865
|
+
if os.environ.get(name) and str(text) == os.environ[name]:
|
|
866
|
+
return name
|
|
867
|
+
return None
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _present_first(target: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
871
|
+
"""The element's ranked locators, those that found it on the page just now ahead of ones only seen on earlier visits."""
|
|
872
|
+
current = target.get("current") or {}
|
|
873
|
+
locs = list(target.get("locators") or [])
|
|
874
|
+
return [l for l in locs if (l["type"], l["value"]) in current] + [l for l in locs if (l["type"], l["value"]) not in current]
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def flow_step(action: str, params: Dict[str, Any], target: Optional[Dict[str, Any]], line: str) -> Dict[str, Any]:
|
|
878
|
+
"""One replayable action. Element actions keep the element's ranked locators and fingerprint, never its index."""
|
|
879
|
+
step: Dict[str, Any] = {"action": action, "params": {k: v for k, v in (params or {}).items() if k != "index"}, "line": line}
|
|
880
|
+
if action == "input" and not step["params"].get("totp_env"):
|
|
881
|
+
text = step["params"].get("text")
|
|
882
|
+
ref = secret_ref(text)
|
|
883
|
+
env = ref[1] if ref and ref[0] == "text" else _secret_env(text)
|
|
884
|
+
if ref and ref[0] == "totp":
|
|
885
|
+
step["params"]["text"] = None
|
|
886
|
+
step["params"]["totp_env"] = ref[1]
|
|
887
|
+
elif env:
|
|
888
|
+
step["params"]["text"] = None
|
|
889
|
+
step["params"]["text_env"] = env
|
|
890
|
+
elif target and (target["facts"].get("attributes") or {}).get("type") == "password":
|
|
891
|
+
step["params"]["text"] = None
|
|
892
|
+
step["secret"] = True
|
|
893
|
+
if (env or ref or step.get("secret")) and text:
|
|
894
|
+
step["line"] = line.replace(str(text), "***")
|
|
895
|
+
if target:
|
|
896
|
+
facts = target["facts"]
|
|
897
|
+
step.update(
|
|
898
|
+
page=target["page"],
|
|
899
|
+
element=target["key"],
|
|
900
|
+
label=element_label(facts),
|
|
901
|
+
locators=[_public_locator(l) for l in _present_first(target)[:FLOW_STEP_LOCATORS]],
|
|
902
|
+
fingerprint={
|
|
903
|
+
"tag": facts["tag"],
|
|
904
|
+
"role": facts["role"],
|
|
905
|
+
"name": facts["name"],
|
|
906
|
+
"stable_hash": facts["stable_hash"],
|
|
907
|
+
"xpath": facts["xpath"],
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
return step
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def resolve_step(
|
|
914
|
+
step: Dict[str, Any], snapshot: Dict[int, Dict[str, Any]], store: Optional[MemoryStore] = None
|
|
915
|
+
) -> Tuple[Optional[int], Optional[LocKey], List[LocKey]]:
|
|
916
|
+
"""
|
|
917
|
+
Find a recorded step's element in the current page snapshot (from MemoryStore.observe).
|
|
918
|
+
Tries locators from most to least confident; returns (index, locator used, locators that matched nothing).
|
|
919
|
+
"""
|
|
920
|
+
locs = list(step.get("locators") or [])
|
|
921
|
+
if store and step.get("page") and step.get("element"):
|
|
922
|
+
entry = store.element(step["page"], step["element"])
|
|
923
|
+
if entry and entry.get("locators"):
|
|
924
|
+
fresh = {(l["type"], l["value"]) for l in entry["locators"]}
|
|
925
|
+
locs = entry["locators"] + [l for l in locs if (l["type"], l["value"]) not in fresh]
|
|
926
|
+
fp = step.get("fingerprint") or {}
|
|
927
|
+
stale: List[LocKey] = []
|
|
928
|
+
for loc in locs:
|
|
929
|
+
k = (loc["type"], loc["value"])
|
|
930
|
+
hits = [idx for idx, e in snapshot.items() if k in e["current"]]
|
|
931
|
+
if len(hits) == 1:
|
|
932
|
+
return hits[0], k, stale
|
|
933
|
+
if len(hits) > 1:
|
|
934
|
+
narrowed = [
|
|
935
|
+
i for i in hits
|
|
936
|
+
if snapshot[i]["facts"]["tag"] == fp.get("tag") and (not fp.get("name") or snapshot[i]["facts"]["name"] == fp.get("name"))
|
|
937
|
+
]
|
|
938
|
+
if len(narrowed) == 1:
|
|
939
|
+
return narrowed[0], k, stale
|
|
940
|
+
continue
|
|
941
|
+
stale.append(k)
|
|
942
|
+
key = step.get("element")
|
|
943
|
+
hits = [idx for idx, e in snapshot.items() if key and e["key"] == key]
|
|
944
|
+
if len(hits) == 1:
|
|
945
|
+
return hits[0], None, stale
|
|
946
|
+
if fp.get("stable_hash") is not None:
|
|
947
|
+
hits = [idx for idx, e in snapshot.items() if e["facts"].get("stable_hash") == fp["stable_hash"]]
|
|
948
|
+
if len(hits) == 1:
|
|
949
|
+
return hits[0], None, stale
|
|
950
|
+
return None, None, stale
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
def compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
954
|
+
"""Flow step → workflow.yaml step, carrying the best-ranked locator and the browser agent's note for the step."""
|
|
955
|
+
out = _compact_step(step)
|
|
956
|
+
if step.get("scripted"):
|
|
957
|
+
out["scripted"] = True
|
|
958
|
+
if step.get("overlay"):
|
|
959
|
+
out["overlay"] = step["overlay"]
|
|
960
|
+
if step.get("note"):
|
|
961
|
+
out["note"] = step["note"]
|
|
962
|
+
return out
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _compact_step(step: Dict[str, Any]) -> Dict[str, Any]:
|
|
966
|
+
action = step.get("action") or ""
|
|
967
|
+
p = step.get("params") or {}
|
|
968
|
+
if action == "navigate":
|
|
969
|
+
return {"op": "navigate", "url": p.get("url") or ""}
|
|
970
|
+
if action in ("click", "input", "select_dropdown"):
|
|
971
|
+
locs = step.get("locators") or []
|
|
972
|
+
fp = step.get("fingerprint") or {}
|
|
973
|
+
out: Dict[str, Any] = {
|
|
974
|
+
"op": {"click": "click", "input": "input", "select_dropdown": "select"}[action],
|
|
975
|
+
"page": step.get("page") or "",
|
|
976
|
+
"label": step.get("label") or "",
|
|
977
|
+
"best_locator": locs[0]["playwright"] if locs else None,
|
|
978
|
+
"locators": [
|
|
979
|
+
{"type": l["type"], "value": l["value"], "confidence": l.get("confidence"), **{k: l[k] for k in ("css", "attr", "verified") if l.get(k)}}
|
|
980
|
+
for l in locs
|
|
981
|
+
],
|
|
982
|
+
"locator": {k: v for k, v in {"tag": (fp.get("tag") or "").upper(), "role": fp.get("role"), "name": fp.get("name")}.items() if v},
|
|
983
|
+
}
|
|
984
|
+
if action == "input":
|
|
985
|
+
out["text"] = p.get("text") if p.get("text") is not None else ""
|
|
986
|
+
out["clear"] = bool(p.get("clear", False))
|
|
987
|
+
if p.get("text_env"):
|
|
988
|
+
out["text_env"] = p["text_env"]
|
|
989
|
+
if p.get("totp_env"):
|
|
990
|
+
out["totp_env"] = p["totp_env"]
|
|
991
|
+
if action == "select_dropdown":
|
|
992
|
+
out["text"] = p.get("text") or ""
|
|
993
|
+
return out
|
|
994
|
+
if action == "send_keys":
|
|
995
|
+
return {"op": "press", "keys": p.get("keys") or ""}
|
|
996
|
+
if action == "wait":
|
|
997
|
+
return {"op": "wait", "seconds": p.get("seconds") or 1}
|
|
998
|
+
if action == "scroll":
|
|
999
|
+
return {"op": "scroll", "down": p.get("down", True), "pages": p.get("pages") or 1}
|
|
1000
|
+
if action == "go_back":
|
|
1001
|
+
return {"op": "back"}
|
|
1002
|
+
if action == "evaluate":
|
|
1003
|
+
return {"op": "evaluate", "code": str(p.get("code") or "")[:2000]}
|
|
1004
|
+
return {"op": "unknown", "action_keys": [action]}
|