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,35 @@
|
|
|
1
|
+
"""selenium-expect — Playwright-style expect() for Selenium Python."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from selenium_expect._config import (
|
|
6
|
+
ExpectConfig,
|
|
7
|
+
get_config,
|
|
8
|
+
set_debug_mode,
|
|
9
|
+
set_default_polling_interval,
|
|
10
|
+
set_default_polling_intervals,
|
|
11
|
+
set_default_timeout,
|
|
12
|
+
set_screenshot_on_failure,
|
|
13
|
+
)
|
|
14
|
+
from selenium_expect._expect import expect
|
|
15
|
+
from selenium_expect._matcher import extend, merge_expects
|
|
16
|
+
from selenium_expect._poll import poll
|
|
17
|
+
from selenium_expect._soft import SoftAssertionCollector, assert_all
|
|
18
|
+
|
|
19
|
+
__version__ = "1.0.0"
|
|
20
|
+
|
|
21
|
+
__all__: list[str] = [
|
|
22
|
+
"ExpectConfig",
|
|
23
|
+
"SoftAssertionCollector",
|
|
24
|
+
"assert_all",
|
|
25
|
+
"expect",
|
|
26
|
+
"extend",
|
|
27
|
+
"get_config",
|
|
28
|
+
"merge_expects",
|
|
29
|
+
"poll",
|
|
30
|
+
"set_debug_mode",
|
|
31
|
+
"set_default_polling_interval",
|
|
32
|
+
"set_default_polling_intervals",
|
|
33
|
+
"set_default_timeout",
|
|
34
|
+
"set_screenshot_on_failure",
|
|
35
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Composition assertions — satisfy_all, satisfy_any, satisfy_none.
|
|
2
|
+
|
|
3
|
+
Each condition is a callable that receives the target and executes an
|
|
4
|
+
assertion (e.g. a lambda calling ``expect(el).to_be_visible()``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from selenium_expect._errors import AssertionFormatter
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def satisfy_all(
|
|
16
|
+
target: Any,
|
|
17
|
+
*conditions: Callable[[Any], None],
|
|
18
|
+
message: str | None = None,
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Assert all conditions pass (AND logic).
|
|
21
|
+
|
|
22
|
+
Each condition is a callable that receives *target* and may raise
|
|
23
|
+
``AssertionError`` on failure. All must pass without raising.
|
|
24
|
+
|
|
25
|
+
Each condition is responsible for its own retry/timeout via ``expect()``.
|
|
26
|
+
"""
|
|
27
|
+
if not conditions:
|
|
28
|
+
raise ValueError("satisfy_all requires at least one condition")
|
|
29
|
+
failures: list[str] = []
|
|
30
|
+
for i, cond in enumerate(conditions):
|
|
31
|
+
try:
|
|
32
|
+
cond(target)
|
|
33
|
+
except AssertionError as exc:
|
|
34
|
+
failures.append(f"condition {i}: {exc}")
|
|
35
|
+
if failures:
|
|
36
|
+
combined = "\n".join(failures)
|
|
37
|
+
error_msg = AssertionFormatter.format_error(
|
|
38
|
+
entity="composition",
|
|
39
|
+
condition="to_satisfy_all",
|
|
40
|
+
expected="all pass",
|
|
41
|
+
actual=combined,
|
|
42
|
+
elapsed_ms=0,
|
|
43
|
+
poll_count=len(conditions),
|
|
44
|
+
polling_interval=0.0,
|
|
45
|
+
message=message,
|
|
46
|
+
)
|
|
47
|
+
raise AssertionError(error_msg)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def satisfy_any(
|
|
51
|
+
target: Any,
|
|
52
|
+
*conditions: Callable[[Any], None],
|
|
53
|
+
message: str | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Assert at least one condition passes (OR logic).
|
|
56
|
+
|
|
57
|
+
Each condition is a callable that receives *target* and may raise
|
|
58
|
+
``AssertionError`` on failure. At least one must pass without raising.
|
|
59
|
+
|
|
60
|
+
Each condition is responsible for its own retry/timeout via ``expect()``.
|
|
61
|
+
"""
|
|
62
|
+
if not conditions:
|
|
63
|
+
raise ValueError("satisfy_any requires at least one condition")
|
|
64
|
+
failures: list[str] = []
|
|
65
|
+
for i, cond in enumerate(conditions):
|
|
66
|
+
try:
|
|
67
|
+
cond(target)
|
|
68
|
+
return # at least one passed
|
|
69
|
+
except AssertionError as exc:
|
|
70
|
+
failures.append(f"condition {i}: {exc}")
|
|
71
|
+
combined = "\n".join(failures)
|
|
72
|
+
error_msg = AssertionFormatter.format_error(
|
|
73
|
+
entity="composition",
|
|
74
|
+
condition="to_satisfy_any",
|
|
75
|
+
expected="at least one pass",
|
|
76
|
+
actual=combined,
|
|
77
|
+
elapsed_ms=0,
|
|
78
|
+
poll_count=len(conditions),
|
|
79
|
+
polling_interval=0.0,
|
|
80
|
+
message=message,
|
|
81
|
+
)
|
|
82
|
+
raise AssertionError(error_msg)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def satisfy_none(
|
|
86
|
+
target: Any,
|
|
87
|
+
*conditions: Callable[[Any], None],
|
|
88
|
+
message: str | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Assert no condition passes (NOT logic).
|
|
91
|
+
|
|
92
|
+
Each condition is a callable that receives *target* and may raise
|
|
93
|
+
``AssertionError``. All must raise (i.e. none pass).
|
|
94
|
+
|
|
95
|
+
Each condition is responsible for its own retry/timeout via ``expect()``.
|
|
96
|
+
"""
|
|
97
|
+
if not conditions:
|
|
98
|
+
raise ValueError("satisfy_none requires at least one condition")
|
|
99
|
+
passed: list[int] = []
|
|
100
|
+
for i, cond in enumerate(conditions):
|
|
101
|
+
try:
|
|
102
|
+
cond(target)
|
|
103
|
+
passed.append(i)
|
|
104
|
+
except AssertionError:
|
|
105
|
+
pass # expected to fail
|
|
106
|
+
if passed:
|
|
107
|
+
error_msg = AssertionFormatter.format_error(
|
|
108
|
+
entity="composition",
|
|
109
|
+
condition="to_satisfy_none",
|
|
110
|
+
expected="none pass",
|
|
111
|
+
actual=f"conditions {passed} passed",
|
|
112
|
+
elapsed_ms=0,
|
|
113
|
+
poll_count=len(conditions),
|
|
114
|
+
polling_interval=0.0,
|
|
115
|
+
message=message,
|
|
116
|
+
)
|
|
117
|
+
raise AssertionError(error_msg)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Configuration for selenium-expect assertions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from dataclasses import replace as _replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def normalize_timeout(timeout: float) -> float:
|
|
11
|
+
"""Convert timeout to seconds.
|
|
12
|
+
|
|
13
|
+
If *timeout* is an int >= 1000, it is interpreted as milliseconds.
|
|
14
|
+
Floats and ints < 1000 are treated as seconds.
|
|
15
|
+
"""
|
|
16
|
+
if isinstance(timeout, int) and timeout >= 1000:
|
|
17
|
+
return timeout / 1000.0
|
|
18
|
+
return float(timeout)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class ExpectConfig:
|
|
23
|
+
"""Immutable configuration for expect assertions.
|
|
24
|
+
|
|
25
|
+
Use ``replace()`` to create a new instance with overridden fields.
|
|
26
|
+
The global config singleton is mutated via the module-level setters.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
timeout: float = 5.0
|
|
30
|
+
polling_interval: float = 0.5
|
|
31
|
+
polling_intervals: list[float] | None = None
|
|
32
|
+
screenshot_on_failure: bool = False
|
|
33
|
+
screenshot_path: str | None = None
|
|
34
|
+
debug_mode: bool = False
|
|
35
|
+
soft_mode: bool = False
|
|
36
|
+
|
|
37
|
+
def __post_init__(self) -> None:
|
|
38
|
+
if self.timeout < 0:
|
|
39
|
+
raise ValueError(f"timeout must be >= 0, got {self.timeout}")
|
|
40
|
+
if self.polling_interval < 0:
|
|
41
|
+
raise ValueError(f"polling_interval must be >= 0, got {self.polling_interval}")
|
|
42
|
+
if self.polling_intervals is not None:
|
|
43
|
+
if len(self.polling_intervals) == 0:
|
|
44
|
+
raise ValueError("polling_intervals must not be empty; use None for fixed interval")
|
|
45
|
+
for i, interval in enumerate(self.polling_intervals):
|
|
46
|
+
if interval < 0:
|
|
47
|
+
raise ValueError(f"polling_intervals[{i}] must be >= 0, got {interval}")
|
|
48
|
+
|
|
49
|
+
def replace(self, **kwargs: Any) -> ExpectConfig:
|
|
50
|
+
"""Return a new instance with overridden fields."""
|
|
51
|
+
return _replace(self, **kwargs)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_global_config: ExpectConfig = ExpectConfig()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_default_timeout(seconds: float) -> None:
|
|
58
|
+
"""Set the default timeout for all expect assertions.
|
|
59
|
+
|
|
60
|
+
If *seconds* is an int >= 1000, it is interpreted as milliseconds
|
|
61
|
+
(consistent with ``expect(timeout=...)``).
|
|
62
|
+
"""
|
|
63
|
+
global _global_config
|
|
64
|
+
_global_config = _global_config.replace(timeout=normalize_timeout(seconds))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def set_default_polling_interval(seconds: float) -> None:
|
|
68
|
+
"""Set the default polling interval for all expect assertions."""
|
|
69
|
+
global _global_config
|
|
70
|
+
_global_config = _global_config.replace(polling_interval=seconds)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_default_polling_intervals(intervals: list[float]) -> None:
|
|
74
|
+
"""Set a backoff schedule for polling intervals."""
|
|
75
|
+
global _global_config
|
|
76
|
+
_global_config = _global_config.replace(polling_intervals=intervals)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_screenshot_on_failure(enabled: bool, path: str | None = None) -> None:
|
|
80
|
+
"""Enable or disable screenshot capture on assertion failure."""
|
|
81
|
+
global _global_config
|
|
82
|
+
_global_config = _global_config.replace(
|
|
83
|
+
screenshot_on_failure=enabled,
|
|
84
|
+
screenshot_path=path if path is not None else "./screenshots/",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def set_debug_mode(enabled: bool) -> None:
|
|
89
|
+
"""Enable or disable debug logging for retry loops."""
|
|
90
|
+
global _global_config
|
|
91
|
+
_global_config = _global_config.replace(debug_mode=enabled)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def get_config() -> ExpectConfig:
|
|
95
|
+
"""Return the current global config."""
|
|
96
|
+
return _global_config
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Assertion error message formatting for selenium-expect."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AssertionFormatter:
|
|
9
|
+
"""Builds descriptive multi-line error messages."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def format_error(
|
|
13
|
+
entity: str,
|
|
14
|
+
condition: str,
|
|
15
|
+
expected: Any,
|
|
16
|
+
actual: Any,
|
|
17
|
+
elapsed_ms: int,
|
|
18
|
+
poll_count: int,
|
|
19
|
+
polling_interval: float,
|
|
20
|
+
message: str | None = None,
|
|
21
|
+
element_html: str | None = None,
|
|
22
|
+
timeline: list[dict[str, Any]] | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
"""Format a descriptive AssertionError message.
|
|
25
|
+
|
|
26
|
+
Output format:
|
|
27
|
+
Expected {entity} {condition}, but got {actual}
|
|
28
|
+
Expected: {expected}
|
|
29
|
+
Actual: {actual}
|
|
30
|
+
Element: {truncated_html} # if element_html provided
|
|
31
|
+
Waited: {elapsed_ms}ms ({poll_count} polls at {polling_interval}s interval)
|
|
32
|
+
Message: {custom_message} # if message provided
|
|
33
|
+
Timeline: [poll N: actual, ...] # last 5 polls if timeline provided
|
|
34
|
+
"""
|
|
35
|
+
lines: list[str] = [
|
|
36
|
+
f"Expected {entity} {condition}, but got {actual}",
|
|
37
|
+
f" Expected: {expected}",
|
|
38
|
+
f" Actual: {actual}",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
if element_html is not None:
|
|
42
|
+
truncated = element_html if len(element_html) <= 200 else element_html[:200] + "..."
|
|
43
|
+
lines.append(f" Element: {truncated}")
|
|
44
|
+
|
|
45
|
+
lines.append(
|
|
46
|
+
f" Waited: {elapsed_ms}ms ({poll_count} polls at {polling_interval}s interval)"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if message is not None:
|
|
50
|
+
lines.append(f" Message: {message}")
|
|
51
|
+
|
|
52
|
+
if timeline:
|
|
53
|
+
recent = timeline[-5:]
|
|
54
|
+
entries = ", ".join(f"poll {e['poll']}: {e['actual']}" for e in recent)
|
|
55
|
+
lines.append(f" Timeline: [{entries}]")
|
|
56
|
+
|
|
57
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""expect() entry point — dispatches to the correct assertion class."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
from selenium.webdriver.common.alert import Alert
|
|
9
|
+
from selenium.webdriver.remote.shadowroot import ShadowRoot
|
|
10
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
11
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
12
|
+
from selenium.webdriver.support.ui import Select
|
|
13
|
+
|
|
14
|
+
from selenium_expect._config import ExpectConfig, get_config, normalize_timeout
|
|
15
|
+
from selenium_expect._poll import PollAssertion
|
|
16
|
+
from selenium_expect.assertions import ASSERTION_REGISTRY
|
|
17
|
+
from selenium_expect.assertions._base import AssertionMixin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _resolve_target_type(target: Any) -> str:
|
|
21
|
+
"""Determine the registry type name for *target*.
|
|
22
|
+
|
|
23
|
+
Select is checked before WebElement because Select wraps a WebElement.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(target, Select):
|
|
26
|
+
return "Select"
|
|
27
|
+
if isinstance(target, WebElement):
|
|
28
|
+
return "WebElement"
|
|
29
|
+
if isinstance(target, list):
|
|
30
|
+
return "list"
|
|
31
|
+
if isinstance(target, WebDriver):
|
|
32
|
+
return "WebDriver"
|
|
33
|
+
if isinstance(target, Alert):
|
|
34
|
+
return "Alert"
|
|
35
|
+
if isinstance(target, ShadowRoot):
|
|
36
|
+
return "ShadowRoot"
|
|
37
|
+
cls_name = type(target).__name__
|
|
38
|
+
if cls_name == "WebDriver":
|
|
39
|
+
return "WebDriver"
|
|
40
|
+
if cls_name == "Alert":
|
|
41
|
+
return "Alert"
|
|
42
|
+
return cls_name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Expect:
|
|
46
|
+
"""Callable expect dispatcher with attached utilities.
|
|
47
|
+
|
|
48
|
+
Use ``expect(target)`` to create assertions, ``expect.poll(fn)`` for
|
|
49
|
+
retry-based polling, and ``expect.configure(...)`` for pre-configured
|
|
50
|
+
variants.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __call__(
|
|
54
|
+
self,
|
|
55
|
+
target: Any,
|
|
56
|
+
/,
|
|
57
|
+
*,
|
|
58
|
+
message: str | None = None,
|
|
59
|
+
timeout: float | None = None,
|
|
60
|
+
polling: float | list[float] | None = None,
|
|
61
|
+
soft: bool | None = None,
|
|
62
|
+
config: ExpectConfig | None = None,
|
|
63
|
+
by: str | None = None,
|
|
64
|
+
value: str | None = None,
|
|
65
|
+
locator: tuple[str, str] | None = None,
|
|
66
|
+
) -> AssertionMixin:
|
|
67
|
+
"""Create an expect assertion for the given target.
|
|
68
|
+
|
|
69
|
+
Dispatches to the appropriate assertion class via ``ASSERTION_REGISTRY``
|
|
70
|
+
based on the target's type.
|
|
71
|
+
|
|
72
|
+
If ``by`` and ``value`` are provided, a ``LocatorExpect`` is created
|
|
73
|
+
that re-finds the element on each poll cycle.
|
|
74
|
+
|
|
75
|
+
Alternatively, ``locator=(By.ID, 'foo')`` can be used as a tuple
|
|
76
|
+
shorthand for ``by=..., value=...``.
|
|
77
|
+
"""
|
|
78
|
+
if locator is not None:
|
|
79
|
+
if by is not None or value is not None:
|
|
80
|
+
raise ValueError("Cannot use both 'locator' and 'by/value' arguments")
|
|
81
|
+
by, value = locator
|
|
82
|
+
|
|
83
|
+
if (by is not None) != (value is not None):
|
|
84
|
+
raise ValueError("Must provide both 'by' and 'value', or neither")
|
|
85
|
+
|
|
86
|
+
if by is not None and value is not None:
|
|
87
|
+
from selenium_expect._locator import LocatorExpect
|
|
88
|
+
|
|
89
|
+
if not isinstance(target, WebDriver):
|
|
90
|
+
raise TypeError("expect() with by/value requires a WebDriver target")
|
|
91
|
+
|
|
92
|
+
effective_config = config if config is not None else get_config()
|
|
93
|
+
if timeout is not None or polling is not None or soft is not None:
|
|
94
|
+
overrides: dict[str, Any] = {}
|
|
95
|
+
if timeout is not None:
|
|
96
|
+
overrides["timeout"] = normalize_timeout(timeout)
|
|
97
|
+
if polling is not None:
|
|
98
|
+
if isinstance(polling, list):
|
|
99
|
+
overrides["polling_intervals"] = polling
|
|
100
|
+
else:
|
|
101
|
+
overrides["polling_interval"] = polling
|
|
102
|
+
if soft is not None:
|
|
103
|
+
overrides["soft_mode"] = soft
|
|
104
|
+
effective_config = effective_config.replace(**overrides)
|
|
105
|
+
|
|
106
|
+
return LocatorExpect(
|
|
107
|
+
driver=target,
|
|
108
|
+
by=by,
|
|
109
|
+
value=value,
|
|
110
|
+
config=effective_config,
|
|
111
|
+
message=message,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if target is None:
|
|
115
|
+
raise TypeError("expect() does not support None as target")
|
|
116
|
+
|
|
117
|
+
type_name = _resolve_target_type(target)
|
|
118
|
+
cls = ASSERTION_REGISTRY.get(type_name)
|
|
119
|
+
if cls is None:
|
|
120
|
+
raise TypeError(f"expect() does not support target type '{type_name}'")
|
|
121
|
+
|
|
122
|
+
assertion_cls = cast(type[AssertionMixin], cls)
|
|
123
|
+
|
|
124
|
+
effective_config = config if config is not None else get_config()
|
|
125
|
+
|
|
126
|
+
if timeout is not None or polling is not None or soft is not None:
|
|
127
|
+
cfg_overrides: dict[str, Any] = {}
|
|
128
|
+
if timeout is not None:
|
|
129
|
+
cfg_overrides["timeout"] = normalize_timeout(timeout)
|
|
130
|
+
if polling is not None:
|
|
131
|
+
if isinstance(polling, list):
|
|
132
|
+
cfg_overrides["polling_intervals"] = polling
|
|
133
|
+
else:
|
|
134
|
+
cfg_overrides["polling_interval"] = polling
|
|
135
|
+
if soft is not None:
|
|
136
|
+
cfg_overrides["soft_mode"] = soft
|
|
137
|
+
effective_config = effective_config.replace(**cfg_overrides)
|
|
138
|
+
|
|
139
|
+
return assertion_cls(target=target, config=effective_config, message=message)
|
|
140
|
+
|
|
141
|
+
def poll(
|
|
142
|
+
self,
|
|
143
|
+
fn: Callable[[], Any],
|
|
144
|
+
*,
|
|
145
|
+
timeout: float | None = None,
|
|
146
|
+
polling: float | list[float] | None = None,
|
|
147
|
+
config: ExpectConfig | None = None,
|
|
148
|
+
) -> PollAssertion:
|
|
149
|
+
"""Create a ``PollAssertion`` for retry-based assertions on *fn*.
|
|
150
|
+
|
|
151
|
+
Usage::
|
|
152
|
+
|
|
153
|
+
expect.poll(lambda: driver.execute_script("return document.readyState"))
|
|
154
|
+
.to_equal("complete")
|
|
155
|
+
"""
|
|
156
|
+
return PollAssertion(fn, timeout=timeout, polling=polling, config=config)
|
|
157
|
+
|
|
158
|
+
def configure(self, **defaults: Any) -> Callable[..., AssertionMixin]:
|
|
159
|
+
"""Create a pre-configured expect variant.
|
|
160
|
+
|
|
161
|
+
Returns a callable that behaves like ``expect()`` with *defaults*
|
|
162
|
+
pre-applied. Explicit kwargs from the caller override the defaults.
|
|
163
|
+
|
|
164
|
+
Usage::
|
|
165
|
+
|
|
166
|
+
fast_expect = expect.configure(timeout=1.0, polling=0.1)
|
|
167
|
+
fast_expect(el).to_be_visible()
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def _configured_expect(
|
|
171
|
+
target: Any,
|
|
172
|
+
/,
|
|
173
|
+
**overrides: Any,
|
|
174
|
+
) -> AssertionMixin:
|
|
175
|
+
merged: dict[str, Any] = {**defaults, **overrides}
|
|
176
|
+
return self(target, **merged)
|
|
177
|
+
|
|
178
|
+
return _configured_expect
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
expect = Expect()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""LocatorExpect — re-finds element on each poll cycle.
|
|
2
|
+
|
|
3
|
+
When ``expect(driver, by=By.ID, value="foo")`` is used, a ``LocatorExpect``
|
|
4
|
+
is returned instead of ``ExpectElement``. On every poll cycle it calls
|
|
5
|
+
``driver.find_element(by, value)`` to get a fresh element, then delegates
|
|
6
|
+
the condition check to the corresponding ``ExpectElement`` method.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException
|
|
14
|
+
from selenium.webdriver.remote.webdriver import WebDriver
|
|
15
|
+
|
|
16
|
+
from selenium_expect._config import ExpectConfig
|
|
17
|
+
from selenium_expect.assertions._base import AssertionMixin
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocatorExpect(AssertionMixin):
|
|
21
|
+
"""Locator-based expect that re-finds the element on each poll.
|
|
22
|
+
|
|
23
|
+
Delegates all ``ExpectElement`` assertion methods via ``__getattr__``.
|
|
24
|
+
Each assertion method is executed with a fresh element obtained from
|
|
25
|
+
``driver.find_element(by, value)`` on every poll cycle.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
driver: WebDriver,
|
|
31
|
+
by: str,
|
|
32
|
+
value: str,
|
|
33
|
+
config: ExpectConfig | None = None,
|
|
34
|
+
message: str | None = None,
|
|
35
|
+
negate: bool = False,
|
|
36
|
+
) -> None:
|
|
37
|
+
super().__init__(target=driver, config=config, message=message, negate=negate)
|
|
38
|
+
self._driver = driver
|
|
39
|
+
self._by = by
|
|
40
|
+
self._value = value
|
|
41
|
+
|
|
42
|
+
def _find_element(self) -> Any:
|
|
43
|
+
"""Find the element fresh. Returns None if not found."""
|
|
44
|
+
try:
|
|
45
|
+
return self._driver.find_element(self._by, self._value)
|
|
46
|
+
except NoSuchElementException:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def not_(self) -> LocatorExpect:
|
|
51
|
+
"""Return a negated copy."""
|
|
52
|
+
return LocatorExpect(
|
|
53
|
+
driver=self._driver,
|
|
54
|
+
by=self._by,
|
|
55
|
+
value=self._value,
|
|
56
|
+
config=self._config,
|
|
57
|
+
message=self._message,
|
|
58
|
+
negate=not self._negate,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def _entity_description(self) -> str:
|
|
62
|
+
return f"locator({self._by}={self._value!r})"
|
|
63
|
+
|
|
64
|
+
def _get_element_html(self) -> str | None:
|
|
65
|
+
el = self._find_element()
|
|
66
|
+
if el is None:
|
|
67
|
+
return None
|
|
68
|
+
try:
|
|
69
|
+
html: str | None = el.get_attribute("outerHTML")
|
|
70
|
+
return html
|
|
71
|
+
except StaleElementReferenceException:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def __getattr__(self, name: str) -> Any:
|
|
75
|
+
"""Delegate to ExpectElement methods or custom matchers with re-find on each poll.
|
|
76
|
+
|
|
77
|
+
For each assertion method call, we wrap the condition so that
|
|
78
|
+
``find_element`` is called fresh on every retry poll.
|
|
79
|
+
"""
|
|
80
|
+
# Avoid recursion for private/dunder attributes
|
|
81
|
+
if name.startswith("_"):
|
|
82
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
83
|
+
|
|
84
|
+
from selenium_expect._matcher import CustomMatcherRegistry
|
|
85
|
+
from selenium_expect.assertions.element import ExpectElement
|
|
86
|
+
|
|
87
|
+
# Check for custom matcher first
|
|
88
|
+
matcher_fn = CustomMatcherRegistry.get(name)
|
|
89
|
+
|
|
90
|
+
# Get the actual method from ExpectElement
|
|
91
|
+
element_method = getattr(ExpectElement, name, None)
|
|
92
|
+
|
|
93
|
+
if matcher_fn is None and (element_method is None or not callable(element_method)):
|
|
94
|
+
raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}")
|
|
95
|
+
|
|
96
|
+
if matcher_fn is not None:
|
|
97
|
+
|
|
98
|
+
def _invoke_matcher(*args: Any, **kwargs: Any) -> None:
|
|
99
|
+
timeout = kwargs.pop("timeout", None)
|
|
100
|
+
polling = kwargs.pop("polling", None)
|
|
101
|
+
|
|
102
|
+
def condition() -> tuple[bool, Any]:
|
|
103
|
+
try:
|
|
104
|
+
el = self._driver.find_element(self._by, self._value)
|
|
105
|
+
except NoSuchElementException:
|
|
106
|
+
return (False, "element not found")
|
|
107
|
+
try:
|
|
108
|
+
return matcher_fn(el, *args, **kwargs)
|
|
109
|
+
except StaleElementReferenceException:
|
|
110
|
+
return (False, "stale element")
|
|
111
|
+
|
|
112
|
+
self._run_assertion(
|
|
113
|
+
condition=condition,
|
|
114
|
+
condition_name=name.replace("_", " "),
|
|
115
|
+
expected=None,
|
|
116
|
+
entity=self._entity_description(),
|
|
117
|
+
timeout=timeout,
|
|
118
|
+
polling=polling,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return _invoke_matcher
|
|
122
|
+
|
|
123
|
+
def _invoke(*args: Any, **kwargs: Any) -> None:
|
|
124
|
+
timeout = kwargs.pop("timeout", None)
|
|
125
|
+
polling = kwargs.pop("polling", None)
|
|
126
|
+
|
|
127
|
+
def condition() -> tuple[bool, Any]:
|
|
128
|
+
try:
|
|
129
|
+
el = self._driver.find_element(self._by, self._value)
|
|
130
|
+
except NoSuchElementException:
|
|
131
|
+
return (False, "element not found")
|
|
132
|
+
inner_config = self._config.replace(soft_mode=False, screenshot_on_failure=False)
|
|
133
|
+
temp = ExpectElement(
|
|
134
|
+
target=el,
|
|
135
|
+
config=inner_config,
|
|
136
|
+
message=self._message,
|
|
137
|
+
negate=False,
|
|
138
|
+
)
|
|
139
|
+
method = getattr(temp, name)
|
|
140
|
+
try:
|
|
141
|
+
method(*args, timeout=0.001, **kwargs)
|
|
142
|
+
return (True, "passed")
|
|
143
|
+
except AssertionError:
|
|
144
|
+
return (False, "failed")
|
|
145
|
+
except StaleElementReferenceException:
|
|
146
|
+
return (False, "stale element")
|
|
147
|
+
|
|
148
|
+
self._run_assertion(
|
|
149
|
+
condition=condition,
|
|
150
|
+
condition_name=name.replace("_", " "),
|
|
151
|
+
expected=None,
|
|
152
|
+
entity=self._entity_description(),
|
|
153
|
+
timeout=timeout,
|
|
154
|
+
polling=polling,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return _invoke
|