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.
Files changed (61) hide show
  1. visus/web/__init__.py +64 -0
  2. visus/web/api/__init__.py +0 -0
  3. visus/web/api/_htmlsel.py +175 -0
  4. visus/web/api/_steps.py +235 -0
  5. visus/web/api/assertions.py +257 -0
  6. visus/web/api/browser.py +47 -0
  7. visus/web/api/context.py +78 -0
  8. visus/web/api/events.py +91 -0
  9. visus/web/api/fields.py +41 -0
  10. visus/web/api/frame_locator.py +51 -0
  11. visus/web/api/input.py +70 -0
  12. visus/web/api/locator.py +512 -0
  13. visus/web/api/page.py +472 -0
  14. visus/web/async_api/__init__.py +1205 -0
  15. visus/web/backends/__init__.py +0 -0
  16. visus/web/backends/base.py +161 -0
  17. visus/web/backends/browsers/__init__.py +0 -0
  18. visus/web/backends/browsers/_ie_preflight.py +127 -0
  19. visus/web/backends/browsers/chrome.py +45 -0
  20. visus/web/backends/browsers/edge.py +45 -0
  21. visus/web/backends/browsers/edge_ie.py +102 -0
  22. visus/web/backends/browsers/firefox.py +30 -0
  23. visus/web/backends/selenium/__init__.py +0 -0
  24. visus/web/backends/selenium/_diagnostics.py +179 -0
  25. visus/web/backends/selenium/actionability.py +132 -0
  26. visus/web/backends/selenium/driver_delegate.py +1319 -0
  27. visus/web/backends/selenium/expect_engine.py +214 -0
  28. visus/web/backends/selenium/js/__init__.py +7 -0
  29. visus/web/backends/selenium/js/bundle.js +885 -0
  30. visus/web/backends/selenium/resolver.py +68 -0
  31. visus/web/backends/selenium_backend.py +193 -0
  32. visus/web/cli/__init__.py +1 -0
  33. visus/web/cli/codegen.py +86 -0
  34. visus/web/cli/console.py +249 -0
  35. visus/web/cli/main.py +906 -0
  36. visus/web/cli/session_client.py +231 -0
  37. visus/web/cli/session_server.py +777 -0
  38. visus/web/config.py +13 -0
  39. visus/web/engine.py +23 -0
  40. visus/web/errors.py +31 -0
  41. visus/web/mcp/__init__.py +1 -0
  42. visus/web/mcp/server.py +761 -0
  43. visus/web/mcp/session.py +160 -0
  44. visus/web/network.py +444 -0
  45. visus/web/observability/__init__.py +0 -0
  46. visus/web/observability/recorder.py +225 -0
  47. visus/web/observability/report.py +689 -0
  48. visus/web/py.typed +0 -0
  49. visus/web/pytest_plugin.py +115 -0
  50. visus/web/registry.py +43 -0
  51. visus/web/rpa.py +145 -0
  52. visus/web/tracing.py +104 -0
  53. visus/web/vision/__init__.py +6 -0
  54. visus/web/vision/_images.py +42 -0
  55. visus/web/vision/imaging.py +72 -0
  56. visus/web/vision/ocr.py +46 -0
  57. visus_web-0.1.0.dist-info/METADATA +1110 -0
  58. visus_web-0.1.0.dist-info/RECORD +61 -0
  59. visus_web-0.1.0.dist-info/WHEEL +4 -0
  60. visus_web-0.1.0.dist-info/entry_points.txt +6 -0
  61. 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
+ }
@@ -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
+ )