selenium-expect 1.0.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.
- selenium_expect/__init__.py +35 -0
- selenium_expect/_compose.py +117 -0
- selenium_expect/_config.py +96 -0
- selenium_expect/_errors.py +57 -0
- selenium_expect/_expect.py +181 -0
- selenium_expect/_locator.py +157 -0
- selenium_expect/_matcher.py +111 -0
- selenium_expect/_poll.py +221 -0
- selenium_expect/_retry.py +145 -0
- selenium_expect/_soft.py +49 -0
- selenium_expect/assertions/__init__.py +30 -0
- selenium_expect/assertions/_base.py +311 -0
- selenium_expect/assertions/alert.py +131 -0
- selenium_expect/assertions/cookie.py +330 -0
- selenium_expect/assertions/driver.py +681 -0
- selenium_expect/assertions/element.py +1755 -0
- selenium_expect/assertions/iframe.py +191 -0
- selenium_expect/assertions/js.py +353 -0
- selenium_expect/assertions/list.py +763 -0
- selenium_expect/assertions/select.py +386 -0
- selenium_expect/assertions/shadow.py +175 -0
- selenium_expect/assertions/window.py +118 -0
- selenium_expect/py.typed +0 -0
- selenium_expect-1.0.0.dist-info/METADATA +666 -0
- selenium_expect-1.0.0.dist-info/RECORD +27 -0
- selenium_expect-1.0.0.dist-info/WHEEL +4 -0
- selenium_expect-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Custom matcher registry for selenium-expect.
|
|
2
|
+
|
|
3
|
+
Allows users to register custom assertion methods that integrate with
|
|
4
|
+
the retry loop and negation via ``__getattr__`` on ``AssertionMixin``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from types import ModuleType
|
|
11
|
+
from typing import Any, ClassVar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CustomMatcherRegistry:
|
|
15
|
+
"""Registry of user-defined custom matchers."""
|
|
16
|
+
|
|
17
|
+
_matchers: ClassVar[dict[str, Callable[..., tuple[bool, Any]]]] = {}
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def register(cls, name: str, matcher_fn: Callable[..., tuple[bool, Any]]) -> None:
|
|
21
|
+
"""Register a custom matcher function under *name*."""
|
|
22
|
+
cls._matchers[name] = matcher_fn
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def get(cls, name: str) -> Callable[..., tuple[bool, Any]] | None:
|
|
26
|
+
"""Return the matcher registered under *name*, or ``None``."""
|
|
27
|
+
return cls._matchers.get(name)
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def names(cls) -> list[str]:
|
|
31
|
+
"""Return all registered matcher names."""
|
|
32
|
+
return list(cls._matchers)
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def reset(cls) -> None:
|
|
36
|
+
"""Clear all registered matchers (for testing)."""
|
|
37
|
+
cls._matchers.clear()
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def merge_from(cls, *modules: ModuleType) -> list[str]:
|
|
41
|
+
"""Merge matchers from the given modules into this registry.
|
|
42
|
+
|
|
43
|
+
Each module is expected to have used ``@extend`` to register
|
|
44
|
+
matchers at import time. This method imports (if passed as a
|
|
45
|
+
string) or processes each module and returns the names of
|
|
46
|
+
newly registered matchers.
|
|
47
|
+
"""
|
|
48
|
+
added: list[str] = []
|
|
49
|
+
for mod in modules:
|
|
50
|
+
for name in dir(mod):
|
|
51
|
+
fn = getattr(mod, name, None)
|
|
52
|
+
if callable(fn) and hasattr(fn, "_selenium_expect_matcher"):
|
|
53
|
+
matcher_name: str = fn._selenium_expect_matcher
|
|
54
|
+
if matcher_name not in cls._matchers:
|
|
55
|
+
cls._matchers[matcher_name] = fn
|
|
56
|
+
added.append(matcher_name)
|
|
57
|
+
return added
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_MatcherFn = Callable[..., tuple[bool, Any]]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def extend(name: str) -> Callable[[_MatcherFn], _MatcherFn]:
|
|
64
|
+
"""Decorator to register a custom matcher under *name*.
|
|
65
|
+
|
|
66
|
+
Usage::
|
|
67
|
+
|
|
68
|
+
@extend("to_be_in_viewport")
|
|
69
|
+
def check_in_viewport(element: Any) -> tuple[bool, Any]:
|
|
70
|
+
...
|
|
71
|
+
return (passed, actual_value)
|
|
72
|
+
|
|
73
|
+
The matcher function receives the assertion's ``_target`` as its
|
|
74
|
+
first argument and must return a ``(bool, Any)`` tuple where the
|
|
75
|
+
bool indicates pass/fail and the Any is the actual value for
|
|
76
|
+
error reporting.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def decorator(fn: Callable[..., tuple[bool, Any]]) -> Callable[..., tuple[bool, Any]]:
|
|
80
|
+
CustomMatcherRegistry.register(name, fn)
|
|
81
|
+
fn._selenium_expect_matcher = name # type: ignore[attr-defined]
|
|
82
|
+
return fn
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def merge_expects(*modules: ModuleType | str) -> list[str]:
|
|
88
|
+
"""Combine custom matchers from multiple modules into the registry.
|
|
89
|
+
|
|
90
|
+
Each module should have used ``@extend`` to register matchers.
|
|
91
|
+
Pass modules as objects or importable strings.
|
|
92
|
+
|
|
93
|
+
Usage::
|
|
94
|
+
|
|
95
|
+
import my_matchers
|
|
96
|
+
merge_expects(my_matchers)
|
|
97
|
+
|
|
98
|
+
# or by import path:
|
|
99
|
+
merge_expects("my_project.matchers")
|
|
100
|
+
|
|
101
|
+
Returns the list of newly registered matcher names.
|
|
102
|
+
"""
|
|
103
|
+
import importlib
|
|
104
|
+
|
|
105
|
+
resolved: list[ModuleType] = []
|
|
106
|
+
for mod in modules:
|
|
107
|
+
if isinstance(mod, str):
|
|
108
|
+
resolved.append(importlib.import_module(mod))
|
|
109
|
+
else:
|
|
110
|
+
resolved.append(mod)
|
|
111
|
+
return CustomMatcherRegistry.merge_from(*resolved)
|
selenium_expect/_poll.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""PollAssertion — retry-based assertions over arbitrary functions.
|
|
2
|
+
|
|
3
|
+
``poll(fn)`` wraps any zero-arg callable and provides Playwright-style
|
|
4
|
+
assertion methods that retry until the function's return value satisfies
|
|
5
|
+
the condition or the timeout expires.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from selenium_expect._config import (
|
|
15
|
+
ExpectConfig,
|
|
16
|
+
get_config,
|
|
17
|
+
)
|
|
18
|
+
from selenium_expect._config import (
|
|
19
|
+
normalize_timeout as _normalize_timeout,
|
|
20
|
+
)
|
|
21
|
+
from selenium_expect._errors import AssertionFormatter
|
|
22
|
+
from selenium_expect._retry import retry_until
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PollAssertion:
|
|
26
|
+
"""Assertion over an arbitrary function with retry loop."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
fn: Callable[[], Any],
|
|
31
|
+
timeout: float | None = None,
|
|
32
|
+
polling: float | list[float] | None = None,
|
|
33
|
+
config: ExpectConfig | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._fn = fn
|
|
36
|
+
self._config = config if config is not None else get_config()
|
|
37
|
+
self._timeout = _normalize_timeout(timeout) if timeout is not None else self._config.timeout
|
|
38
|
+
if self._timeout < 0:
|
|
39
|
+
raise ValueError(f"timeout must be >= 0, got {self._timeout}")
|
|
40
|
+
if polling is None:
|
|
41
|
+
self._polling_interval = self._config.polling_interval
|
|
42
|
+
self._polling_intervals = self._config.polling_intervals
|
|
43
|
+
elif isinstance(polling, list):
|
|
44
|
+
if len(polling) == 0:
|
|
45
|
+
raise ValueError("polling list must not be empty; use a float for fixed interval")
|
|
46
|
+
self._polling_interval = 0.5
|
|
47
|
+
self._polling_intervals = polling
|
|
48
|
+
else:
|
|
49
|
+
self._polling_interval = polling
|
|
50
|
+
self._polling_intervals = None
|
|
51
|
+
if self._polling_interval < 0:
|
|
52
|
+
raise ValueError(f"polling interval must be >= 0, got {self._polling_interval}")
|
|
53
|
+
if self._polling_intervals is not None:
|
|
54
|
+
for i, interval in enumerate(self._polling_intervals):
|
|
55
|
+
if interval < 0:
|
|
56
|
+
raise ValueError(f"polling_intervals[{i}] must be >= 0, got {interval}")
|
|
57
|
+
|
|
58
|
+
def _run(
|
|
59
|
+
self,
|
|
60
|
+
condition: Callable[[], tuple[bool, Any]],
|
|
61
|
+
condition_name: str,
|
|
62
|
+
expected: Any = None,
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Execute the retry loop and raise on failure."""
|
|
65
|
+
result = retry_until(
|
|
66
|
+
condition=condition,
|
|
67
|
+
timeout=self._timeout,
|
|
68
|
+
polling_interval=self._polling_interval,
|
|
69
|
+
polling_intervals=self._polling_intervals,
|
|
70
|
+
debug=self._config.debug_mode,
|
|
71
|
+
)
|
|
72
|
+
if result.passed:
|
|
73
|
+
return
|
|
74
|
+
error_msg = AssertionFormatter.format_error(
|
|
75
|
+
entity="poll()",
|
|
76
|
+
condition=condition_name,
|
|
77
|
+
expected=expected,
|
|
78
|
+
actual=result.actual_value,
|
|
79
|
+
elapsed_ms=result.elapsed_ms,
|
|
80
|
+
poll_count=result.poll_count,
|
|
81
|
+
polling_interval=self._polling_interval,
|
|
82
|
+
timeline=result.timeline,
|
|
83
|
+
)
|
|
84
|
+
if self._config.soft_mode:
|
|
85
|
+
from selenium_expect._soft import SoftAssertionCollector
|
|
86
|
+
|
|
87
|
+
SoftAssertionCollector.add_failure(error_msg)
|
|
88
|
+
else:
|
|
89
|
+
raise AssertionError(error_msg)
|
|
90
|
+
|
|
91
|
+
def to_equal(self, expected: Any) -> None:
|
|
92
|
+
"""Assert fn() == expected."""
|
|
93
|
+
fn = self._fn
|
|
94
|
+
|
|
95
|
+
def condition() -> tuple[bool, Any]:
|
|
96
|
+
actual = fn()
|
|
97
|
+
return (actual == expected, actual)
|
|
98
|
+
|
|
99
|
+
self._run(condition, f"to equal {expected!r}", expected)
|
|
100
|
+
|
|
101
|
+
def to_be_truthy(self) -> None:
|
|
102
|
+
"""Assert bool(fn()) is True."""
|
|
103
|
+
fn = self._fn
|
|
104
|
+
|
|
105
|
+
def condition() -> tuple[bool, Any]:
|
|
106
|
+
actual = fn()
|
|
107
|
+
return (bool(actual), actual)
|
|
108
|
+
|
|
109
|
+
self._run(condition, "to be truthy", True)
|
|
110
|
+
|
|
111
|
+
def to_be_falsy(self) -> None:
|
|
112
|
+
"""Assert bool(fn()) is False."""
|
|
113
|
+
fn = self._fn
|
|
114
|
+
|
|
115
|
+
def condition() -> tuple[bool, Any]:
|
|
116
|
+
actual = fn()
|
|
117
|
+
return (not bool(actual), actual)
|
|
118
|
+
|
|
119
|
+
self._run(condition, "to be falsy", False)
|
|
120
|
+
|
|
121
|
+
def to_be_none(self) -> None:
|
|
122
|
+
"""Assert fn() is None."""
|
|
123
|
+
fn = self._fn
|
|
124
|
+
|
|
125
|
+
def condition() -> tuple[bool, Any]:
|
|
126
|
+
actual = fn()
|
|
127
|
+
return (actual is None, actual)
|
|
128
|
+
|
|
129
|
+
self._run(condition, "to be None", None)
|
|
130
|
+
|
|
131
|
+
def to_contain(self, expected: Any) -> None:
|
|
132
|
+
"""Assert expected in fn()."""
|
|
133
|
+
fn = self._fn
|
|
134
|
+
|
|
135
|
+
def condition() -> tuple[bool, Any]:
|
|
136
|
+
actual = fn()
|
|
137
|
+
if actual is None:
|
|
138
|
+
return (False, actual)
|
|
139
|
+
try:
|
|
140
|
+
return (expected in actual, actual)
|
|
141
|
+
except TypeError:
|
|
142
|
+
return (False, f"not iterable: {actual!r}")
|
|
143
|
+
|
|
144
|
+
self._run(condition, f"to contain {expected!r}", expected)
|
|
145
|
+
|
|
146
|
+
def to_match(self, pattern: str) -> None:
|
|
147
|
+
"""Assert re.search(pattern, str(fn()))."""
|
|
148
|
+
fn = self._fn
|
|
149
|
+
|
|
150
|
+
def condition() -> tuple[bool, Any]:
|
|
151
|
+
actual = fn()
|
|
152
|
+
return (re.search(pattern, str(actual)) is not None, actual)
|
|
153
|
+
|
|
154
|
+
self._run(condition, f"to match {pattern!r}", pattern)
|
|
155
|
+
|
|
156
|
+
def to_be_greater_than(self, expected: Any) -> None:
|
|
157
|
+
"""Assert fn() > expected."""
|
|
158
|
+
fn = self._fn
|
|
159
|
+
|
|
160
|
+
def condition() -> tuple[bool, Any]:
|
|
161
|
+
actual = fn()
|
|
162
|
+
try:
|
|
163
|
+
return (actual > expected, actual)
|
|
164
|
+
except TypeError:
|
|
165
|
+
return (False, f"not comparable: {actual!r} > {expected!r}")
|
|
166
|
+
|
|
167
|
+
self._run(condition, f"to be greater than {expected}", expected)
|
|
168
|
+
|
|
169
|
+
def to_be_less_than(self, expected: Any) -> None:
|
|
170
|
+
"""Assert fn() < expected."""
|
|
171
|
+
fn = self._fn
|
|
172
|
+
|
|
173
|
+
def condition() -> tuple[bool, Any]:
|
|
174
|
+
actual = fn()
|
|
175
|
+
try:
|
|
176
|
+
return (actual < expected, actual)
|
|
177
|
+
except TypeError:
|
|
178
|
+
return (False, f"not comparable: {actual!r} < {expected!r}")
|
|
179
|
+
|
|
180
|
+
self._run(condition, f"to be less than {expected}", expected)
|
|
181
|
+
|
|
182
|
+
def to_be_in_list(self, expected: list[Any]) -> None:
|
|
183
|
+
"""Assert fn() in expected."""
|
|
184
|
+
fn = self._fn
|
|
185
|
+
|
|
186
|
+
def condition() -> tuple[bool, Any]:
|
|
187
|
+
actual = fn()
|
|
188
|
+
return (actual in expected, actual)
|
|
189
|
+
|
|
190
|
+
self._run(condition, f"to be in {expected!r}", expected)
|
|
191
|
+
|
|
192
|
+
def to_have_length(self, expected: int) -> None:
|
|
193
|
+
"""Assert len(fn()) == expected."""
|
|
194
|
+
fn = self._fn
|
|
195
|
+
|
|
196
|
+
def condition() -> tuple[bool, Any]:
|
|
197
|
+
actual = fn()
|
|
198
|
+
try:
|
|
199
|
+
actual_len = len(actual)
|
|
200
|
+
except TypeError:
|
|
201
|
+
return (False, f"no len() for {actual!r}")
|
|
202
|
+
return (actual_len == expected, actual_len)
|
|
203
|
+
|
|
204
|
+
self._run(condition, f"to have length {expected}", expected)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def poll(
|
|
208
|
+
fn: Callable[[], Any],
|
|
209
|
+
*,
|
|
210
|
+
timeout: float | None = None,
|
|
211
|
+
polling: float | list[float] | None = None,
|
|
212
|
+
config: ExpectConfig | None = None,
|
|
213
|
+
) -> PollAssertion:
|
|
214
|
+
"""Create a ``PollAssertion`` for retry-based assertions on *fn*.
|
|
215
|
+
|
|
216
|
+
Usage::
|
|
217
|
+
|
|
218
|
+
poll(lambda: driver.execute_script("return document.readyState"))
|
|
219
|
+
.to_equal("complete")
|
|
220
|
+
"""
|
|
221
|
+
return PollAssertion(fn, timeout=timeout, polling=polling, config=config)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Retry engine for selenium-expect assertions.
|
|
2
|
+
|
|
3
|
+
The retry loop is agnostic — it knows nothing about Selenium. It calls
|
|
4
|
+
a condition callable repeatedly until it returns ``(True, value)`` or
|
|
5
|
+
the timeout expires. Retryable Selenium exceptions are caught and
|
|
6
|
+
treated as a failed poll.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import time
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from selenium.common.exceptions import (
|
|
18
|
+
ElementNotInteractableException,
|
|
19
|
+
NoAlertPresentException,
|
|
20
|
+
NoSuchElementException,
|
|
21
|
+
NoSuchFrameException,
|
|
22
|
+
NoSuchShadowRootException,
|
|
23
|
+
NoSuchWindowException,
|
|
24
|
+
StaleElementReferenceException,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("selenium_expect")
|
|
28
|
+
|
|
29
|
+
RETRYABLE_EXCEPTIONS: tuple[type[Exception], ...] = (
|
|
30
|
+
StaleElementReferenceException,
|
|
31
|
+
NoSuchElementException,
|
|
32
|
+
NoAlertPresentException,
|
|
33
|
+
NoSuchWindowException,
|
|
34
|
+
NoSuchFrameException,
|
|
35
|
+
ElementNotInteractableException,
|
|
36
|
+
NoSuchShadowRootException,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RetryResult:
|
|
42
|
+
"""Result of a retry loop execution."""
|
|
43
|
+
|
|
44
|
+
passed: bool
|
|
45
|
+
actual_value: Any
|
|
46
|
+
elapsed_ms: int
|
|
47
|
+
poll_count: int
|
|
48
|
+
timeline: list[dict[str, Any]] = field(default_factory=list)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def retry_until(
|
|
52
|
+
condition: Callable[[], tuple[bool, Any]],
|
|
53
|
+
timeout: float,
|
|
54
|
+
polling_interval: float = 0.5,
|
|
55
|
+
polling_intervals: list[float] | None = None,
|
|
56
|
+
debug: bool = False,
|
|
57
|
+
) -> RetryResult:
|
|
58
|
+
"""Execute ``condition()`` repeatedly until it returns ``(True, value)`` or timeout.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
condition: Callable returning ``(passed: bool, actual_value: Any)``.
|
|
62
|
+
timeout: Maximum seconds to wait.
|
|
63
|
+
polling_interval: Fixed seconds between polls.
|
|
64
|
+
polling_intervals: Backoff schedule (overrides ``polling_interval``).
|
|
65
|
+
When exhausted, the last interval is repeated.
|
|
66
|
+
debug: If ``True``, log each poll via the ``logging`` module.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
``RetryResult`` with pass/fail, actual value, elapsed time, and poll timeline.
|
|
70
|
+
"""
|
|
71
|
+
start = time.monotonic()
|
|
72
|
+
poll_count = 0
|
|
73
|
+
timeline: list[dict[str, Any]] = []
|
|
74
|
+
actual_value: Any = None
|
|
75
|
+
|
|
76
|
+
def _get_interval(idx: int) -> float:
|
|
77
|
+
if polling_intervals is None or len(polling_intervals) == 0:
|
|
78
|
+
return polling_interval
|
|
79
|
+
if idx < len(polling_intervals):
|
|
80
|
+
return polling_intervals[idx]
|
|
81
|
+
return polling_intervals[-1]
|
|
82
|
+
|
|
83
|
+
while True:
|
|
84
|
+
poll_count += 1
|
|
85
|
+
try:
|
|
86
|
+
passed, actual_value = condition()
|
|
87
|
+
except RETRYABLE_EXCEPTIONS as exc:
|
|
88
|
+
passed = False
|
|
89
|
+
actual_value = f"{type(exc).__name__}: {exc}"
|
|
90
|
+
if debug:
|
|
91
|
+
logger.debug(
|
|
92
|
+
"poll %d: retryable exception %s — %.1fms elapsed",
|
|
93
|
+
poll_count,
|
|
94
|
+
type(exc).__name__,
|
|
95
|
+
(time.monotonic() - start) * 1000,
|
|
96
|
+
)
|
|
97
|
+
timeline.append({"poll": poll_count, "passed": False, "actual": str(actual_value)})
|
|
98
|
+
elapsed = time.monotonic() - start
|
|
99
|
+
if elapsed >= timeout:
|
|
100
|
+
break
|
|
101
|
+
_sleep_and_check_timeout(start, timeout, _get_interval(poll_count - 1))
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
if debug:
|
|
105
|
+
logger.debug(
|
|
106
|
+
"poll %d: passed=%s actual=%r — %.1fms elapsed",
|
|
107
|
+
poll_count,
|
|
108
|
+
passed,
|
|
109
|
+
actual_value,
|
|
110
|
+
(time.monotonic() - start) * 1000,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
timeline.append({"poll": poll_count, "passed": passed, "actual": actual_value})
|
|
114
|
+
|
|
115
|
+
if passed:
|
|
116
|
+
return RetryResult(
|
|
117
|
+
passed=True,
|
|
118
|
+
actual_value=actual_value,
|
|
119
|
+
elapsed_ms=int((time.monotonic() - start) * 1000),
|
|
120
|
+
poll_count=poll_count,
|
|
121
|
+
timeline=timeline,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
elapsed = time.monotonic() - start
|
|
125
|
+
if elapsed >= timeout:
|
|
126
|
+
break
|
|
127
|
+
|
|
128
|
+
_sleep_and_check_timeout(start, timeout, _get_interval(poll_count - 1))
|
|
129
|
+
|
|
130
|
+
return RetryResult(
|
|
131
|
+
passed=False,
|
|
132
|
+
actual_value=actual_value,
|
|
133
|
+
elapsed_ms=int((time.monotonic() - start) * 1000),
|
|
134
|
+
poll_count=poll_count,
|
|
135
|
+
timeline=timeline,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _sleep_and_check_timeout(start: float, timeout: float, interval: float) -> None:
|
|
140
|
+
"""Sleep for *interval* seconds, but not past the timeout deadline."""
|
|
141
|
+
elapsed = time.monotonic() - start
|
|
142
|
+
remaining = timeout - elapsed
|
|
143
|
+
if remaining <= 0:
|
|
144
|
+
return
|
|
145
|
+
time.sleep(min(interval, remaining))
|
selenium_expect/_soft.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Soft assertion collector for selenium-expect.
|
|
2
|
+
|
|
3
|
+
Accumulates assertion failures when ``soft_mode=True`` instead of raising
|
|
4
|
+
immediately. ``assert_all()`` raises a combined ``AssertionError`` if any
|
|
5
|
+
failures were collected.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import ClassVar
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SoftAssertionCollector:
|
|
14
|
+
"""Collects soft assertion failures for deferred raising."""
|
|
15
|
+
|
|
16
|
+
_failures: ClassVar[list[str]] = []
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def add_failure(cls, message: str) -> None:
|
|
20
|
+
"""Record a soft assertion failure."""
|
|
21
|
+
cls._failures.append(message)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def get_failures(cls) -> list[str]:
|
|
25
|
+
"""Return all collected failures."""
|
|
26
|
+
return list(cls._failures)
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def reset(cls) -> None:
|
|
30
|
+
"""Clear all collected failures."""
|
|
31
|
+
cls._failures.clear()
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def assert_all(cls) -> None:
|
|
35
|
+
"""Raise ``AssertionError`` if any failures were collected, then reset."""
|
|
36
|
+
if not cls._failures:
|
|
37
|
+
return
|
|
38
|
+
messages = list(cls._failures)
|
|
39
|
+
cls.reset()
|
|
40
|
+
combined = "\n---\n".join(messages)
|
|
41
|
+
raise AssertionError(f"Soft assertion failures ({len(messages)}):\n{combined}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def assert_all() -> None:
|
|
45
|
+
"""Raise ``AssertionError`` if any soft failures were collected, then reset.
|
|
46
|
+
|
|
47
|
+
Convenience wrapper around ``SoftAssertionCollector.assert_all()``.
|
|
48
|
+
"""
|
|
49
|
+
SoftAssertionCollector.assert_all()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Assertion class registry for selenium-expect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
ASSERTION_REGISTRY: dict[str, type[Any]] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register(type_name: str, cls: type[Any]) -> None:
|
|
12
|
+
"""Register an assertion class for a target type name."""
|
|
13
|
+
ASSERTION_REGISTRY[type_name] = cls
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Import all assertion modules to trigger their register() calls.
|
|
17
|
+
# Done via importlib to avoid issues with `list` being a Python builtin.
|
|
18
|
+
for _module_name in (
|
|
19
|
+
"alert",
|
|
20
|
+
"cookie",
|
|
21
|
+
"driver",
|
|
22
|
+
"element",
|
|
23
|
+
"iframe",
|
|
24
|
+
"js",
|
|
25
|
+
"list",
|
|
26
|
+
"select",
|
|
27
|
+
"shadow",
|
|
28
|
+
"window",
|
|
29
|
+
):
|
|
30
|
+
importlib.import_module(f"selenium_expect.assertions.{_module_name}")
|