@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.
@@ -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