@capagents/forgeqe 2.2.1 → 2.2.3
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 +235 -51
- package/README.md +2 -2
- package/THIRD_PARTY_NOTICES.md +2 -1
- package/dist/forgeqe.js +1145 -951
- package/dist/template.html +36 -36
- package/docs/ARCHITECTURE.md +6 -2
- package/docs/CONTAINERS.md +80 -4
- package/docs/user-guide.html +1509 -143
- package/forgeqe.example.yaml +3 -1
- package/llms.example.json +2 -4
- package/package.json +5 -2
- package/prompts/README.md +1 -0
- package/prompts/brain.md +3 -1
- package/prompts/test-plan.md +61 -0
- package/py/forgeqe_engine/browser.py +106 -1
- package/py/forgeqe_engine/cli.py +57 -0
- package/py/forgeqe_engine/copilot_gateway.py +369 -0
- package/py/forgeqe_engine/dynamic.py +149 -0
- package/py/forgeqe_engine/recordsession.py +908 -0
- package/py/forgeqe_engine/service.py +29 -0
- package/py/forgeqe_engine/webrecord.py +589 -0
- package/py/forgeqe_engine/webrecord_page.py +530 -0
- package/reporters/playwright.mjs +83 -0
|
@@ -46,11 +46,18 @@ from .llm import (
|
|
|
46
46
|
_agent_usage,
|
|
47
47
|
)
|
|
48
48
|
from .browser import (
|
|
49
|
+
VIDEO_FILE,
|
|
49
50
|
FreshTabAfterPassword,
|
|
50
51
|
browser_connection_label,
|
|
51
52
|
browser_launch_kwargs,
|
|
52
53
|
local_browser_launch_kwargs,
|
|
54
|
+
maximize_window,
|
|
53
55
|
settle_page,
|
|
56
|
+
start_video,
|
|
57
|
+
stop_video,
|
|
58
|
+
video_available,
|
|
59
|
+
wants_maximized,
|
|
60
|
+
window_launch_kwargs,
|
|
54
61
|
_shutdown_browser_instance,
|
|
55
62
|
_stop_agent_instance,
|
|
56
63
|
browser_profile,
|
|
@@ -1004,6 +1011,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1004
1011
|
browser_provider = str(browser_cfg.get("provider") or "local")
|
|
1005
1012
|
browser_remote = browser_cfg.get("remote") if isinstance(browser_cfg.get("remote"), dict) else {}
|
|
1006
1013
|
browser_kw = browser_launch_kwargs(provider=browser_provider, remote=browser_remote, headless=headless)
|
|
1014
|
+
browser_kw.update(window_launch_kwargs(browser_cfg, headless=headless))
|
|
1007
1015
|
if allowed_domains is not None:
|
|
1008
1016
|
browser_kw["allowed_domains"] = allowed_domains
|
|
1009
1017
|
logins = parse_logins(req.logins)
|
|
@@ -1142,6 +1150,21 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1142
1150
|
shared = mem.store.shared_start(goal, normalized_initial)
|
|
1143
1151
|
if shared:
|
|
1144
1152
|
flow, kind = shared, "shared"
|
|
1153
|
+
maximize = wants_maximized(browser_cfg, headless=headless)
|
|
1154
|
+
video = browser_cfg.get("video") is True
|
|
1155
|
+
if video and not video_available():
|
|
1156
|
+
emit({"type": "log", "level": "warn", "text": "No video for this run: the engine's video packages are missing. Run: forgeqe setup"})
|
|
1157
|
+
video = False
|
|
1158
|
+
if maximize or video:
|
|
1159
|
+
# Started before the agent so the window is maximised, and the video sized, before the first page opens.
|
|
1160
|
+
try:
|
|
1161
|
+
await browser.start()
|
|
1162
|
+
if maximize and not await maximize_window(browser):
|
|
1163
|
+
emit({"type": "log", "level": "debug", "text": "The browser window could not be maximised"})
|
|
1164
|
+
if video and await start_video(browser, out_dir) is None:
|
|
1165
|
+
emit({"type": "log", "level": "warn", "text": "No video for this run: recording did not start"})
|
|
1166
|
+
except Exception as ex:
|
|
1167
|
+
logger.debug("early browser start failed: %s", ex)
|
|
1145
1168
|
if flow and kind and flow.get("steps"):
|
|
1146
1169
|
if kind == "interrupted":
|
|
1147
1170
|
replay_text = (
|
|
@@ -1668,6 +1691,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
|
|
|
1668
1691
|
await _save_logins()
|
|
1669
1692
|
if overlay is not None:
|
|
1670
1693
|
overlay.close()
|
|
1694
|
+
if await stop_video(browser):
|
|
1695
|
+
emit({"type": "log", "level": "info", "text": "Video saved: video.mp4"})
|
|
1671
1696
|
await _shutdown_browser_instance(browser)
|
|
1672
1697
|
|
|
1673
1698
|
|
|
@@ -1694,5 +1719,9 @@ async def run_with_events(req: RunRequest, emit: Emit, cancel_event: asyncio.Eve
|
|
|
1694
1719
|
except Exception as ex:
|
|
1695
1720
|
logger.warning("artifact write failed: %s", ex)
|
|
1696
1721
|
out["files"] = []
|
|
1722
|
+
# The video is finished when the browser closes, after the other files were written.
|
|
1723
|
+
video = Path(req.out_dir) / VIDEO_FILE
|
|
1724
|
+
if video.is_file() and str(video) not in out["files"]:
|
|
1725
|
+
out["files"].append(str(video))
|
|
1697
1726
|
emit({"type": "done", "result": {k: v for k, v in out.items() if k != "step_events"}})
|
|
1698
1727
|
return out
|
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recording a web test by hand: the tester does the steps in the browser once, and ForgeQE writes them as the steps of a
|
|
3
|
+
run (workflow.yaml), in the same shape as the browser agent's, so the replay check and the coding agent take them as
|
|
4
|
+
they are.
|
|
5
|
+
|
|
6
|
+
This module turns what the tester did into steps; ``recordsession.py`` watches the browser and feeds it. What is recorded:
|
|
7
|
+
|
|
8
|
+
- a click on a control → ``click`` (two on the same control → ``dblclick``). A click on an item of a menu that opened
|
|
9
|
+
when the pointer went over its trigger gets a ``hover`` on the trigger first;
|
|
10
|
+
- text typed into a field → one ``input`` step with the field's final text, when the tester moves on; a click into that
|
|
11
|
+
field just before is dropped. A password field, or text equal to a saved login's value, is written as ``text_env``:
|
|
12
|
+
the typed characters of a password never reach this module;
|
|
13
|
+
- a choice in a drop-down → ``select`` (the click that opened it is part of the select);
|
|
14
|
+
- Enter and Escape, arrows in an autocomplete field, and key chords outside a text field → ``press``;
|
|
15
|
+
- a page the tester opened by hand → ``navigate``; going back → ``back``. Pages opened by a click, a key, or a form are
|
|
16
|
+
part of that step;
|
|
17
|
+
- the tester's checks → ``expect``; values kept for later steps → ``capture`` (text typed later that equals a kept
|
|
18
|
+
value is written as ``{{name}}``); a wait for an element → ``wait_for``; notes → ``note`` on the step before.
|
|
19
|
+
|
|
20
|
+
Uploads and drag and drop are not recorded; the result lists them.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, Callable, Dict, List, Mapping, Optional, Tuple
|
|
28
|
+
from urllib.parse import urlparse
|
|
29
|
+
|
|
30
|
+
from . import dynamic
|
|
31
|
+
from .memory import FLOW_STEP_LOCATORS, TEST_ID_ATTRS, element_label, looks_generated, page_id
|
|
32
|
+
|
|
33
|
+
FORMAT_SOURCE = "recorded"
|
|
34
|
+
# A page that loads this soon after a click, a key, or a typed value came from it, not from the address bar (for a
|
|
35
|
+
# navigation the browser did not report as the page's own).
|
|
36
|
+
NAV_CAUSED_SECONDS = 1.5
|
|
37
|
+
DOUBLE_CLICK_SECONDS = 0.5
|
|
38
|
+
MAX_TEXT = 200
|
|
39
|
+
EXPECT_KINDS = ("text", "visible", "hidden", "value", "checked", "url", "title", "intent")
|
|
40
|
+
# Keys recorded inside a text field; any other key there is typing.
|
|
41
|
+
FIELD_KEYS = ("Enter", "Escape")
|
|
42
|
+
AUTOCOMPLETE_KEYS = ("ArrowDown", "ArrowUp")
|
|
43
|
+
_NAME = re.compile(r"[^a-z0-9_]+")
|
|
44
|
+
# Files never recorded for an upload, as ForgeQE's attachments refuse them (src/mcp/attachtools.ts): credentials and keys.
|
|
45
|
+
SECRET_FILE = re.compile(r"^(?:forgeqe\.ya?ml|credentials\.json|auth\.json|llms\.json|id_(?:rsa|dsa|ecdsa|ed25519)|.*\.(?:pem|key|pfx|p12|kdbx))$", re.I)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def upload_path(path: str, work_dir: str) -> Tuple[Optional[str], str]:
|
|
49
|
+
"""
|
|
50
|
+
(path relative to the working folder, "") for a file the tester chose that a test may use, or (None, why not): it
|
|
51
|
+
must be inside the working folder, outside its hidden files and folders, and not named like a file of credentials.
|
|
52
|
+
"""
|
|
53
|
+
if not path or not work_dir:
|
|
54
|
+
return None, "the file's path is not known"
|
|
55
|
+
real, work = os.path.realpath(path), os.path.realpath(work_dir)
|
|
56
|
+
rel = os.path.relpath(real, work)
|
|
57
|
+
if rel == os.pardir or rel.startswith(os.pardir + os.sep) or os.path.isabs(rel):
|
|
58
|
+
return None, f"{os.path.basename(real)} is outside the working folder; put it in the repository's test data and record again"
|
|
59
|
+
if any(part.startswith(".") for part in rel.split(os.sep)):
|
|
60
|
+
return None, f"{os.path.basename(real)} is in a hidden folder or is a hidden file, which can hold secrets"
|
|
61
|
+
if SECRET_FILE.match(os.path.basename(real)):
|
|
62
|
+
return None, f"{os.path.basename(real)} is named like a file of credentials or keys"
|
|
63
|
+
return rel.replace(os.sep, "/"), ""
|
|
64
|
+
_ENV = re.compile(r"[^A-Z0-9_]+")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clip(text: Any, n: int = MAX_TEXT) -> str:
|
|
68
|
+
return " ".join(str(text or "").split())[:n]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def var_name(text: str, taken: Any = ()) -> str:
|
|
72
|
+
"""A variable name from a label: "Order number" → order_number; numbered when taken."""
|
|
73
|
+
base = _NAME.sub("_", (text or "").lower()).strip("_")[:30] or "value"
|
|
74
|
+
if base[0].isdigit():
|
|
75
|
+
base = f"v_{base}"
|
|
76
|
+
name, n = base, 2
|
|
77
|
+
while name in taken:
|
|
78
|
+
name, n = f"{base}_{n}", n + 1
|
|
79
|
+
return name
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def secret_env_for(host: str) -> str:
|
|
83
|
+
"""The variable a recorded password is read from when no saved login names one: shop.example.com → SHOP_EXAMPLE_COM_PASSWORD."""
|
|
84
|
+
base = _ENV.sub("_", (host or "SITE").upper()).strip("_") or "SITE"
|
|
85
|
+
if base.startswith("WWW_"):
|
|
86
|
+
base = base[4:]
|
|
87
|
+
# A shell variable cannot start with a digit (127.0.0.1).
|
|
88
|
+
if base[0].isdigit():
|
|
89
|
+
base = f"SITE_{base}"
|
|
90
|
+
return f"{base}_PASSWORD"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Target:
|
|
95
|
+
"""An element the tester acted on: the page it was on, its facts, and its locators (verified on the live page first)."""
|
|
96
|
+
|
|
97
|
+
url: str
|
|
98
|
+
facts: Dict[str, Any]
|
|
99
|
+
locators: List[Dict[str, Any]]
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def tag(self) -> str:
|
|
103
|
+
return str(self.facts.get("tag") or "").lower()
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def input_type(self) -> str:
|
|
107
|
+
return str((self.facts.get("attributes") or {}).get("type") or "").lower()
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def key(self) -> Tuple[str, str]:
|
|
111
|
+
"""What makes two targets the same element: the page and its best locator."""
|
|
112
|
+
best = self.locators[0] if self.locators else {}
|
|
113
|
+
return (page_id(self.url) or self.url, f"{best.get('type')}:{best.get('value')}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def element_step(op: str, t: Target) -> Dict[str, Any]:
|
|
117
|
+
"""A step on an element, as memory.compact_step writes the browser agent's."""
|
|
118
|
+
locs = t.locators[:FLOW_STEP_LOCATORS]
|
|
119
|
+
f = t.facts
|
|
120
|
+
return {
|
|
121
|
+
"op": op,
|
|
122
|
+
"page": page_id(t.url) or t.url,
|
|
123
|
+
"label": _clip(element_label(f), 120),
|
|
124
|
+
"best_locator": locs[0].get("playwright") if locs else None,
|
|
125
|
+
"locators": [
|
|
126
|
+
{"type": l["type"], "value": l["value"], "confidence": l.get("confidence"), **{k: l[k] for k in ("css", "attr", "verified") if l.get(k)}}
|
|
127
|
+
for l in locs
|
|
128
|
+
if l.get("type") and l.get("value")
|
|
129
|
+
],
|
|
130
|
+
"locator": {k: v for k, v in {"tag": (f.get("tag") or "").upper(), "role": f.get("role"), "name": f.get("name")}.items() if v},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def describe(step: Dict[str, Any]) -> str:
|
|
135
|
+
"""One line for the step list in the Recorder and the terminal."""
|
|
136
|
+
op = step.get("op") or ""
|
|
137
|
+
label = step.get("label") or (step.get("locator") or {}).get("name") or "element"
|
|
138
|
+
if op == "navigate":
|
|
139
|
+
return f"open {step.get('url')}"
|
|
140
|
+
if op in ("click", "dblclick", "hover"):
|
|
141
|
+
return f"{'double-click' if op == 'dblclick' else op} {label}"
|
|
142
|
+
if op == "input":
|
|
143
|
+
if step.get("text_env"):
|
|
144
|
+
return f"type ${step['text_env']} into {label}"
|
|
145
|
+
if step.get("generated") == "date":
|
|
146
|
+
return f"type a date {step.get('text')} into {label} ({step.get('example')!r} today)"
|
|
147
|
+
if step.get("generated") == "unique":
|
|
148
|
+
return f"type {step.get('text')!r} into {label}, new on each run ({step.get('example')!r} this time)"
|
|
149
|
+
return f"type {step.get('text')!r} into {label}" + (f" (test data: {step['param']})" if step.get("param") else "")
|
|
150
|
+
if op == "select":
|
|
151
|
+
return f"choose {step.get('text')!r} in {label}"
|
|
152
|
+
if op == "press":
|
|
153
|
+
return f"press {step.get('keys')}"
|
|
154
|
+
if op == "back":
|
|
155
|
+
return "go back"
|
|
156
|
+
if op == "wait_for":
|
|
157
|
+
return f"wait for {label}"
|
|
158
|
+
if op == "upload":
|
|
159
|
+
return f"upload {', '.join(step.get('files') or [])} into {label}"
|
|
160
|
+
if op == "capture":
|
|
161
|
+
return f"keep {label} as {{{{{step.get('name')}}}}}"
|
|
162
|
+
if op == "expect":
|
|
163
|
+
kind = step.get("kind")
|
|
164
|
+
if kind == "intent":
|
|
165
|
+
return f"check: {step.get('expected')} (to be resolved)"
|
|
166
|
+
if kind in ("url", "title"):
|
|
167
|
+
return f"check the page {kind} contains {step.get('text')!r}"
|
|
168
|
+
if kind in ("visible", "hidden"):
|
|
169
|
+
return f"check {label} is {kind}"
|
|
170
|
+
if kind == "checked":
|
|
171
|
+
return f"check {label} is {'checked' if step.get('text') != 'false' else 'not checked'}"
|
|
172
|
+
what = "value" if kind == "value" else "text"
|
|
173
|
+
if step.get("equals"):
|
|
174
|
+
return f"check {label} {what} contains {{{{{step['equals']}}}}} (kept earlier: {step.get('text')!r} this time)"
|
|
175
|
+
keep = f", kept as {{{{{step['keep']['name']}}}}}" if step.get("keep") else ""
|
|
176
|
+
return f"check {label} {what} {'matches the pattern of' if step.get('dynamic') else 'contains'} {step.get('text')!r}{keep}"
|
|
177
|
+
return op
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class WebRecorder:
|
|
182
|
+
start_url: str
|
|
183
|
+
name: str = ""
|
|
184
|
+
# Saved logins' values by the variable they come from, so text equal to one is written as that variable.
|
|
185
|
+
login_values: Mapping[str, str] = field(default_factory=dict)
|
|
186
|
+
# The password variable of each saved login, by the hosts it signs in to.
|
|
187
|
+
login_passwords: Mapping[str, str] = field(default_factory=dict)
|
|
188
|
+
clock: Callable[[], float] = lambda: 0.0
|
|
189
|
+
# The test case being recorded: its steps ({n, action, expected}), shown as a checklist; each recorded step belongs
|
|
190
|
+
# to the case step the tester is on.
|
|
191
|
+
case_steps: List[Dict[str, Any]] = field(default_factory=list)
|
|
192
|
+
|
|
193
|
+
def __post_init__(self) -> None:
|
|
194
|
+
self.steps: List[Dict[str, Any]] = [{"op": "navigate", "url": self.start_url}]
|
|
195
|
+
self.skipped: List[Dict[str, Any]] = []
|
|
196
|
+
self.variables: Dict[str, str] = {}
|
|
197
|
+
self.secrets: List[str] = []
|
|
198
|
+
self.paused = False
|
|
199
|
+
self.url = self.start_url
|
|
200
|
+
self._history: List[str] = [self.start_url]
|
|
201
|
+
self._last_action = -1e9
|
|
202
|
+
self._last_click: Optional[Tuple[Tuple[str, str], float]] = None
|
|
203
|
+
self.case_at = 1 if self.case_steps else 0
|
|
204
|
+
|
|
205
|
+
# -- what the tester did ----------------------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def on_navigate(self, url: str, how: str = "") -> Optional[str]:
|
|
208
|
+
"""
|
|
209
|
+
A page loaded in the tab: `how` is "page" (the page itself navigated: a link, a form, a script), "reload",
|
|
210
|
+
"back", "forward", or "" (the address bar, a bookmark).
|
|
211
|
+
"""
|
|
212
|
+
if not url.startswith(("http://", "https://")):
|
|
213
|
+
return None
|
|
214
|
+
at = self.clock()
|
|
215
|
+
previous, self.url = self.url, url
|
|
216
|
+
if how == "reload" or url == previous:
|
|
217
|
+
return None
|
|
218
|
+
caused = how == "page" or at - self._last_action <= NAV_CAUSED_SECONDS
|
|
219
|
+
if how == "back" and not caused:
|
|
220
|
+
if self.paused:
|
|
221
|
+
return None
|
|
222
|
+
self._history.append(url)
|
|
223
|
+
self._add({"op": "back"})
|
|
224
|
+
return "go back"
|
|
225
|
+
self._history.append(url)
|
|
226
|
+
if caused or self.paused:
|
|
227
|
+
return None
|
|
228
|
+
self._add({"op": "navigate", "url": url})
|
|
229
|
+
return f"open {url}"
|
|
230
|
+
|
|
231
|
+
def on_click(self, t: Target, *, hover: Optional[Target] = None) -> Optional[str]:
|
|
232
|
+
if self.paused:
|
|
233
|
+
return None
|
|
234
|
+
at = self.clock()
|
|
235
|
+
self._last_action = at
|
|
236
|
+
last = self._last_click
|
|
237
|
+
if last is not None and last[0] == t.key and at - last[1] <= DOUBLE_CLICK_SECONDS and self.steps[-1].get("op") == "click":
|
|
238
|
+
self.steps[-1] = {**self.steps[-1], "op": "dblclick"}
|
|
239
|
+
self._last_click = None
|
|
240
|
+
return describe(self.steps[-1])
|
|
241
|
+
if hover is not None and hover.key != t.key:
|
|
242
|
+
prev = self.steps[-1]
|
|
243
|
+
if not (prev.get("op") == "hover" and _same(prev, hover)):
|
|
244
|
+
self._add({**element_step("hover", hover), "inferred": True})
|
|
245
|
+
self._add(element_step("click", t))
|
|
246
|
+
self._last_click = (t.key, at)
|
|
247
|
+
return describe(self.steps[-1])
|
|
248
|
+
|
|
249
|
+
def on_dblclick(self, t: Target) -> Optional[str]:
|
|
250
|
+
"""The browser's own double-click event: the two clicks before it on the element become one double-click."""
|
|
251
|
+
if self.paused:
|
|
252
|
+
return None
|
|
253
|
+
self._last_action = self.clock()
|
|
254
|
+
if self.steps[-1].get("op") == "dblclick" and _same(self.steps[-1], t):
|
|
255
|
+
return None
|
|
256
|
+
while len(self.steps) > 1 and self.steps[-1].get("op") == "click" and _same(self.steps[-1], t):
|
|
257
|
+
self.steps.pop()
|
|
258
|
+
self._add(element_step("dblclick", t))
|
|
259
|
+
self._last_click = None
|
|
260
|
+
return describe(self.steps[-1])
|
|
261
|
+
|
|
262
|
+
def on_fill(self, t: Target, value: Optional[str], *, password: bool = False) -> Optional[str]:
|
|
263
|
+
"""The final text of a field the tester typed in; `value` is None for a password field (never sent)."""
|
|
264
|
+
if self.paused:
|
|
265
|
+
return None
|
|
266
|
+
self._last_action = self.clock()
|
|
267
|
+
self._last_click = None
|
|
268
|
+
# The click that put the caret in the field, and an earlier fill of the same field just before: one step.
|
|
269
|
+
while len(self.steps) > 1 and self.steps[-1].get("op") in ("click", "input") and _same(self.steps[-1], t):
|
|
270
|
+
self.steps.pop()
|
|
271
|
+
step = element_step("input", t)
|
|
272
|
+
step["clear"] = True
|
|
273
|
+
host = urlparse(t.url).hostname or ""
|
|
274
|
+
if password or value is None:
|
|
275
|
+
env = self._password_env(host)
|
|
276
|
+
step.update(text="", text_env=env)
|
|
277
|
+
if env not in self.secrets:
|
|
278
|
+
self.secrets.append(env)
|
|
279
|
+
else:
|
|
280
|
+
env = next((k for k, v in self.login_values.items() if v and value == v), None)
|
|
281
|
+
if env:
|
|
282
|
+
step.update(text="", text_env=env)
|
|
283
|
+
else:
|
|
284
|
+
var = next((k for k, v in self.variables.items() if v and value == v), None)
|
|
285
|
+
step["text"] = "{{%s}}" % var if var else value
|
|
286
|
+
# A date typed as itself changes meaning tomorrow: it is kept as days from today.
|
|
287
|
+
fresh = None if var else dynamic.relative_date(value)
|
|
288
|
+
if fresh:
|
|
289
|
+
step.update(text=fresh, example=value, generated="date")
|
|
290
|
+
self._add(step)
|
|
291
|
+
return describe(step)
|
|
292
|
+
|
|
293
|
+
def on_select(self, t: Target, text: str) -> Optional[str]:
|
|
294
|
+
if self.paused:
|
|
295
|
+
return None
|
|
296
|
+
self._last_action = self.clock()
|
|
297
|
+
self._last_click = None
|
|
298
|
+
if len(self.steps) > 1 and self.steps[-1].get("op") == "click" and _same(self.steps[-1], t):
|
|
299
|
+
self.steps.pop()
|
|
300
|
+
step = element_step("select", t)
|
|
301
|
+
step["text"] = _clip(text)
|
|
302
|
+
self._add(step)
|
|
303
|
+
return describe(step)
|
|
304
|
+
|
|
305
|
+
def on_press(self, keys: str, *, in_field: bool = False, autocomplete: bool = False) -> Optional[str]:
|
|
306
|
+
if self.paused or not keys:
|
|
307
|
+
return None
|
|
308
|
+
key = keys.split("+")[-1]
|
|
309
|
+
if in_field and "+" not in keys and key not in FIELD_KEYS and not (autocomplete and key in AUTOCOMPLETE_KEYS):
|
|
310
|
+
return None
|
|
311
|
+
self._last_action = self.clock()
|
|
312
|
+
self._last_click = None
|
|
313
|
+
self._add({"op": "press", "keys": keys})
|
|
314
|
+
return describe(self.steps[-1])
|
|
315
|
+
|
|
316
|
+
def on_upload(self, t: Target, files: List[str], refused: List[str]) -> Optional[str]:
|
|
317
|
+
"""Files the tester chose in a file field: `files` relative to the working folder, `refused` why others are not."""
|
|
318
|
+
if self.paused:
|
|
319
|
+
return None
|
|
320
|
+
self._last_action = self.clock()
|
|
321
|
+
self._last_click = None
|
|
322
|
+
for why in refused:
|
|
323
|
+
self._skip("upload", why)
|
|
324
|
+
if not files:
|
|
325
|
+
return f"not recorded: upload: {refused[0]}" if refused else None
|
|
326
|
+
if len(self.steps) > 1 and self.steps[-1].get("op") == "click" and _same(self.steps[-1], t):
|
|
327
|
+
self.steps.pop()
|
|
328
|
+
self._add({**element_step("upload", t), "files": files})
|
|
329
|
+
return describe(self.steps[-1])
|
|
330
|
+
|
|
331
|
+
def on_unsupported(self, action: str, reason: str) -> str:
|
|
332
|
+
"""Something the tester did that cannot be a step yet (an upload, a drag): listed in the result."""
|
|
333
|
+
if not self.paused:
|
|
334
|
+
self._last_action = self.clock()
|
|
335
|
+
return self._skip(action, reason)
|
|
336
|
+
|
|
337
|
+
# -- what the tester added ----------------------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def add_expect(self, kind: str, t: Optional[Target] = None, text: str = "", *, expected: str = "", dynamic: bool = False, source: str = "picked") -> str:
|
|
340
|
+
# On a case step with an expected result, the check is that result unless the tester said otherwise.
|
|
341
|
+
expected = expected or self._case_expected()
|
|
342
|
+
if kind not in EXPECT_KINDS:
|
|
343
|
+
return self._skip("check", f"unknown kind of check {kind!r}")
|
|
344
|
+
if kind in ("url", "title", "intent"):
|
|
345
|
+
step: Dict[str, Any] = {"op": "expect", "kind": kind}
|
|
346
|
+
elif t is None:
|
|
347
|
+
return self._skip("check", "no element was picked")
|
|
348
|
+
else:
|
|
349
|
+
if kind == "value" and t.input_type == "password":
|
|
350
|
+
return self._skip("check", "a password field is never checked")
|
|
351
|
+
step = {**element_step("expect", t), "kind": kind, "_facts": t.facts}
|
|
352
|
+
text = _clip(text)
|
|
353
|
+
if kind not in ("visible", "hidden", "intent"):
|
|
354
|
+
if not text and kind != "checked":
|
|
355
|
+
return self._skip("check", "the element shows no text; check that it is visible instead")
|
|
356
|
+
step["text"] = text
|
|
357
|
+
if kind == "intent":
|
|
358
|
+
if not _clip(expected):
|
|
359
|
+
return self._skip("check", "say what to check")
|
|
360
|
+
if expected:
|
|
361
|
+
step["expected"] = _clip(expected)
|
|
362
|
+
if dynamic:
|
|
363
|
+
step["dynamic"] = True
|
|
364
|
+
# A check of a value the test makes fresh on each run (a date from today, a unique part) checks the same fresh value.
|
|
365
|
+
made = next((s for s in reversed(self.steps) if s.get("op") == "input" and s.get("generated") and s.get("example") == text), None)
|
|
366
|
+
if made is not None:
|
|
367
|
+
step["text"] = made["text"]
|
|
368
|
+
elif kind in ("text", "value") and text:
|
|
369
|
+
self._live(step, text, explicit=dynamic)
|
|
370
|
+
step["source"] = source
|
|
371
|
+
self._add(step)
|
|
372
|
+
return describe(step)
|
|
373
|
+
|
|
374
|
+
def _live(self, step: Dict[str, Any], text: str, *, explicit: bool = False) -> None:
|
|
375
|
+
"""
|
|
376
|
+
A check of data that changes: the same value an earlier step checked or kept becomes that variable; else the
|
|
377
|
+
check is of its pattern, not this run's value.
|
|
378
|
+
"""
|
|
379
|
+
found = dynamic.values(text)
|
|
380
|
+
for v in found:
|
|
381
|
+
name = self._kept_as(v, step)
|
|
382
|
+
if name:
|
|
383
|
+
step.update(equals=name, dynamic=True)
|
|
384
|
+
step.pop("pattern", None)
|
|
385
|
+
return
|
|
386
|
+
pattern = dynamic.pattern_of(text) if found else None
|
|
387
|
+
if pattern:
|
|
388
|
+
step.update(dynamic=True, pattern=pattern)
|
|
389
|
+
elif explicit:
|
|
390
|
+
step["dynamic"] = True
|
|
391
|
+
|
|
392
|
+
def _kept_as(self, value: str, step: Dict[str, Any]) -> Optional[str]:
|
|
393
|
+
"""The variable an earlier check or keep step holds `value` in, made so when that step checked it."""
|
|
394
|
+
for name, v in self.variables.items():
|
|
395
|
+
if v == value and (step.get("keep") or {}).get("name") != name:
|
|
396
|
+
return name
|
|
397
|
+
at = next((i for i, x in enumerate(self.steps) if x is step), len(self.steps))
|
|
398
|
+
for prev in reversed(self.steps[:at]):
|
|
399
|
+
if prev.get("op") != "expect" or prev.get("kind") not in ("text", "value") or value not in dynamic.values(str(prev.get("text") or "")):
|
|
400
|
+
continue
|
|
401
|
+
if prev.get("keep"):
|
|
402
|
+
return str(prev["keep"]["name"])
|
|
403
|
+
name = var_name(_name_hint(prev.get("_facts") or {}) or str(prev.get("label") or ""), self.variables)
|
|
404
|
+
pattern = next(dynamic._token_pattern(k, value, m) for s_, e_, k, m in dynamic._spans(value))
|
|
405
|
+
prev["keep"] = {"name": name, "pattern": pattern}
|
|
406
|
+
self.variables[name] = value
|
|
407
|
+
return name
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
def add_capture(self, t: Target, text: str, name: str = "") -> str:
|
|
411
|
+
text = _clip(text)
|
|
412
|
+
if not text:
|
|
413
|
+
return self._skip("variable", "the element shows no text to keep")
|
|
414
|
+
if t.input_type == "password":
|
|
415
|
+
return self._skip("variable", "a password field is never kept")
|
|
416
|
+
name = var_name(name or _name_hint(t.facts), self.variables)
|
|
417
|
+
self.variables[name] = text
|
|
418
|
+
self._add({**element_step("capture", t), "name": name, "text": text})
|
|
419
|
+
return describe(self.steps[-1])
|
|
420
|
+
|
|
421
|
+
def add_wait(self, t: Target) -> str:
|
|
422
|
+
self._add(element_step("wait_for", t))
|
|
423
|
+
return describe(self.steps[-1])
|
|
424
|
+
|
|
425
|
+
def add_note(self, text: str) -> str:
|
|
426
|
+
text = _clip(text, 400)
|
|
427
|
+
if not text:
|
|
428
|
+
return self._skip("note", "the note is empty")
|
|
429
|
+
step = self.steps[-1]
|
|
430
|
+
step["note"] = f"{step['note']} {text}" if step.get("note") else text
|
|
431
|
+
return f"note on step {len(self.steps)}: {text}"
|
|
432
|
+
|
|
433
|
+
def mark_param(self, name: str = "") -> str:
|
|
434
|
+
"""The last typed value becomes test data: the test reads it from its data instead of spelling it out."""
|
|
435
|
+
step = next((s for s in reversed(self.steps) if s.get("op") == "input"), None)
|
|
436
|
+
if step is None or step.get("text_env") or "{{" in str(step.get("text") or ""):
|
|
437
|
+
return self._skip("test data", "no typed value to turn into test data")
|
|
438
|
+
taken = {s.get("param") for s in self.steps if s.get("param")}
|
|
439
|
+
step["param"] = var_name(name or step.get("label") or "", taken)
|
|
440
|
+
return describe(step)
|
|
441
|
+
|
|
442
|
+
def undo(self) -> str:
|
|
443
|
+
if len(self.steps) <= 1:
|
|
444
|
+
return "nothing to undo"
|
|
445
|
+
step = self.steps.pop()
|
|
446
|
+
if step.get("op") == "capture":
|
|
447
|
+
self.variables.pop(str(step.get("name")), None)
|
|
448
|
+
self._last_click = None
|
|
449
|
+
return f"removed: {describe(step)}"
|
|
450
|
+
|
|
451
|
+
def delete(self, n: int) -> str:
|
|
452
|
+
"""Remove step `n` (1-based); the first step, opening the start page, stays."""
|
|
453
|
+
if n <= 1 or n > len(self.steps):
|
|
454
|
+
return f"no step {n} to remove"
|
|
455
|
+
step = self.steps.pop(n - 1)
|
|
456
|
+
if step.get("op") == "capture":
|
|
457
|
+
self.variables.pop(str(step.get("name")), None)
|
|
458
|
+
self._last_click = None
|
|
459
|
+
return f"removed step {n}: {describe(step)}"
|
|
460
|
+
|
|
461
|
+
def set_kind(self, n: int, kind: str) -> str:
|
|
462
|
+
if not (1 <= n <= len(self.steps)) or self.steps[n - 1].get("op") != "expect":
|
|
463
|
+
return f"step {n} is not a check"
|
|
464
|
+
step = self.steps[n - 1]
|
|
465
|
+
if kind not in ("text", "visible", "hidden", "value") or step.get("kind") in ("url", "title", "intent"):
|
|
466
|
+
return f"a check of this element cannot be {kind!r}"
|
|
467
|
+
step["kind"] = kind
|
|
468
|
+
if kind in ("visible", "hidden"):
|
|
469
|
+
step.pop("text", None)
|
|
470
|
+
return describe(step)
|
|
471
|
+
|
|
472
|
+
def set_dynamic(self, n: int, live: bool) -> str:
|
|
473
|
+
"""The tester's say on live data: a check of its pattern or of this value; a typed date as typed or from today."""
|
|
474
|
+
if not (1 <= n <= len(self.steps)):
|
|
475
|
+
return f"no step {n}"
|
|
476
|
+
step = self.steps[n - 1]
|
|
477
|
+
if step.get("op") == "input" and step.get("generated"):
|
|
478
|
+
if not live:
|
|
479
|
+
step.update(text=step.pop("example"), generated=None)
|
|
480
|
+
step.pop("generated", None)
|
|
481
|
+
return describe(step)
|
|
482
|
+
if step.get("op") != "expect":
|
|
483
|
+
return f"step {n} is not a check"
|
|
484
|
+
if live:
|
|
485
|
+
step["dynamic"] = True
|
|
486
|
+
if step.get("text") and not step.get("equals"):
|
|
487
|
+
self._live(step, str(step["text"]), explicit=True)
|
|
488
|
+
else:
|
|
489
|
+
for k in ("dynamic", "pattern", "equals"):
|
|
490
|
+
step.pop(k, None)
|
|
491
|
+
return describe(step)
|
|
492
|
+
|
|
493
|
+
# -- the test case --------------------------------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
def set_case_step(self, n: int) -> str:
|
|
496
|
+
if not self.case_steps:
|
|
497
|
+
return "no test case is being recorded"
|
|
498
|
+
if not 1 <= n <= len(self.case_steps):
|
|
499
|
+
return f"the test case has no step {n}"
|
|
500
|
+
self.case_at = n
|
|
501
|
+
return f"on case step {n}: {_clip(self.case_steps[n - 1].get('action'), 120)}"
|
|
502
|
+
|
|
503
|
+
def next_case_step(self) -> str:
|
|
504
|
+
return self.set_case_step(min(self.case_at + 1, len(self.case_steps))) if self.case_steps else "no test case is being recorded"
|
|
505
|
+
|
|
506
|
+
def case_gaps(self) -> List[Dict[str, Any]]:
|
|
507
|
+
"""The case steps with an expected result and no check recorded on them."""
|
|
508
|
+
checked = {s.get("case_step") for s in self.steps if s.get("op") == "expect"}
|
|
509
|
+
return [{"n": i + 1, "expected": _clip(c.get("expected"))} for i, c in enumerate(self.case_steps) if _clip(c.get("expected")) and (i + 1) not in checked]
|
|
510
|
+
|
|
511
|
+
def case_view(self) -> List[Dict[str, Any]]:
|
|
512
|
+
checked = {s.get("case_step") for s in self.steps if s.get("op") == "expect"}
|
|
513
|
+
done = {s.get("case_step") for s in self.steps}
|
|
514
|
+
return [
|
|
515
|
+
{"n": i + 1, "action": _clip(c.get("action"), 160), **({"expected": _clip(c.get("expected"))} if c.get("expected") else {}), "current": i + 1 == self.case_at, "checked": (i + 1) in checked, "done": (i + 1) in done}
|
|
516
|
+
for i, c in enumerate(self.case_steps)
|
|
517
|
+
]
|
|
518
|
+
|
|
519
|
+
def _case_expected(self) -> str:
|
|
520
|
+
if not self.case_at or self.case_at > len(self.case_steps):
|
|
521
|
+
return ""
|
|
522
|
+
return _clip(self.case_steps[self.case_at - 1].get("expected"))
|
|
523
|
+
|
|
524
|
+
def _add(self, step: Dict[str, Any]) -> None:
|
|
525
|
+
if self.case_at:
|
|
526
|
+
step.setdefault("case_step", self.case_at)
|
|
527
|
+
self.steps.append(step)
|
|
528
|
+
|
|
529
|
+
# -- the result ---------------------------------------------------------------------------------------------------
|
|
530
|
+
|
|
531
|
+
@property
|
|
532
|
+
def checks(self) -> int:
|
|
533
|
+
return sum(1 for s in self.steps if s.get("op") == "expect")
|
|
534
|
+
|
|
535
|
+
def view(self) -> List[Dict[str, Any]]:
|
|
536
|
+
"""The steps as the Recorder lists them."""
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
"n": i + 1,
|
|
540
|
+
"op": s.get("op"),
|
|
541
|
+
"text": describe(s),
|
|
542
|
+
**({"kind": s["kind"]} if s.get("kind") else {}),
|
|
543
|
+
**({"dynamic": True} if s.get("dynamic") else {}),
|
|
544
|
+
**({"note": s["note"]} if s.get("note") else {}),
|
|
545
|
+
**({"inferred": True} if s.get("inferred") else {}),
|
|
546
|
+
**({"case_step": s["case_step"]} if s.get("case_step") else {}),
|
|
547
|
+
**({"generated": s["generated"]} if s.get("generated") else {}),
|
|
548
|
+
}
|
|
549
|
+
for i, s in enumerate(self.steps)
|
|
550
|
+
]
|
|
551
|
+
|
|
552
|
+
def summary(self) -> str:
|
|
553
|
+
n = len(self.steps)
|
|
554
|
+
parts = [f"{n} step{'s' if n != 1 else ''}"]
|
|
555
|
+
if self.checks:
|
|
556
|
+
parts.append(f"{self.checks} check{'s' if self.checks != 1 else ''}")
|
|
557
|
+
if self.variables:
|
|
558
|
+
parts.append(f"{len(self.variables)} variable{'s' if len(self.variables) != 1 else ''}")
|
|
559
|
+
return ", ".join(parts)
|
|
560
|
+
|
|
561
|
+
# -- helpers ------------------------------------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
def _password_env(self, host: str) -> str:
|
|
564
|
+
host = host.lower()
|
|
565
|
+
for hosts, env in self.login_passwords.items():
|
|
566
|
+
if any(host == h or host.endswith("." + h) for h in hosts.split(",") if h):
|
|
567
|
+
return env
|
|
568
|
+
return secret_env_for(host)
|
|
569
|
+
|
|
570
|
+
def _skip(self, action: str, reason: str) -> str:
|
|
571
|
+
self.skipped.append({"index": len(self.skipped) + 1, "action": action, "reason": reason})
|
|
572
|
+
return f"not recorded: {action}: {reason}"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _name_hint(facts: Dict[str, Any]) -> str:
|
|
576
|
+
"""What to call a kept value: the element's test id, id, name, or label (data-testid="order-id" → order_id), not its text."""
|
|
577
|
+
a = facts.get("attributes") or {}
|
|
578
|
+
for k in (*TEST_ID_ATTRS, "id", "name", "aria-label", "title"):
|
|
579
|
+
v = str(a.get(k) or "")
|
|
580
|
+
if v and not looks_generated(v):
|
|
581
|
+
return v
|
|
582
|
+
return str(facts.get("name") or "") if facts.get("tag") in ("input", "select", "textarea") else ""
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _same(step: Dict[str, Any], t: Target) -> bool:
|
|
586
|
+
"""Whether a recorded step acted on target `t`: the same page and the same best locator."""
|
|
587
|
+
locs = step.get("locators") or []
|
|
588
|
+
best = locs[0] if locs else {}
|
|
589
|
+
return (step.get("page"), f"{best.get('type')}:{best.get('value')}") == t.key
|