visus-web 0.1.0__py3-none-any.whl
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.
- visus/web/__init__.py +64 -0
- visus/web/api/__init__.py +0 -0
- visus/web/api/_htmlsel.py +175 -0
- visus/web/api/_steps.py +235 -0
- visus/web/api/assertions.py +257 -0
- visus/web/api/browser.py +47 -0
- visus/web/api/context.py +78 -0
- visus/web/api/events.py +91 -0
- visus/web/api/fields.py +41 -0
- visus/web/api/frame_locator.py +51 -0
- visus/web/api/input.py +70 -0
- visus/web/api/locator.py +512 -0
- visus/web/api/page.py +472 -0
- visus/web/async_api/__init__.py +1205 -0
- visus/web/backends/__init__.py +0 -0
- visus/web/backends/base.py +161 -0
- visus/web/backends/browsers/__init__.py +0 -0
- visus/web/backends/browsers/_ie_preflight.py +127 -0
- visus/web/backends/browsers/chrome.py +45 -0
- visus/web/backends/browsers/edge.py +45 -0
- visus/web/backends/browsers/edge_ie.py +102 -0
- visus/web/backends/browsers/firefox.py +30 -0
- visus/web/backends/selenium/__init__.py +0 -0
- visus/web/backends/selenium/_diagnostics.py +179 -0
- visus/web/backends/selenium/actionability.py +132 -0
- visus/web/backends/selenium/driver_delegate.py +1319 -0
- visus/web/backends/selenium/expect_engine.py +214 -0
- visus/web/backends/selenium/js/__init__.py +7 -0
- visus/web/backends/selenium/js/bundle.js +885 -0
- visus/web/backends/selenium/resolver.py +68 -0
- visus/web/backends/selenium_backend.py +193 -0
- visus/web/cli/__init__.py +1 -0
- visus/web/cli/codegen.py +86 -0
- visus/web/cli/console.py +249 -0
- visus/web/cli/main.py +906 -0
- visus/web/cli/session_client.py +231 -0
- visus/web/cli/session_server.py +777 -0
- visus/web/config.py +13 -0
- visus/web/engine.py +23 -0
- visus/web/errors.py +31 -0
- visus/web/mcp/__init__.py +1 -0
- visus/web/mcp/server.py +761 -0
- visus/web/mcp/session.py +160 -0
- visus/web/network.py +444 -0
- visus/web/observability/__init__.py +0 -0
- visus/web/observability/recorder.py +225 -0
- visus/web/observability/report.py +689 -0
- visus/web/py.typed +0 -0
- visus/web/pytest_plugin.py +115 -0
- visus/web/registry.py +43 -0
- visus/web/rpa.py +145 -0
- visus/web/tracing.py +104 -0
- visus/web/vision/__init__.py +6 -0
- visus/web/vision/_images.py +42 -0
- visus/web/vision/imaging.py +72 -0
- visus/web/vision/ocr.py +46 -0
- visus_web-0.1.0.dist-info/METADATA +1110 -0
- visus_web-0.1.0.dist-info/RECORD +61 -0
- visus_web-0.1.0.dist-info/WHEEL +4 -0
- visus_web-0.1.0.dist-info/entry_points.txt +6 -0
- visus_web-0.1.0.dist-info/licenses/LICENSE +109 -0
visus/web/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""visus.web — Playwright-style web automation on a Selenium engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from visus.web import errors, tracing
|
|
6
|
+
from visus.web.api.assertions import expect
|
|
7
|
+
from visus.web.api.browser import Browser
|
|
8
|
+
from visus.web.api.context import Context
|
|
9
|
+
from visus.web.api.fields import Field
|
|
10
|
+
from visus.web.api.page import Page
|
|
11
|
+
from visus.web.backends.selenium_backend import SeleniumBackend
|
|
12
|
+
from visus.web.config import Defaults
|
|
13
|
+
from visus.web.engine import Engine
|
|
14
|
+
from visus.web.registry import get_browser_config
|
|
15
|
+
from visus.web.rpa import rpa
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"launch",
|
|
21
|
+
"rpa",
|
|
22
|
+
"expect",
|
|
23
|
+
"Engine",
|
|
24
|
+
"Browser",
|
|
25
|
+
"Context",
|
|
26
|
+
"Page",
|
|
27
|
+
"Field",
|
|
28
|
+
"errors",
|
|
29
|
+
"tracing",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def launch(
|
|
34
|
+
engine: Engine | str = Engine.CHROME,
|
|
35
|
+
*,
|
|
36
|
+
headless: bool = False,
|
|
37
|
+
slow_mo: int = 0,
|
|
38
|
+
user_data_dir: str | None = None,
|
|
39
|
+
remote_url: str | None = None,
|
|
40
|
+
) -> Browser:
|
|
41
|
+
"""Launch a browser and return a Browser handle.
|
|
42
|
+
|
|
43
|
+
*slow_mo* delays every action/navigation by the given milliseconds — handy
|
|
44
|
+
to watch a script drive the page in real time.
|
|
45
|
+
|
|
46
|
+
*user_data_dir* reuses (or creates) a persistent browser profile instead of
|
|
47
|
+
a throwaway temp profile — cookies, localStorage and logins survive across
|
|
48
|
+
runs. The directory is never deleted by visus.
|
|
49
|
+
|
|
50
|
+
*remote_url* opens the session on a Selenium Grid / Remote WebDriver
|
|
51
|
+
(e.g. ``"http://grid:4444/wd/hub"``) instead of spawning a local browser.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
with launch(headless=True) as browser:
|
|
55
|
+
page = browser.new_page()
|
|
56
|
+
page.goto("https://example.com")
|
|
57
|
+
"""
|
|
58
|
+
resolved = Engine.from_str(engine)
|
|
59
|
+
config = get_browser_config(resolved)
|
|
60
|
+
backend = SeleniumBackend()
|
|
61
|
+
delegate = backend.launch(
|
|
62
|
+
config, headless=headless, user_data_dir=user_data_dir, remote_url=remote_url
|
|
63
|
+
)
|
|
64
|
+
return Browser(delegate, Defaults(slow_mo_ms=slow_mo))
|
|
File without changes
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Build a resilient *smart* locator from a pasted DevTools element snippet.
|
|
2
|
+
|
|
3
|
+
When a selector is an HTML element (e.g. copied via DevTools → "Copy element"),
|
|
4
|
+
this derives an ordered list of candidate selectors — CSS-first, most-likely-unique
|
|
5
|
+
first — that the engine tries in order until one matches. Fault-tolerant: stale or
|
|
6
|
+
non-matching candidates (an id that changed, a class that moved) are skipped, and a
|
|
7
|
+
candidate that matches a *single* element is preferred over an ambiguous one.
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
page.locator('<input name="query" aria-label="Search" class="md-search__input">')
|
|
12
|
+
# tries: input[name="query"] → [aria-label="Search"] → input.md-search__input → xpath…
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from html.parser import HTMLParser
|
|
19
|
+
|
|
20
|
+
_SIMPLE_IDENT = re.compile(r"^[A-Za-z_-][\w-]*$")
|
|
21
|
+
_TEST_HOOKS = ("data-testid", "data-test", "data-test-id", "data-qa", "data-cy")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _FirstElement(HTMLParser):
|
|
25
|
+
"""Capture the tag, attributes, and text of the first element in a snippet."""
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.tag: str | None = None
|
|
30
|
+
self.attrs: dict[str, str] = {}
|
|
31
|
+
self._depth = 0
|
|
32
|
+
self._text: list[str] = []
|
|
33
|
+
|
|
34
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
35
|
+
if self.tag is None:
|
|
36
|
+
self.tag = tag
|
|
37
|
+
self.attrs = {k: (v or "") for k, v in attrs}
|
|
38
|
+
self._depth = 1
|
|
39
|
+
elif self._depth > 0:
|
|
40
|
+
self._depth += 1
|
|
41
|
+
|
|
42
|
+
def handle_endtag(self, tag: str) -> None:
|
|
43
|
+
if self._depth > 0:
|
|
44
|
+
self._depth -= 1
|
|
45
|
+
|
|
46
|
+
def handle_data(self, data: str) -> None:
|
|
47
|
+
if self.tag is not None and self._depth >= 1:
|
|
48
|
+
self._text.append(data)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def text(self) -> str:
|
|
52
|
+
return " ".join("".join(self._text).split())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse(snippet: str) -> tuple[str | None, dict[str, str], str]:
|
|
56
|
+
"""Return (tag, attrs, text) for the first element in *snippet*."""
|
|
57
|
+
p = _FirstElement()
|
|
58
|
+
p.feed(snippet)
|
|
59
|
+
return p.tag, p.attrs, p.text
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _eq(value: str) -> str:
|
|
63
|
+
"""Escape a value for a CSS ``[attr="value"]`` selector."""
|
|
64
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _css_ident_escape(ident: str) -> str:
|
|
68
|
+
"""Escape a class/id identifier for use in a CSS selector.
|
|
69
|
+
|
|
70
|
+
Handles Tailwind-style classes such as ``file:border-0`` → ``file\\:border-0``
|
|
71
|
+
or ``pr-[88px]`` → ``pr-\\[88px\\]``. ``\\w`` is unicode-aware, so accented
|
|
72
|
+
class names stay intact; only ASCII punctuation is backslash-escaped.
|
|
73
|
+
"""
|
|
74
|
+
return re.sub(r"([^\w-])", r"\\\1", ident)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _xq(value: str) -> str:
|
|
78
|
+
"""Render *value* as an XPath string literal (handling embedded quotes)."""
|
|
79
|
+
if '"' not in value:
|
|
80
|
+
return f'"{value}"'
|
|
81
|
+
if "'" not in value:
|
|
82
|
+
return f"'{value}'"
|
|
83
|
+
parts = value.split('"')
|
|
84
|
+
return "concat(" + ", '\"', ".join(f'"{p}"' for p in parts) + ")"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def candidates(tag: str, attrs: dict[str, str], text: str) -> list[dict[str, str]]:
|
|
88
|
+
"""Ordered candidate selectors (CSS-first, most-likely-unique first)."""
|
|
89
|
+
out: list[dict[str, str]] = []
|
|
90
|
+
seen: set[tuple[str, str]] = set()
|
|
91
|
+
|
|
92
|
+
def add(kind: str, value: str) -> None:
|
|
93
|
+
key = (kind, value)
|
|
94
|
+
if value and key not in seen:
|
|
95
|
+
seen.add(key)
|
|
96
|
+
out.append({kind: value})
|
|
97
|
+
|
|
98
|
+
t = tag or "*"
|
|
99
|
+
classes = (attrs.get("class") or "").split()
|
|
100
|
+
|
|
101
|
+
if attrs.get("id"):
|
|
102
|
+
aid = attrs["id"]
|
|
103
|
+
add("css", f"#{aid}" if _SIMPLE_IDENT.match(aid) else f'[id="{_eq(aid)}"]')
|
|
104
|
+
for k in _TEST_HOOKS:
|
|
105
|
+
if attrs.get(k):
|
|
106
|
+
add("css", f'[{k}="{_eq(attrs[k])}"]')
|
|
107
|
+
if attrs.get("name"):
|
|
108
|
+
add("css", f'{t}[name="{_eq(attrs["name"])}"]')
|
|
109
|
+
for k, v in attrs.items():
|
|
110
|
+
if k.startswith("data-") and v and k not in _TEST_HOOKS:
|
|
111
|
+
add("css", f'{t}[{k}="{_eq(v)}"]')
|
|
112
|
+
if attrs.get("aria-label"):
|
|
113
|
+
add("css", f'[aria-label="{_eq(attrs["aria-label"])}"]')
|
|
114
|
+
if attrs.get("placeholder"):
|
|
115
|
+
add("css", f'[placeholder="{_eq(attrs["placeholder"])}"]')
|
|
116
|
+
if classes:
|
|
117
|
+
add("css", t + "".join("." + _css_ident_escape(c) for c in classes))
|
|
118
|
+
combined = _combined_css(t, attrs)
|
|
119
|
+
if combined:
|
|
120
|
+
add("css", combined)
|
|
121
|
+
if text:
|
|
122
|
+
add("xpath", f"//{t}[normalize-space()={_xq(text)}]")
|
|
123
|
+
for k in ("name", "aria-label", "placeholder"):
|
|
124
|
+
if attrs.get(k):
|
|
125
|
+
add("xpath", f"//{t}[@{k}={_xq(attrs[k])}]")
|
|
126
|
+
if attrs.get("class"):
|
|
127
|
+
add("xpath", f"//{t}[@class={_xq(attrs['class'])}]")
|
|
128
|
+
if not out:
|
|
129
|
+
add("css", t)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _combined_css(tag: str, attrs: dict[str, str]) -> str:
|
|
134
|
+
"""A tighter CSS selector combining a couple of identifying attributes."""
|
|
135
|
+
keys = [k for k in ("name", "type", "role", "href", "aria-label") if attrs.get(k)]
|
|
136
|
+
if len(keys) < 2:
|
|
137
|
+
return ""
|
|
138
|
+
return tag + "".join(f'[{k}="{_eq(attrs[k])}"]' for k in keys[:3])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def smart_step(snippet: str) -> dict[str, object]:
|
|
142
|
+
"""Parse a pasted element snippet into a ``smart`` locator step."""
|
|
143
|
+
tag, attrs, text = parse(snippet)
|
|
144
|
+
return {"kind": "smart", "tag": tag or "*", "candidates": candidates(tag or "*", attrs, text)}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def translate(snippet: str) -> dict[str, object]:
|
|
148
|
+
"""Translate a pasted DevTools element into id / css / xpath / class selectors.
|
|
149
|
+
|
|
150
|
+
Returns the recommended selector in each format plus the full ordered candidate
|
|
151
|
+
list (the same order the smart locator tries them in). Powers ``visus translate``
|
|
152
|
+
and the ``browser_translate_element`` MCP tool.
|
|
153
|
+
"""
|
|
154
|
+
tag, attrs, text = parse(snippet)
|
|
155
|
+
t = tag or "*"
|
|
156
|
+
cands = candidates(t, attrs, text)
|
|
157
|
+
css = [c["css"] for c in cands if "css" in c]
|
|
158
|
+
xpath = [c["xpath"] for c in cands if "xpath" in c]
|
|
159
|
+
classes = (attrs.get("class") or "").split()
|
|
160
|
+
aid = attrs.get("id")
|
|
161
|
+
id_sel = None
|
|
162
|
+
if aid:
|
|
163
|
+
id_sel = f"#{aid}" if _SIMPLE_IDENT.match(aid) else f'[id="{_eq(aid)}"]'
|
|
164
|
+
class_sel = t + "".join("." + _css_ident_escape(c) for c in classes) if classes else None
|
|
165
|
+
return {
|
|
166
|
+
"tag": t,
|
|
167
|
+
"id": id_sel,
|
|
168
|
+
"name": attrs.get("name"),
|
|
169
|
+
"css": css[0] if css else None,
|
|
170
|
+
"xpath": xpath[0] if xpath else None,
|
|
171
|
+
"class": class_sel,
|
|
172
|
+
"classes": classes,
|
|
173
|
+
"candidates_css": css,
|
|
174
|
+
"candidates_xpath": xpath,
|
|
175
|
+
}
|
visus/web/api/_steps.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Backtrack helper for visus.web actions.
|
|
2
|
+
|
|
3
|
+
run_step() executes an action and, on VisusWebError, optionally *backtracks*: it
|
|
4
|
+
re-runs the last N successful steps (oldest→newest), then retries the failed step
|
|
5
|
+
**once**. ``backtrack`` is the depth — how many previous steps to replay before
|
|
6
|
+
retrying:
|
|
7
|
+
|
|
8
|
+
* ``False`` / ``0`` → disabled (the error propagates immediately)
|
|
9
|
+
* ``True`` → replay 1 previous step (the default depth)
|
|
10
|
+
* ``N`` → replay the last ``min(N, 3)`` steps
|
|
11
|
+
|
|
12
|
+
Example — steps 1, 2, 3, 4 where step 4 fails:
|
|
13
|
+
``backtrack=3`` re-runs 1, 2, 3 (in order) then retries 4;
|
|
14
|
+
``backtrack=1`` re-runs 3 then retries 4.
|
|
15
|
+
|
|
16
|
+
Backtrack is **not** a retry loop: the failed step is retried exactly once after
|
|
17
|
+
the replay. Each delegate carries a rolling history of its last 3 successful
|
|
18
|
+
steps, so this behaves identically for the sync and async (to_thread) APIs.
|
|
19
|
+
|
|
20
|
+
When tracing is active each step is timed and an event (+ highlighted screenshot)
|
|
21
|
+
is emitted via the active Recorder — *including the replayed backtrack steps*,
|
|
22
|
+
which appear in the report timeline as ``↻`` events. With tracing OFF the
|
|
23
|
+
behaviour is the same minus the recording overhead.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from visus.web import errors
|
|
34
|
+
|
|
35
|
+
_log = logging.getLogger("visus.web")
|
|
36
|
+
|
|
37
|
+
MAX_BACKTRACK = 3 # how many previous steps the engine remembers and can replay
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_depth(backtrack: bool | int) -> int:
|
|
41
|
+
"""Backtrack depth: False/0 → 0 (off), True → 1, N → min(N, MAX_BACKTRACK)."""
|
|
42
|
+
return max(0, min(int(backtrack), MAX_BACKTRACK))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _record_step(delegate: object, action: Callable[[], None]) -> None:
|
|
46
|
+
"""Append a successful action to the delegate's rolling step history (last 3)."""
|
|
47
|
+
history: list[Callable[[], None]] | None = getattr(delegate, "_step_history", None)
|
|
48
|
+
if history is None:
|
|
49
|
+
history = []
|
|
50
|
+
delegate._step_history = history # type: ignore[attr-defined]
|
|
51
|
+
history.append(action)
|
|
52
|
+
del history[:-MAX_BACKTRACK] # keep only the most recent MAX_BACKTRACK steps
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _replay_one(step: Callable[[], None], recorder: Any, delegate: object) -> None:
|
|
56
|
+
"""Re-run a previous step during a backtrack.
|
|
57
|
+
|
|
58
|
+
When tracing, record it as a ``↻`` replay event (with a highlighted screenshot)
|
|
59
|
+
so the backtrack's replayed steps are visible in the report — using the metadata
|
|
60
|
+
tagged onto the action by :func:`run_step`. A replay failure is recorded then
|
|
61
|
+
re-raised.
|
|
62
|
+
"""
|
|
63
|
+
meta = getattr(step, "_visus_step", None)
|
|
64
|
+
if recorder is None or meta is None:
|
|
65
|
+
step() # plain replay (no tracing, or an untagged history entry)
|
|
66
|
+
return
|
|
67
|
+
start = time.monotonic()
|
|
68
|
+
start_ts = time.time()
|
|
69
|
+
success = True
|
|
70
|
+
error: str | None = None
|
|
71
|
+
try:
|
|
72
|
+
step()
|
|
73
|
+
except errors.VisusWebError as exc:
|
|
74
|
+
success = False
|
|
75
|
+
error = str(exc)
|
|
76
|
+
raise
|
|
77
|
+
finally:
|
|
78
|
+
recorder.record_action(
|
|
79
|
+
delegate,
|
|
80
|
+
action="replay " + str(meta.get("name") or "step"),
|
|
81
|
+
selector=meta.get("selector"),
|
|
82
|
+
target=meta.get("target"),
|
|
83
|
+
start_ts=start_ts,
|
|
84
|
+
end_ts=time.time(),
|
|
85
|
+
duration_ms=int((time.monotonic() - start) * 1000),
|
|
86
|
+
success=success,
|
|
87
|
+
error=error,
|
|
88
|
+
backtrack_steps=0,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _execute(delegate: object, action: Callable[[], None], depth: int, recorder: Any = None) -> int:
|
|
93
|
+
"""Run ``action`` once; backtrack on failure.
|
|
94
|
+
|
|
95
|
+
On :exc:`~visus.web.errors.VisusWebError`, if *depth* > 0, re-run the last
|
|
96
|
+
*depth* successful steps (oldest→newest) then retry ``action`` exactly once.
|
|
97
|
+
Each replayed step is recorded via *recorder* when tracing is active. Returns
|
|
98
|
+
the number of steps replayed (0 when the action succeeded on the first try).
|
|
99
|
+
Records the action as a successful step on success; re-raises if it still fails
|
|
100
|
+
or if there is nothing to backtrack to.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
action()
|
|
104
|
+
except errors.VisusWebError:
|
|
105
|
+
history: list[Callable[[], None]] = list(getattr(delegate, "_step_history", None) or [])
|
|
106
|
+
replay = history[-depth:] if depth > 0 else []
|
|
107
|
+
if not replay:
|
|
108
|
+
raise # backtrack disabled, or no previous step to return to
|
|
109
|
+
attempted = len(replay)
|
|
110
|
+
_log.info("backtrack: replaying %d previous step(s) before retry", attempted)
|
|
111
|
+
in_replay = True
|
|
112
|
+
try:
|
|
113
|
+
for step in replay:
|
|
114
|
+
_replay_one(step, recorder, delegate) # re-run (and record) the previous step(s)
|
|
115
|
+
in_replay = False
|
|
116
|
+
action() # then retry the failed step ONCE
|
|
117
|
+
except errors.VisusWebError as exc:
|
|
118
|
+
# A depth-N backtrack was attempted; tag it so the traced report records
|
|
119
|
+
# the backtrack even when a replayed step (or the retry) ultimately fails.
|
|
120
|
+
exc.backtrack_steps = attempted # type: ignore[attr-defined]
|
|
121
|
+
if in_replay:
|
|
122
|
+
_log.warning("backtrack: a replayed step failed — the page may have moved on")
|
|
123
|
+
# `from None`: the first attempt and the retry are the same failure — don't
|
|
124
|
+
# chain them into a noisy "During handling of the above exception" traceback.
|
|
125
|
+
raise exc from None
|
|
126
|
+
_record_step(delegate, action)
|
|
127
|
+
return attempted
|
|
128
|
+
_record_step(delegate, action)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run_step(
|
|
133
|
+
delegate: object,
|
|
134
|
+
action: Callable[[], None],
|
|
135
|
+
backtrack: bool | int,
|
|
136
|
+
*,
|
|
137
|
+
action_name: str = "action",
|
|
138
|
+
selector: str | None = None,
|
|
139
|
+
target: str | None = None,
|
|
140
|
+
slow_mo_ms: int = 0,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Run ``action()`` with optional backtrack recovery.
|
|
143
|
+
|
|
144
|
+
On :exc:`~visus.web.errors.VisusWebError`, if *backtrack* is truthy, re-run
|
|
145
|
+
the last ``int(backtrack)`` (capped at 3) successful steps then retry
|
|
146
|
+
``action`` once. ``True`` → depth 1. On success, record ``action`` as the
|
|
147
|
+
delegate's most recent successful step.
|
|
148
|
+
|
|
149
|
+
*action_name*, *selector*, and *target* are forwarded to the :class:`Recorder`
|
|
150
|
+
when tracing is active (and tagged onto *action* so a later backtrack replay of
|
|
151
|
+
this step can be recorded too); they are ignored on the fast path.
|
|
152
|
+
"""
|
|
153
|
+
from visus.web import tracing
|
|
154
|
+
|
|
155
|
+
if slow_mo_ms > 0:
|
|
156
|
+
time.sleep(slow_mo_ms / 1000)
|
|
157
|
+
|
|
158
|
+
# Tag the action with its metadata so that, if a later step backtracks and
|
|
159
|
+
# replays this one, the replay can be recorded with the right label/screenshot.
|
|
160
|
+
action._visus_step = { # type: ignore[attr-defined]
|
|
161
|
+
"name": action_name,
|
|
162
|
+
"selector": selector,
|
|
163
|
+
"target": target,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if not tracing.is_enabled() or tracing.current_recorder() is None:
|
|
167
|
+
_run_plain(delegate, action, backtrack)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
_run_traced(
|
|
171
|
+
delegate,
|
|
172
|
+
action,
|
|
173
|
+
backtrack,
|
|
174
|
+
action_name=action_name,
|
|
175
|
+
selector=selector,
|
|
176
|
+
target=target,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _run_plain(delegate: object, action: Callable[[], None], backtrack: bool | int) -> None:
|
|
181
|
+
"""Fast path: replay-then-retry backtrack with zero tracing overhead."""
|
|
182
|
+
_execute(delegate, action, _coerce_depth(backtrack))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _run_traced(
|
|
186
|
+
delegate: object,
|
|
187
|
+
action: Callable[[], None],
|
|
188
|
+
backtrack: bool | int,
|
|
189
|
+
*,
|
|
190
|
+
action_name: str,
|
|
191
|
+
selector: str | None,
|
|
192
|
+
target: str | None,
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Traced path: same semantics as _run_plain but times the step and records an event."""
|
|
195
|
+
from visus.web import tracing
|
|
196
|
+
|
|
197
|
+
rec = tracing.current_recorder()
|
|
198
|
+
# rec should not be None here (caller checked), but guard defensively
|
|
199
|
+
if rec is None:
|
|
200
|
+
_run_plain(delegate, action, backtrack)
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
depth = _coerce_depth(backtrack)
|
|
204
|
+
start = time.monotonic()
|
|
205
|
+
start_ts = time.time()
|
|
206
|
+
steps_replayed = 0
|
|
207
|
+
error: str | None = None
|
|
208
|
+
success = False
|
|
209
|
+
label = target or selector or ""
|
|
210
|
+
_log.info("%s → %s", action_name, label)
|
|
211
|
+
try:
|
|
212
|
+
steps_replayed = _execute(delegate, action, depth, recorder=rec)
|
|
213
|
+
success = True
|
|
214
|
+
except errors.VisusWebError as exc:
|
|
215
|
+
error = str(exc)
|
|
216
|
+
steps_replayed = getattr(exc, "backtrack_steps", 0) # backtrack tried but failed
|
|
217
|
+
raise
|
|
218
|
+
finally:
|
|
219
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
220
|
+
if success:
|
|
221
|
+
_log.info("%s ✓ (%dms, back %d step(s))", action_name, duration_ms, steps_replayed)
|
|
222
|
+
else:
|
|
223
|
+
_log.warning("%s ✗ %s", action_name, error or "")
|
|
224
|
+
rec.record_action(
|
|
225
|
+
delegate,
|
|
226
|
+
action=action_name,
|
|
227
|
+
selector=selector,
|
|
228
|
+
target=target,
|
|
229
|
+
start_ts=start_ts,
|
|
230
|
+
end_ts=time.time(),
|
|
231
|
+
duration_ms=duration_ms,
|
|
232
|
+
success=success,
|
|
233
|
+
error=error,
|
|
234
|
+
backtrack_steps=steps_replayed,
|
|
235
|
+
)
|