jc-selenium-helper 0.1.0__tar.gz
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.
- jc_selenium_helper-0.1.0/LICENSE +21 -0
- jc_selenium_helper-0.1.0/PKG-INFO +82 -0
- jc_selenium_helper-0.1.0/README.md +40 -0
- jc_selenium_helper-0.1.0/pyproject.toml +84 -0
- jc_selenium_helper-0.1.0/setup.cfg +4 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/__init__.py +7 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/axe.py +21 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/browser.py +190 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/colors.py +14 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/config.py +28 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/legacy.py +158 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/plugin.py +43 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper/py.typed +0 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/PKG-INFO +82 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/SOURCES.txt +28 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/dependency_links.txt +1 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/entry_points.txt +2 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/requires.txt +27 -0
- jc_selenium_helper-0.1.0/src/jc_selenium_helper.egg-info/top_level.txt +1 -0
- jc_selenium_helper-0.1.0/tests/test_axe.py +19 -0
- jc_selenium_helper-0.1.0/tests/test_browser_actions.py +34 -0
- jc_selenium_helper-0.1.0/tests/test_browser_assertions.py +35 -0
- jc_selenium_helper-0.1.0/tests/test_browser_finders.py +24 -0
- jc_selenium_helper-0.1.0/tests/test_browser_frame.py +12 -0
- jc_selenium_helper-0.1.0/tests/test_browser_waits.py +29 -0
- jc_selenium_helper-0.1.0/tests/test_colors.py +16 -0
- jc_selenium_helper-0.1.0/tests/test_config.py +20 -0
- jc_selenium_helper-0.1.0/tests/test_legacy.py +24 -0
- jc_selenium_helper-0.1.0/tests/test_package.py +7 -0
- jc_selenium_helper-0.1.0/tests/test_plugin.py +48 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexander Jacob
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jc-selenium-helper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Selenium WebDriver helper utilities for writing browser tests
|
|
5
|
+
Author-email: Alexander Jacob <alexander.jacob@jacob-consulting.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jacob-consulting/jc-selenium-helper
|
|
8
|
+
Project-URL: Documentation, https://jc-selenium-helper.readthedocs.io
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: selenium>=4.38.0
|
|
21
|
+
Provides-Extra: pytest
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "pytest"
|
|
23
|
+
Requires-Dist: pytest-selenium>=4.1.0; extra == "pytest"
|
|
24
|
+
Provides-Extra: config
|
|
25
|
+
Requires-Dist: dynaconf>=3.2; extra == "config"
|
|
26
|
+
Provides-Extra: axe
|
|
27
|
+
Requires-Dist: axe-selenium-python>=2.1.6; extra == "axe"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: jc-selenium-helper[axe,config,pytest]; extra == "all"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
|
32
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
33
|
+
Requires-Dist: bump-my-version; extra == "dev"
|
|
34
|
+
Requires-Dist: mkdocs>=1.6.1; extra == "dev"
|
|
35
|
+
Requires-Dist: mkdocs-awesome-pages-plugin>=2.10.1; extra == "dev"
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: jc-selenium-helper[axe,config,pytest]; extra == "test"
|
|
38
|
+
Requires-Dist: nox; extra == "test"
|
|
39
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
40
|
+
Requires-Dist: pytest-xdist; extra == "test"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# jc-selenium-helper
|
|
44
|
+
|
|
45
|
+
Selenium WebDriver helper utilities for writing browser tests.
|
|
46
|
+
|
|
47
|
+
`Browser` wraps a Selenium `WebDriver` with concise, XPath-by-default finders,
|
|
48
|
+
waits, actions, assertions, and frame handling. Optional extras add a ready-made
|
|
49
|
+
pytest fixture, a Dynaconf settings loader, and axe-core accessibility checks.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install jc-selenium-helper # core (selenium only)
|
|
55
|
+
pip install "jc-selenium-helper[pytest]" # + pytest fixtures
|
|
56
|
+
pip install "jc-selenium-helper[config]" # + Dynaconf settings loader
|
|
57
|
+
pip install "jc-selenium-helper[axe]" # + accessibility checks
|
|
58
|
+
pip install "jc-selenium-helper[all]" # everything
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from selenium import webdriver
|
|
65
|
+
from jc_selenium_helper import Browser
|
|
66
|
+
|
|
67
|
+
driver = webdriver.Chrome()
|
|
68
|
+
browser = Browser(driver, default_timeout=30)
|
|
69
|
+
|
|
70
|
+
browser.open("https://example.com")
|
|
71
|
+
browser.wait_and_click("//a[@id='more']") # XPath by default
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- Full documentation: https://jc-selenium-helper.readthedocs.io
|
|
77
|
+
- PyPI: https://pypi.org/project/jc-selenium-helper/
|
|
78
|
+
- Source: https://github.com/jacob-consulting/jc-selenium-helper
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# jc-selenium-helper
|
|
2
|
+
|
|
3
|
+
Selenium WebDriver helper utilities for writing browser tests.
|
|
4
|
+
|
|
5
|
+
`Browser` wraps a Selenium `WebDriver` with concise, XPath-by-default finders,
|
|
6
|
+
waits, actions, assertions, and frame handling. Optional extras add a ready-made
|
|
7
|
+
pytest fixture, a Dynaconf settings loader, and axe-core accessibility checks.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install jc-selenium-helper # core (selenium only)
|
|
13
|
+
pip install "jc-selenium-helper[pytest]" # + pytest fixtures
|
|
14
|
+
pip install "jc-selenium-helper[config]" # + Dynaconf settings loader
|
|
15
|
+
pip install "jc-selenium-helper[axe]" # + accessibility checks
|
|
16
|
+
pip install "jc-selenium-helper[all]" # everything
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quickstart
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
from selenium import webdriver
|
|
23
|
+
from jc_selenium_helper import Browser
|
|
24
|
+
|
|
25
|
+
driver = webdriver.Chrome()
|
|
26
|
+
browser = Browser(driver, default_timeout=30)
|
|
27
|
+
|
|
28
|
+
browser.open("https://example.com")
|
|
29
|
+
browser.wait_and_click("//a[@id='more']") # XPath by default
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Links
|
|
33
|
+
|
|
34
|
+
- Full documentation: https://jc-selenium-helper.readthedocs.io
|
|
35
|
+
- PyPI: https://pypi.org/project/jc-selenium-helper/
|
|
36
|
+
- Source: https://github.com/jacob-consulting/jc-selenium-helper
|
|
37
|
+
|
|
38
|
+
## License
|
|
39
|
+
|
|
40
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "jc-selenium-helper"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Selenium WebDriver helper utilities for writing browser tests"
|
|
5
|
+
authors = [{ name = "Alexander Jacob", email = "alexander.jacob@jacob-consulting.de" }]
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"License :: OSI Approved :: MIT License",
|
|
13
|
+
"Programming Language :: Python :: 3.12",
|
|
14
|
+
"Programming Language :: Python :: 3.13",
|
|
15
|
+
"Programming Language :: Python :: 3.14",
|
|
16
|
+
"Framework :: Pytest",
|
|
17
|
+
"Topic :: Software Development :: Testing",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"selenium>=4.38.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/jacob-consulting/jc-selenium-helper"
|
|
25
|
+
Documentation = "https://jc-selenium-helper.readthedocs.io"
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
pytest = ["pytest>=8", "pytest-selenium>=4.1.0"]
|
|
29
|
+
config = ["dynaconf>=3.2"]
|
|
30
|
+
axe = ["axe-selenium-python>=2.1.6"]
|
|
31
|
+
all = ["jc-selenium-helper[pytest,config,axe]"]
|
|
32
|
+
dev = [
|
|
33
|
+
"ruff",
|
|
34
|
+
"pre-commit",
|
|
35
|
+
"bump-my-version",
|
|
36
|
+
"mkdocs>=1.6.1",
|
|
37
|
+
"mkdocs-awesome-pages-plugin>=2.10.1",
|
|
38
|
+
]
|
|
39
|
+
test = [
|
|
40
|
+
"jc-selenium-helper[pytest,config,axe]",
|
|
41
|
+
"nox",
|
|
42
|
+
"pytest-cov",
|
|
43
|
+
"pytest-xdist",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[project.entry-points.pytest11]
|
|
47
|
+
jc_selenium_helper = "jc_selenium_helper.plugin"
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["setuptools>=68", "wheel"]
|
|
51
|
+
build-backend = "setuptools.build_meta"
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.packages.find]
|
|
54
|
+
where = ["src"]
|
|
55
|
+
|
|
56
|
+
[tool.setuptools.package-data]
|
|
57
|
+
jc_selenium_helper = ["py.typed"]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 120
|
|
61
|
+
target-version = "py312"
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint]
|
|
64
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
65
|
+
|
|
66
|
+
[tool.pytest.ini_options]
|
|
67
|
+
testpaths = ["tests"]
|
|
68
|
+
addopts = "-p no:cacheprovider"
|
|
69
|
+
|
|
70
|
+
[tool.bumpversion]
|
|
71
|
+
current_version = "0.1.0"
|
|
72
|
+
allow_dirty = false
|
|
73
|
+
commit = true
|
|
74
|
+
tag = true
|
|
75
|
+
|
|
76
|
+
[[tool.bumpversion.files]]
|
|
77
|
+
filename = "pyproject.toml"
|
|
78
|
+
search = 'version = "{current_version}"'
|
|
79
|
+
replace = 'version = "{new_version}"'
|
|
80
|
+
|
|
81
|
+
[[tool.bumpversion.files]]
|
|
82
|
+
filename = "src/jc_selenium_helper/__init__.py"
|
|
83
|
+
search = '__version__ = "{current_version}"'
|
|
84
|
+
replace = '__version__ = "{new_version}"'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Optional accessibility checks via axe-core (requires the ``axe`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from axe_selenium_python import Axe
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_axe(driver) -> dict:
|
|
9
|
+
"""Inject axe-core into the current page and return the raw results dict."""
|
|
10
|
+
axe = Axe(driver)
|
|
11
|
+
axe.inject()
|
|
12
|
+
return axe.run()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def assert_no_violations(driver) -> None:
|
|
16
|
+
"""Run axe-core and raise ``AssertionError`` if any violations are found."""
|
|
17
|
+
results = run_axe(driver)
|
|
18
|
+
violations = results.get("violations", [])
|
|
19
|
+
if violations:
|
|
20
|
+
ids = ", ".join(v["id"] for v in violations)
|
|
21
|
+
raise AssertionError(f"{len(violations)} accessibility violation(s): {ids}")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# src/jc_selenium_helper/browser.py
|
|
2
|
+
"""A thin, ergonomic wrapper around a Selenium WebDriver."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
|
9
|
+
from selenium.webdriver.common.action_chains import ActionChains
|
|
10
|
+
from selenium.webdriver.common.by import By
|
|
11
|
+
from selenium.webdriver.common.keys import Keys
|
|
12
|
+
from selenium.webdriver.remote.webelement import WebElement
|
|
13
|
+
from selenium.webdriver.support import expected_conditions as EC
|
|
14
|
+
from selenium.webdriver.support.select import Select
|
|
15
|
+
from selenium.webdriver.support.wait import WebDriverWait
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Browser:
|
|
19
|
+
"""Wraps a Selenium ``WebDriver`` with convenience helpers.
|
|
20
|
+
|
|
21
|
+
Locators default to XPath; pass ``by=By.CSS_SELECTOR`` (or any Selenium
|
|
22
|
+
``By`` value) to use a different strategy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, driver, default_timeout: float = 120, poll_pause: float = 1) -> None:
|
|
26
|
+
self.driver = driver
|
|
27
|
+
self.default_timeout = default_timeout
|
|
28
|
+
self.poll_pause = poll_pause
|
|
29
|
+
|
|
30
|
+
# -- navigation --
|
|
31
|
+
def open(self, url: str) -> None:
|
|
32
|
+
self.driver.get(url)
|
|
33
|
+
|
|
34
|
+
# -- finders --
|
|
35
|
+
def find(self, locator: str, by: str = By.XPATH) -> WebElement:
|
|
36
|
+
return self.driver.find_element(by, locator)
|
|
37
|
+
|
|
38
|
+
def find_all(self, locator: str, by: str = By.XPATH) -> list[WebElement]:
|
|
39
|
+
return self.driver.find_elements(by, locator)
|
|
40
|
+
|
|
41
|
+
def exists(self, locator: str, by: str = By.XPATH) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
self.driver.find_element(by, locator)
|
|
44
|
+
except NoSuchElementException:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
# -- waits --
|
|
49
|
+
def _timeout(self, timeout: float | None) -> float:
|
|
50
|
+
return self.default_timeout if timeout is None else timeout
|
|
51
|
+
|
|
52
|
+
def wait_present(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> WebElement:
|
|
53
|
+
WebDriverWait(self.driver, self._timeout(timeout)).until(EC.presence_of_element_located((by, locator)))
|
|
54
|
+
return self.driver.find_element(by, locator)
|
|
55
|
+
|
|
56
|
+
def wait_clickable(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> WebElement:
|
|
57
|
+
WebDriverWait(self.driver, self._timeout(timeout)).until(EC.element_to_be_clickable((by, locator)))
|
|
58
|
+
return self.driver.find_element(by, locator)
|
|
59
|
+
|
|
60
|
+
def wait_not_present(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> bool:
|
|
61
|
+
seconds = self._timeout(timeout)
|
|
62
|
+
try:
|
|
63
|
+
WebDriverWait(self.driver, seconds).until(EC.invisibility_of_element_located((by, locator)))
|
|
64
|
+
except TimeoutException as exc:
|
|
65
|
+
raise TimeoutException(f"Element '{locator}' still present after {seconds}s") from exc
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
def wait_document_ready(self, timeout: float | None = None) -> None:
|
|
69
|
+
seconds = self._timeout(timeout)
|
|
70
|
+
elapsed = 0.0
|
|
71
|
+
while self.driver.execute_script("return document.readyState") != "complete":
|
|
72
|
+
time.sleep(self.poll_pause)
|
|
73
|
+
elapsed += self.poll_pause
|
|
74
|
+
if elapsed > seconds:
|
|
75
|
+
raise TimeoutError("document.readyState never reached 'complete'")
|
|
76
|
+
|
|
77
|
+
def wait_page_loaded(
|
|
78
|
+
self,
|
|
79
|
+
check_path: str,
|
|
80
|
+
by: str = By.XPATH,
|
|
81
|
+
retries: int = 5,
|
|
82
|
+
interval: float = 10,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Wait for ``check_path`` to appear, refreshing between attempts.
|
|
85
|
+
|
|
86
|
+
Generic replacement for the app-specific ``seite_geladen`` loop.
|
|
87
|
+
"""
|
|
88
|
+
time.sleep(self.poll_pause)
|
|
89
|
+
attempt = 0
|
|
90
|
+
while not self.driver.find_elements(by, check_path):
|
|
91
|
+
self.driver.refresh()
|
|
92
|
+
time.sleep(interval)
|
|
93
|
+
attempt += 1
|
|
94
|
+
if attempt > retries:
|
|
95
|
+
raise TimeoutError(f"Page not loaded (missing {check_path})")
|
|
96
|
+
self.wait_document_ready()
|
|
97
|
+
|
|
98
|
+
# -- actions --
|
|
99
|
+
def wait_and_click(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> None:
|
|
100
|
+
self.wait_clickable(locator, by, timeout).click()
|
|
101
|
+
|
|
102
|
+
def double_click(self, locator: str, by: str = By.XPATH, pause: float = 0) -> None:
|
|
103
|
+
element = self.find(locator, by)
|
|
104
|
+
ActionChains(self.driver).move_to_element(element).double_click().perform()
|
|
105
|
+
if pause:
|
|
106
|
+
time.sleep(pause)
|
|
107
|
+
|
|
108
|
+
def hover(self, locator: str, by: str = By.XPATH) -> None:
|
|
109
|
+
element = self.find(locator, by)
|
|
110
|
+
ActionChains(self.driver).move_to_element(element).perform()
|
|
111
|
+
|
|
112
|
+
def hover_with_offset(self, locator: str, x_offset: int, y_offset: int, by: str = By.XPATH) -> None:
|
|
113
|
+
element = self.find(locator, by)
|
|
114
|
+
actions = ActionChains(self.driver)
|
|
115
|
+
actions.move_to_element(element).perform()
|
|
116
|
+
actions.move_by_offset(x_offset, y_offset).perform()
|
|
117
|
+
|
|
118
|
+
def move_to(self, locator: str, by: str = By.XPATH) -> None:
|
|
119
|
+
element = self.find(locator, by)
|
|
120
|
+
ActionChains(self.driver).move_to_element(element).perform()
|
|
121
|
+
time.sleep(self.poll_pause)
|
|
122
|
+
|
|
123
|
+
def wait_move_click(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> None:
|
|
124
|
+
element = self.wait_clickable(locator, by, timeout)
|
|
125
|
+
ActionChains(self.driver).move_to_element(element).perform()
|
|
126
|
+
time.sleep(self.poll_pause)
|
|
127
|
+
element.click()
|
|
128
|
+
|
|
129
|
+
def type_text(self, locator: str, text: str, by: str = By.XPATH) -> None:
|
|
130
|
+
self.find(locator, by).send_keys(text)
|
|
131
|
+
|
|
132
|
+
def upload_file(self, css_selector: str, path: str) -> None:
|
|
133
|
+
self.driver.find_element(By.CSS_SELECTOR, css_selector).send_keys(path)
|
|
134
|
+
|
|
135
|
+
def click_in_new_tab(
|
|
136
|
+
self,
|
|
137
|
+
locator: str,
|
|
138
|
+
check_path: str,
|
|
139
|
+
by: str = By.XPATH,
|
|
140
|
+
check_by: str = By.XPATH,
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Ctrl-click a link, verify the new tab, close it, return to the first tab."""
|
|
143
|
+
element = self.find(locator, by)
|
|
144
|
+
ActionChains(self.driver).key_down(Keys.CONTROL).click(element).key_up(Keys.CONTROL).perform()
|
|
145
|
+
self.driver.switch_to.window(self.driver.window_handles[-1])
|
|
146
|
+
self.wait_present(check_path, check_by)
|
|
147
|
+
self.driver.close()
|
|
148
|
+
self.driver.switch_to.window(self.driver.window_handles[0])
|
|
149
|
+
|
|
150
|
+
# -- assertions --
|
|
151
|
+
def assert_checkbox_checked(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> None:
|
|
152
|
+
if not self.wait_present(locator, by, timeout).is_selected():
|
|
153
|
+
raise AssertionError(f"Checkbox is not checked: {locator}")
|
|
154
|
+
|
|
155
|
+
def assert_checkbox_unchecked(self, locator: str, by: str = By.XPATH, timeout: float | None = None) -> None:
|
|
156
|
+
if self.wait_present(locator, by, timeout).is_selected():
|
|
157
|
+
raise AssertionError(f"Checkbox is checked: {locator}")
|
|
158
|
+
|
|
159
|
+
def assert_selected_option(self, locator: str, expected_text: str, by: str = By.XPATH) -> None:
|
|
160
|
+
select = Select(self.find(locator, by))
|
|
161
|
+
actual = select.first_selected_option.text
|
|
162
|
+
if actual != expected_text:
|
|
163
|
+
raise AssertionError(f"Selected option '{actual}' != expected '{expected_text}'")
|
|
164
|
+
|
|
165
|
+
def assert_present(self, locator: str, by: str = By.XPATH) -> None:
|
|
166
|
+
if not self.exists(locator, by):
|
|
167
|
+
raise AssertionError(f"Element not present: {locator}")
|
|
168
|
+
|
|
169
|
+
# -- frames --
|
|
170
|
+
def fill_in_frame(
|
|
171
|
+
self,
|
|
172
|
+
frame_path: str,
|
|
173
|
+
inner_path: str,
|
|
174
|
+
text: str,
|
|
175
|
+
by: str = By.XPATH,
|
|
176
|
+
submit: bool = True,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Switch into ``frame_path``, type ``text`` into ``inner_path``, switch back.
|
|
179
|
+
|
|
180
|
+
Generic replacement for the app-specific ``switch_and_fill_frame`` (which
|
|
181
|
+
hardcoded the TinyMCE inner path).
|
|
182
|
+
"""
|
|
183
|
+
frame = self.find(frame_path, by)
|
|
184
|
+
self.driver.switch_to.frame(frame)
|
|
185
|
+
try:
|
|
186
|
+
self.type_text(inner_path, text)
|
|
187
|
+
if submit:
|
|
188
|
+
self.type_text(inner_path, Keys.RETURN)
|
|
189
|
+
finally:
|
|
190
|
+
self.driver.switch_to.default_content()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Color conversion helpers (replaces the colormap/easydev dependency)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def rgb_to_hex(rgb_string: str) -> str:
|
|
7
|
+
"""Convert a CSS ``rgb(...)`` or ``rgba(...)`` string to ``#rrggbb``.
|
|
8
|
+
|
|
9
|
+
The alpha channel of ``rgba`` is ignored.
|
|
10
|
+
"""
|
|
11
|
+
inner = rgb_string[rgb_string.index("(") + 1 : rgb_string.index(")")]
|
|
12
|
+
parts = [int(float(component.strip())) for component in inner.split(",")[:3]]
|
|
13
|
+
r, g, b = parts
|
|
14
|
+
return f"#{r:02x}{g:02x}{b:02x}"
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Optional Dynaconf settings loader (requires the ``config`` extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from dynaconf import Dynaconf
|
|
8
|
+
from dynaconf.base import Settings
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_settings(location: str | Path, *files: str) -> Settings:
|
|
12
|
+
"""Build a Dynaconf ``Settings`` from files resolved next to ``location``.
|
|
13
|
+
|
|
14
|
+
``location`` is typically ``__file__`` of the caller; each name in ``files``
|
|
15
|
+
is resolved against ``Path(location).parent`` and must exist.
|
|
16
|
+
"""
|
|
17
|
+
parent = Path(location).parent
|
|
18
|
+
settings_files = []
|
|
19
|
+
for name in files:
|
|
20
|
+
settings_file = (parent / name).resolve()
|
|
21
|
+
assert settings_file.exists(), f"settings file not found: {settings_file}"
|
|
22
|
+
settings_files.append(settings_file)
|
|
23
|
+
return Dynaconf(
|
|
24
|
+
environments=True,
|
|
25
|
+
envvar_prefix="SELENIUM",
|
|
26
|
+
env_switcher="SELENIUM_ENVIRONMENT",
|
|
27
|
+
settings_files=settings_files,
|
|
28
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# src/jc_selenium_helper/legacy.py
|
|
2
|
+
"""Backwards-compatible adapter exposing the original German/ad-hoc API.
|
|
3
|
+
|
|
4
|
+
Existing suites can migrate by replacing::
|
|
5
|
+
|
|
6
|
+
from libs.browser import Browser
|
|
7
|
+
|
|
8
|
+
with::
|
|
9
|
+
|
|
10
|
+
from jc_selenium_helper.legacy import Browser
|
|
11
|
+
|
|
12
|
+
Every legacy method emits a ``DeprecationWarning`` and delegates to the clean
|
|
13
|
+
:class:`jc_selenium_helper.browser.Browser` API. Two methods
|
|
14
|
+
(``seite_geladen`` and ``switch_and_fill_frame``) keep their original
|
|
15
|
+
app-specific behavior verbatim so existing tests are unaffected.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
import warnings
|
|
22
|
+
|
|
23
|
+
from selenium.webdriver.common.by import By
|
|
24
|
+
from selenium.webdriver.common.keys import Keys
|
|
25
|
+
|
|
26
|
+
from jc_selenium_helper.browser import Browser as _Browser
|
|
27
|
+
from jc_selenium_helper.colors import rgb_to_hex
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _deprecated(old: str, new: str) -> None:
|
|
31
|
+
warnings.warn(
|
|
32
|
+
f"{old}() is deprecated; use {new}() instead.",
|
|
33
|
+
DeprecationWarning,
|
|
34
|
+
stacklevel=3,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class LegacyBrowser(_Browser):
|
|
39
|
+
"""Deprecated German-named API preserved for existing test suites."""
|
|
40
|
+
|
|
41
|
+
def xpath(self, xpath):
|
|
42
|
+
_deprecated("xpath", "find")
|
|
43
|
+
return self.find(xpath)
|
|
44
|
+
|
|
45
|
+
def xpaths(self, xpath):
|
|
46
|
+
_deprecated("xpaths", "find_all")
|
|
47
|
+
return self.find_all(xpath)
|
|
48
|
+
|
|
49
|
+
def css(self, selector):
|
|
50
|
+
_deprecated("css", "find")
|
|
51
|
+
return self.find(selector, by=By.CSS_SELECTOR)
|
|
52
|
+
|
|
53
|
+
def get_elements(self, path):
|
|
54
|
+
_deprecated("get_elements", "find_all")
|
|
55
|
+
return self.find_all(path)
|
|
56
|
+
|
|
57
|
+
def check_exists_by_xpath(self, path):
|
|
58
|
+
_deprecated("check_exists_by_xpath", "exists")
|
|
59
|
+
return self.exists(path)
|
|
60
|
+
|
|
61
|
+
def wait_element_present(self, path, time_to_wait=None):
|
|
62
|
+
_deprecated("wait_element_present", "wait_present")
|
|
63
|
+
return self.wait_present(path, timeout=time_to_wait)
|
|
64
|
+
|
|
65
|
+
def wait_element_not_present(self, path, time_to_wait=None):
|
|
66
|
+
_deprecated("wait_element_not_present", "wait_not_present")
|
|
67
|
+
return self.wait_not_present(path, timeout=time_to_wait)
|
|
68
|
+
|
|
69
|
+
def wait_element_clickable(self, path, time_to_wait=None):
|
|
70
|
+
_deprecated("wait_element_clickable", "wait_clickable")
|
|
71
|
+
return self.wait_clickable(path, timeout=time_to_wait)
|
|
72
|
+
|
|
73
|
+
def inhalt_geladen(self):
|
|
74
|
+
_deprecated("inhalt_geladen", "wait_document_ready")
|
|
75
|
+
return self.wait_document_ready()
|
|
76
|
+
|
|
77
|
+
def doppelclick_element(self, pause, hover):
|
|
78
|
+
_deprecated("doppelclick_element", "double_click")
|
|
79
|
+
return self.double_click(hover, pause=pause)
|
|
80
|
+
|
|
81
|
+
def hover_element(self, hover):
|
|
82
|
+
_deprecated("hover_element", "hover")
|
|
83
|
+
return self.hover(hover)
|
|
84
|
+
|
|
85
|
+
def hover_element_with_offset(self, hover, x_offset, y_offset):
|
|
86
|
+
_deprecated("hover_element_with_offset", "hover_with_offset")
|
|
87
|
+
return self.hover_with_offset(hover, x_offset, y_offset)
|
|
88
|
+
|
|
89
|
+
def move_to_element(self, path):
|
|
90
|
+
_deprecated("move_to_element", "move_to")
|
|
91
|
+
return self.move_to(path)
|
|
92
|
+
|
|
93
|
+
def wait_move_click_element(self, path, time_to_wait=None):
|
|
94
|
+
_deprecated("wait_move_click_element", "wait_move_click")
|
|
95
|
+
return self.wait_move_click(path, timeout=time_to_wait)
|
|
96
|
+
|
|
97
|
+
def click_new_tab(self, path, check_path):
|
|
98
|
+
_deprecated("click_new_tab", "click_in_new_tab")
|
|
99
|
+
return self.click_in_new_tab(path, check_path)
|
|
100
|
+
|
|
101
|
+
def eingabe(self, path, text):
|
|
102
|
+
_deprecated("eingabe", "type_text")
|
|
103
|
+
return self.type_text(path, text)
|
|
104
|
+
|
|
105
|
+
def eingabe_upload_css(self, path, text):
|
|
106
|
+
_deprecated("eingabe_upload_css", "upload_file")
|
|
107
|
+
return self.upload_file(path, text)
|
|
108
|
+
|
|
109
|
+
def assert_checkbox_is_checked(self, path, time_to_wait=None):
|
|
110
|
+
_deprecated("assert_checkbox_is_checked", "assert_checkbox_checked")
|
|
111
|
+
return self.assert_checkbox_checked(path, timeout=time_to_wait)
|
|
112
|
+
|
|
113
|
+
def assert_checkbox_is_not_checked(self, path, time_to_wait=None):
|
|
114
|
+
_deprecated("assert_checkbox_is_not_checked", "assert_checkbox_unchecked")
|
|
115
|
+
return self.assert_checkbox_unchecked(path, timeout=time_to_wait)
|
|
116
|
+
|
|
117
|
+
def check_select(self, path, exp_text):
|
|
118
|
+
_deprecated("check_select", "assert_selected_option")
|
|
119
|
+
return self.assert_selected_option(path, exp_text)
|
|
120
|
+
|
|
121
|
+
def ele_test(self, path):
|
|
122
|
+
_deprecated("ele_test", "assert_present")
|
|
123
|
+
return self.assert_present(path)
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def switch_rgb(color_rgb):
|
|
127
|
+
_deprecated("switch_rgb", "colors.rgb_to_hex")
|
|
128
|
+
return rgb_to_hex(color_rgb)
|
|
129
|
+
|
|
130
|
+
# -- verbatim app-specific behavior (unchanged for compatibility) --
|
|
131
|
+
def seite_geladen(self, check_path):
|
|
132
|
+
_deprecated("seite_geladen", "wait_page_loaded")
|
|
133
|
+
time_counter = 0
|
|
134
|
+
time.sleep(1)
|
|
135
|
+
while True:
|
|
136
|
+
element = self.driver.find_elements(By.XPATH, check_path)
|
|
137
|
+
if element:
|
|
138
|
+
break
|
|
139
|
+
self.driver.refresh()
|
|
140
|
+
time.sleep(10)
|
|
141
|
+
time_counter += 1
|
|
142
|
+
if time_counter > 5:
|
|
143
|
+
raise ValueError("Seite nicht geladen")
|
|
144
|
+
self.inhalt_geladen()
|
|
145
|
+
|
|
146
|
+
def switch_and_fill_frame(self, combined, path, text):
|
|
147
|
+
_deprecated("switch_and_fill_frame", "fill_in_frame")
|
|
148
|
+
time.sleep(combined.settings.k_pause)
|
|
149
|
+
switch_frame = self.driver.find_element(By.XPATH, path)
|
|
150
|
+
self.driver.switch_to.frame(switch_frame)
|
|
151
|
+
tmp_path = "//*[@id='tinymce']/p"
|
|
152
|
+
self.type_text(tmp_path, text)
|
|
153
|
+
self.type_text(tmp_path, Keys.RETURN)
|
|
154
|
+
self.driver.switch_to.default_content()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Drop-in module-level alias: ``from jc_selenium_helper.legacy import Browser``
|
|
158
|
+
Browser = LegacyBrowser
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""pytest plugin exposing ready-made fixtures.
|
|
2
|
+
|
|
3
|
+
Enabled automatically when the package is installed with the ``pytest`` extra
|
|
4
|
+
(registered via the ``pytest11`` entry point). Fixtures are namespaced with a
|
|
5
|
+
``jc_`` prefix to avoid clashing with a project's own ``browser`` fixture.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
from jc_selenium_helper.browser import Browser
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def jc_chrome_options():
|
|
17
|
+
"""Sensible default Chrome options; override in a project conftest to customize."""
|
|
18
|
+
from selenium.webdriver.chrome.options import Options
|
|
19
|
+
|
|
20
|
+
options = Options()
|
|
21
|
+
options.add_argument("--headless=new")
|
|
22
|
+
options.add_argument("--disable-gpu")
|
|
23
|
+
options.add_argument("--ignore-certificate-errors")
|
|
24
|
+
options.add_argument("--no-sandbox")
|
|
25
|
+
options.add_argument("--disable-dev-shm-usage")
|
|
26
|
+
return options
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def chrome_options(jc_chrome_options):
|
|
31
|
+
"""Feed jc_chrome_options into pytest-selenium's driver.
|
|
32
|
+
|
|
33
|
+
pytest-selenium builds its Chrome driver from a fixture named
|
|
34
|
+
``chrome_options``; delegating here makes the package's defaults apply while
|
|
35
|
+
letting users customize by overriding ``jc_chrome_options``.
|
|
36
|
+
"""
|
|
37
|
+
return jc_chrome_options
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def jc_browser(selenium) -> Browser:
|
|
42
|
+
"""Wrap the pytest-selenium ``selenium`` driver in a :class:`Browser`."""
|
|
43
|
+
return Browser(selenium)
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jc-selenium-helper
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Selenium WebDriver helper utilities for writing browser tests
|
|
5
|
+
Author-email: Alexander Jacob <alexander.jacob@jacob-consulting.de>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jacob-consulting/jc-selenium-helper
|
|
8
|
+
Project-URL: Documentation, https://jc-selenium-helper.readthedocs.io
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Framework :: Pytest
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: selenium>=4.38.0
|
|
21
|
+
Provides-Extra: pytest
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "pytest"
|
|
23
|
+
Requires-Dist: pytest-selenium>=4.1.0; extra == "pytest"
|
|
24
|
+
Provides-Extra: config
|
|
25
|
+
Requires-Dist: dynaconf>=3.2; extra == "config"
|
|
26
|
+
Provides-Extra: axe
|
|
27
|
+
Requires-Dist: axe-selenium-python>=2.1.6; extra == "axe"
|
|
28
|
+
Provides-Extra: all
|
|
29
|
+
Requires-Dist: jc-selenium-helper[axe,config,pytest]; extra == "all"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: ruff; extra == "dev"
|
|
32
|
+
Requires-Dist: pre-commit; extra == "dev"
|
|
33
|
+
Requires-Dist: bump-my-version; extra == "dev"
|
|
34
|
+
Requires-Dist: mkdocs>=1.6.1; extra == "dev"
|
|
35
|
+
Requires-Dist: mkdocs-awesome-pages-plugin>=2.10.1; extra == "dev"
|
|
36
|
+
Provides-Extra: test
|
|
37
|
+
Requires-Dist: jc-selenium-helper[axe,config,pytest]; extra == "test"
|
|
38
|
+
Requires-Dist: nox; extra == "test"
|
|
39
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
40
|
+
Requires-Dist: pytest-xdist; extra == "test"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
# jc-selenium-helper
|
|
44
|
+
|
|
45
|
+
Selenium WebDriver helper utilities for writing browser tests.
|
|
46
|
+
|
|
47
|
+
`Browser` wraps a Selenium `WebDriver` with concise, XPath-by-default finders,
|
|
48
|
+
waits, actions, assertions, and frame handling. Optional extras add a ready-made
|
|
49
|
+
pytest fixture, a Dynaconf settings loader, and axe-core accessibility checks.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install jc-selenium-helper # core (selenium only)
|
|
55
|
+
pip install "jc-selenium-helper[pytest]" # + pytest fixtures
|
|
56
|
+
pip install "jc-selenium-helper[config]" # + Dynaconf settings loader
|
|
57
|
+
pip install "jc-selenium-helper[axe]" # + accessibility checks
|
|
58
|
+
pip install "jc-selenium-helper[all]" # everything
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Quickstart
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from selenium import webdriver
|
|
65
|
+
from jc_selenium_helper import Browser
|
|
66
|
+
|
|
67
|
+
driver = webdriver.Chrome()
|
|
68
|
+
browser = Browser(driver, default_timeout=30)
|
|
69
|
+
|
|
70
|
+
browser.open("https://example.com")
|
|
71
|
+
browser.wait_and_click("//a[@id='more']") # XPath by default
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- Full documentation: https://jc-selenium-helper.readthedocs.io
|
|
77
|
+
- PyPI: https://pypi.org/project/jc-selenium-helper/
|
|
78
|
+
- Source: https://github.com/jacob-consulting/jc-selenium-helper
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/jc_selenium_helper/__init__.py
|
|
5
|
+
src/jc_selenium_helper/axe.py
|
|
6
|
+
src/jc_selenium_helper/browser.py
|
|
7
|
+
src/jc_selenium_helper/colors.py
|
|
8
|
+
src/jc_selenium_helper/config.py
|
|
9
|
+
src/jc_selenium_helper/legacy.py
|
|
10
|
+
src/jc_selenium_helper/plugin.py
|
|
11
|
+
src/jc_selenium_helper/py.typed
|
|
12
|
+
src/jc_selenium_helper.egg-info/PKG-INFO
|
|
13
|
+
src/jc_selenium_helper.egg-info/SOURCES.txt
|
|
14
|
+
src/jc_selenium_helper.egg-info/dependency_links.txt
|
|
15
|
+
src/jc_selenium_helper.egg-info/entry_points.txt
|
|
16
|
+
src/jc_selenium_helper.egg-info/requires.txt
|
|
17
|
+
src/jc_selenium_helper.egg-info/top_level.txt
|
|
18
|
+
tests/test_axe.py
|
|
19
|
+
tests/test_browser_actions.py
|
|
20
|
+
tests/test_browser_assertions.py
|
|
21
|
+
tests/test_browser_finders.py
|
|
22
|
+
tests/test_browser_frame.py
|
|
23
|
+
tests/test_browser_waits.py
|
|
24
|
+
tests/test_colors.py
|
|
25
|
+
tests/test_config.py
|
|
26
|
+
tests/test_legacy.py
|
|
27
|
+
tests/test_package.py
|
|
28
|
+
tests/test_plugin.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
selenium>=4.38.0
|
|
2
|
+
|
|
3
|
+
[all]
|
|
4
|
+
jc-selenium-helper[axe,config,pytest]
|
|
5
|
+
|
|
6
|
+
[axe]
|
|
7
|
+
axe-selenium-python>=2.1.6
|
|
8
|
+
|
|
9
|
+
[config]
|
|
10
|
+
dynaconf>=3.2
|
|
11
|
+
|
|
12
|
+
[dev]
|
|
13
|
+
ruff
|
|
14
|
+
pre-commit
|
|
15
|
+
bump-my-version
|
|
16
|
+
mkdocs>=1.6.1
|
|
17
|
+
mkdocs-awesome-pages-plugin>=2.10.1
|
|
18
|
+
|
|
19
|
+
[pytest]
|
|
20
|
+
pytest>=8
|
|
21
|
+
pytest-selenium>=4.1.0
|
|
22
|
+
|
|
23
|
+
[test]
|
|
24
|
+
jc-selenium-helper[axe,config,pytest]
|
|
25
|
+
nox
|
|
26
|
+
pytest-cov
|
|
27
|
+
pytest-xdist
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jc_selenium_helper
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# tests/test_axe.py
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
pytest.importorskip("axe_selenium_python")
|
|
5
|
+
|
|
6
|
+
from jc_selenium_helper.axe import assert_no_violations, run_axe # noqa: E402
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_run_axe_returns_results(driver, fixture_url):
|
|
10
|
+
driver.get(fixture_url("basic.html"))
|
|
11
|
+
results = run_axe(driver)
|
|
12
|
+
assert "violations" in results
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_assert_no_violations_raises_on_violation(driver, fixture_url):
|
|
16
|
+
# a11y_bad.html has an <img> with no alt attribute -> at least one violation expected
|
|
17
|
+
driver.get(fixture_url("a11y_bad.html"))
|
|
18
|
+
with pytest.raises(AssertionError):
|
|
19
|
+
assert_no_violations(driver)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# tests/test_browser_actions.py
|
|
2
|
+
def test_type_text(browser, fixture_url):
|
|
3
|
+
browser.open(fixture_url("basic.html"))
|
|
4
|
+
browser.type_text("//input[@id='text-input']", "hello")
|
|
5
|
+
assert browser.find("//input[@id='text-input']").get_attribute("value") == "hello"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_wait_and_click(browser, fixture_url):
|
|
9
|
+
browser.open(fixture_url("basic.html"))
|
|
10
|
+
browser.wait_and_click("//button[@id='clickable']")
|
|
11
|
+
assert browser.find("//button[@id='clickable']").text == "clicked"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_double_click(browser, fixture_url):
|
|
15
|
+
browser.open(fixture_url("basic.html"))
|
|
16
|
+
browser.double_click("//button[@id='dbl']")
|
|
17
|
+
assert browser.find("//button[@id='dbl']").text == "double"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_hover_does_not_raise(browser, fixture_url):
|
|
21
|
+
browser.open(fixture_url("basic.html"))
|
|
22
|
+
browser.hover("//button[@id='clickable']")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_move_to_does_not_raise(browser, fixture_url):
|
|
26
|
+
browser.open(fixture_url("basic.html"))
|
|
27
|
+
browser.move_to("//button[@id='clickable']")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_click_in_new_tab_returns_to_main(browser, fixture_url):
|
|
31
|
+
browser.open(fixture_url("basic.html"))
|
|
32
|
+
browser.click_in_new_tab("//a[@id='newtab']", "//p[@id='target']")
|
|
33
|
+
assert len(browser.driver.window_handles) == 1
|
|
34
|
+
assert browser.find("//h1[@id='title']").text == "Hello"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# tests/test_browser_assertions.py
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_assert_checkbox_checked_passes(browser, fixture_url):
|
|
6
|
+
browser.open(fixture_url("basic.html"))
|
|
7
|
+
browser.assert_checkbox_checked("//input[@id='checked-box']")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_assert_checkbox_checked_fails(browser, fixture_url):
|
|
11
|
+
browser.open(fixture_url("basic.html"))
|
|
12
|
+
with pytest.raises(AssertionError):
|
|
13
|
+
browser.assert_checkbox_checked("//input[@id='unchecked-box']")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_assert_checkbox_unchecked_passes(browser, fixture_url):
|
|
17
|
+
browser.open(fixture_url("basic.html"))
|
|
18
|
+
browser.assert_checkbox_unchecked("//input[@id='unchecked-box']")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_assert_selected_option_passes(browser, fixture_url):
|
|
22
|
+
browser.open(fixture_url("basic.html"))
|
|
23
|
+
browser.assert_selected_option("//select[@id='picker']", "two")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_assert_selected_option_fails(browser, fixture_url):
|
|
27
|
+
browser.open(fixture_url("basic.html"))
|
|
28
|
+
with pytest.raises(AssertionError):
|
|
29
|
+
browser.assert_selected_option("//select[@id='picker']", "one")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_assert_present_fails_when_missing(browser, fixture_url):
|
|
33
|
+
browser.open(fixture_url("basic.html"))
|
|
34
|
+
with pytest.raises(AssertionError):
|
|
35
|
+
browser.assert_present("//div[@id='nope']")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# tests/test_browser_finders.py
|
|
2
|
+
from selenium.webdriver.common.by import By
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_find_returns_element(browser, fixture_url):
|
|
6
|
+
browser.open(fixture_url("basic.html"))
|
|
7
|
+
assert browser.find("//h1[@id='title']").text == "Hello"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_find_with_css(browser, fixture_url):
|
|
11
|
+
browser.open(fixture_url("basic.html"))
|
|
12
|
+
assert browser.find("#title", by=By.CSS_SELECTOR).text == "Hello"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_find_all_returns_list(browser, fixture_url):
|
|
16
|
+
browser.open(fixture_url("basic.html"))
|
|
17
|
+
items = browser.find_all("//li[@class='item']")
|
|
18
|
+
assert [e.text for e in items] == ["a", "b", "c"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_exists_true_and_false(browser, fixture_url):
|
|
22
|
+
browser.open(fixture_url("basic.html"))
|
|
23
|
+
assert browser.exists("//h1[@id='title']") is True
|
|
24
|
+
assert browser.exists("//h1[@id='missing']") is False
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# tests/test_browser_frame.py
|
|
2
|
+
def test_fill_in_frame(browser, fixture_url):
|
|
3
|
+
browser.open(fixture_url("frame.html"))
|
|
4
|
+
browser.fill_in_frame(
|
|
5
|
+
"//iframe[@id='editor']",
|
|
6
|
+
"//input[@id='inner-input']",
|
|
7
|
+
"typed",
|
|
8
|
+
submit=False,
|
|
9
|
+
)
|
|
10
|
+
browser.driver.switch_to.frame(browser.find("//iframe[@id='editor']"))
|
|
11
|
+
assert browser.find("//input[@id='inner-input']").get_attribute("value") == "typed"
|
|
12
|
+
browser.driver.switch_to.default_content()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# tests/test_browser_waits.py
|
|
2
|
+
import pytest
|
|
3
|
+
from selenium.common.exceptions import TimeoutException
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_wait_present_returns_element(browser, fixture_url):
|
|
7
|
+
browser.open(fixture_url("basic.html"))
|
|
8
|
+
assert browser.wait_present("//span[@id='late']").text == "here"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_wait_present_times_out(browser, fixture_url):
|
|
12
|
+
browser.open(fixture_url("basic.html"))
|
|
13
|
+
with pytest.raises(TimeoutException):
|
|
14
|
+
browser.wait_present("//span[@id='never']", timeout=1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_wait_clickable_returns_element(browser, fixture_url):
|
|
18
|
+
browser.open(fixture_url("basic.html"))
|
|
19
|
+
assert browser.wait_clickable("//button[@id='btn']").tag_name == "button"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_wait_document_ready(browser, fixture_url):
|
|
23
|
+
browser.open(fixture_url("basic.html"))
|
|
24
|
+
browser.wait_document_ready() # should not raise
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_wait_page_loaded_on_present_element(browser, fixture_url):
|
|
28
|
+
browser.open(fixture_url("basic.html"))
|
|
29
|
+
browser.wait_page_loaded("//h1[@id='title']", retries=1, interval=1)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from jc_selenium_helper.colors import rgb_to_hex
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.mark.parametrize(
|
|
7
|
+
"value, expected",
|
|
8
|
+
[
|
|
9
|
+
("rgb(255, 0, 0)", "#ff0000"),
|
|
10
|
+
("rgb(0, 128, 0)", "#008000"),
|
|
11
|
+
("rgba(16, 32, 48, 0.5)", "#102030"),
|
|
12
|
+
("rgb(0,0,0)", "#000000"),
|
|
13
|
+
],
|
|
14
|
+
)
|
|
15
|
+
def test_rgb_to_hex(value, expected):
|
|
16
|
+
assert rgb_to_hex(value) == expected
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
pytest.importorskip("dynaconf")
|
|
4
|
+
|
|
5
|
+
from jc_selenium_helper.config import get_settings # noqa: E402
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_get_settings_reads_relative_file(tmp_path):
|
|
9
|
+
(tmp_path / "settings.yaml").write_text("default:\n greeting: hi\n")
|
|
10
|
+
anchor = tmp_path / "conftest.py" # any file whose parent holds the settings
|
|
11
|
+
anchor.write_text("")
|
|
12
|
+
settings = get_settings(anchor, "settings.yaml")
|
|
13
|
+
assert settings.greeting == "hi"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_get_settings_missing_file_raises(tmp_path):
|
|
17
|
+
anchor = tmp_path / "conftest.py"
|
|
18
|
+
anchor.write_text("")
|
|
19
|
+
with pytest.raises(AssertionError):
|
|
20
|
+
get_settings(anchor, "nope.yaml")
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# tests/test_legacy.py
|
|
2
|
+
import pytest
|
|
3
|
+
|
|
4
|
+
from jc_selenium_helper.legacy import Browser as LegacyBrowser
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_legacy_eingabe_delegates_and_warns(driver, fixture_url):
|
|
8
|
+
legacy = LegacyBrowser(driver, default_timeout=10, poll_pause=0.2)
|
|
9
|
+
legacy.open(fixture_url("basic.html"))
|
|
10
|
+
with pytest.warns(DeprecationWarning):
|
|
11
|
+
legacy.eingabe("//input[@id='text-input']", "legacy")
|
|
12
|
+
assert legacy.find("//input[@id='text-input']").get_attribute("value") == "legacy"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_legacy_check_exists_by_xpath(driver, fixture_url):
|
|
16
|
+
legacy = LegacyBrowser(driver, default_timeout=10, poll_pause=0.2)
|
|
17
|
+
legacy.open(fixture_url("basic.html"))
|
|
18
|
+
with pytest.warns(DeprecationWarning):
|
|
19
|
+
assert legacy.check_exists_by_xpath("//h1[@id='title']") is True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_legacy_switch_rgb_static():
|
|
23
|
+
with pytest.warns(DeprecationWarning):
|
|
24
|
+
assert LegacyBrowser.switch_rgb("rgb(255, 0, 0)") == "#ff0000"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
pytest_plugins = ["pytester"]
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_jc_chrome_options_fixture_available(pytester):
|
|
5
|
+
pytester.makepyfile(
|
|
6
|
+
"""
|
|
7
|
+
def test_options(jc_chrome_options):
|
|
8
|
+
args = jc_chrome_options.arguments
|
|
9
|
+
assert "--headless=new" in args
|
|
10
|
+
"""
|
|
11
|
+
)
|
|
12
|
+
result = pytester.runpytest()
|
|
13
|
+
result.assert_outcomes(passed=1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_chrome_options_feeds_pytest_selenium(pytester):
|
|
17
|
+
pytester.makepyfile(
|
|
18
|
+
"""
|
|
19
|
+
def test_same_object(chrome_options, jc_chrome_options):
|
|
20
|
+
assert chrome_options is jc_chrome_options
|
|
21
|
+
"""
|
|
22
|
+
)
|
|
23
|
+
result = pytester.runpytest()
|
|
24
|
+
result.assert_outcomes(passed=1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_chrome_options_reflects_jc_chrome_options_override(pytester):
|
|
28
|
+
pytester.makeconftest(
|
|
29
|
+
"""
|
|
30
|
+
import pytest
|
|
31
|
+
from selenium.webdriver.chrome.options import Options
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.fixture
|
|
35
|
+
def jc_chrome_options():
|
|
36
|
+
options = Options()
|
|
37
|
+
options.add_argument("--window-size=1920,1080")
|
|
38
|
+
return options
|
|
39
|
+
"""
|
|
40
|
+
)
|
|
41
|
+
pytester.makepyfile(
|
|
42
|
+
"""
|
|
43
|
+
def test_override_flows_through(chrome_options):
|
|
44
|
+
assert "--window-size=1920,1080" in chrome_options.arguments
|
|
45
|
+
"""
|
|
46
|
+
)
|
|
47
|
+
result = pytester.runpytest()
|
|
48
|
+
result.assert_outcomes(passed=1)
|