Skip to content

Commit 45df195

Browse files
test: 100% line coverage + CI coverage gate (#238)
* test: reach 100% line coverage and enforce a CI coverage gate Raise unit coverage from 86% to 100% line coverage with meaningful tests and wire a hard gate into CI so it cannot regress. Tests added: - test_snapshot_units.py (new): targeted unit tests for snapshot.py error and fallback paths the e2e suite doesn't reach — log() shipping failure, _wait_for_ready script-timeout adjust/restore failures, CORS-iframe origin error skip, get_responsive_widths non-list + request failure, CDP resize fallback to set_window_size, _responsive_sleep invalid/unset time, and capture_responsive_dom invalid minHeight handling. - test_init.py (new): percy_screenshot dispatch — unsupported driver, RemoteConnection -> automate, AppiumConnection delegation when the appium package is present, helpful error when it is not, and unknown connection returning None. - robot_library: parse-helper edge cases (_parse_widths/_parse_csv/ _parse_json unsupported types, full _parse_padding matrix), _get_driver SeleniumLibrary-missing error, percy_screenshot keyword (basic + ignore/consider region elements), and a reload-based test of the no-robotframework stub (graceful ImportError). - driver_metadata: command_executor._url -> client_config.remote_server_addr fallback for newer Selenium clients. Instrumentation: - Add .coveragerc (source=percy, fail_under=100, show_missing). - Add coverage to development.txt; new Makefile `coverage` target runs every test module (snapshot under `percy exec --testing`), combines the parallel data, and enforces the threshold. - test.yml runs `make coverage` across the Python matrix. - Add the two new modules to `make test` as well. - __init__: mark two genuinely-unreachable optional-import fallbacks with `# pragma: no cover` (robot_library never raises ImportError; percy.snapshot is the core module and always ships). - gitignore coverage/npm artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test: cover optional-import fallbacks with real tests, drop pragmas Replace the two no-cover pragmas in percy/__init__ with reload-based unit tests that actually execute the fallback lines: - block percy.robot_library so the robot import fails and the except: pass branch runs (package still loads) - inject a percy.snapshot stand-in lacking percy_snapshot so the import fails and the ModuleNotFoundError fallback is defined and raised. Coverage stays at 100% line with no skipped lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 540d01e commit 45df195

9 files changed

Lines changed: 365 additions & 3 deletions

File tree

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[run]
2+
source = percy
3+
parallel = True
4+
5+
[report]
6+
show_missing = True
7+
fail_under = 100

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,4 @@ jobs:
6161
yarn remove @percy/cli && yarn link `echo $PERCY_PACKAGES`
6262
npx percy --version
6363
64-
- run: make test
64+
- run: make coverage

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@ build
66
**/__pycache__
77
node_modules*
88
.DS_Store
9+
.coverage
10+
.coverage.*
11+
htmlcov
12+
package-lock.json
13+
output_file.json

Makefile

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ $(VENV)/$(MARKER): $(VENVDEPS) | $(VENV)
1212
$(VENV)/pip install $(foreach path,$(REQUIREMENTS),-r $(path))
1313
touch $(VENV)/$(MARKER)
1414

15-
.PHONY: venv lint test clean build release
15+
.PHONY: venv lint test coverage clean build release
1616

1717
venv: $(VENV)/$(MARKER)
1818

@@ -24,6 +24,19 @@ test: venv
2424
$(VENV)/python -m unittest tests.test_cache
2525
$(VENV)/python -m unittest tests.test_driver_metadata
2626
$(VENV)/python -m unittest tests.test_robot_library
27+
$(VENV)/python -m unittest tests.test_snapshot_units
28+
$(VENV)/python -m unittest tests.test_init
29+
30+
coverage: venv
31+
$(VENV)/coverage erase
32+
npx percy exec --testing -- $(VENV)/coverage run -p --source percy -m unittest tests.test_snapshot
33+
$(VENV)/coverage run -p --source percy -m unittest tests.test_cache
34+
$(VENV)/coverage run -p --source percy -m unittest tests.test_driver_metadata
35+
$(VENV)/coverage run -p --source percy -m unittest tests.test_robot_library
36+
$(VENV)/coverage run -p --source percy -m unittest tests.test_snapshot_units
37+
$(VENV)/coverage run -p --source percy -m unittest tests.test_init
38+
$(VENV)/coverage combine
39+
$(VENV)/coverage report
2740

2841
clean:
2942
rm -rf $$(cat .gitignore)

development.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ pylint==2.*
33
twine
44
robotframework>=5.0
55
robotframework-seleniumlibrary>=5.0
6+
coverage==7.*

tests/test_driver_metadata.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pylint: disable=[abstract-class-instantiated, arguments-differ]
22
import unittest
3-
from unittest.mock import patch
3+
from unittest.mock import patch, Mock
44
from selenium.webdriver.remote.webdriver import WebDriver
55

66
from percy.driver_metadata import DriverMetaData
@@ -39,6 +39,17 @@ def test_command_executor_url(self):
3939
url = 'https://example-hub:4444/wd/hub'
4040
self.assertEqual(self.metadata.command_executor_url, url)
4141

42+
@patch('percy.cache.Cache.CACHE', {})
43+
def test_command_executor_url_falls_back_to_remote_server_addr(self):
44+
# Newer Selenium clients drop command_executor._url; fall back to
45+
# client_config.remote_server_addr instead of failing.
46+
command_executor = Mock(spec=['client_config'])
47+
command_executor.client_config.remote_server_addr = 'https://fallback-hub:4444/wd/hub'
48+
self.mock_webdriver.command_executor = command_executor
49+
self.assertEqual(
50+
self.metadata.command_executor_url, 'https://fallback-hub:4444/wd/hub'
51+
)
52+
4253
@patch('percy.cache.Cache.CACHE', {})
4354
def test_capabilities(self):
4455
capabilities = {

tests/test_init.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# pylint: disable=too-few-public-methods
2+
"""Tests for the percy package entrypoint, focused on percy_screenshot
3+
dispatch across connection types."""
4+
import importlib
5+
import sys
6+
import types
7+
import unittest
8+
from unittest.mock import MagicMock, patch
9+
10+
import percy
11+
from percy.exception import UnsupportedWebDriverException
12+
13+
14+
class _WebDriver: # class name must be exactly "WebDriver" for the dispatch check
15+
def __init__(self, command_executor):
16+
self.command_executor = command_executor
17+
18+
19+
class _RemoteConnection:
20+
pass
21+
22+
23+
class _AppiumConnection:
24+
pass
25+
26+
27+
class _OtherConnection:
28+
pass
29+
30+
31+
# the dispatch keys on the class name string, so name the wrapper classes to match
32+
_WebDriver.__name__ = "WebDriver"
33+
_RemoteConnection.__name__ = "RemoteConnection"
34+
_AppiumConnection.__name__ = "AppiumConnection"
35+
36+
37+
class TestPercyScreenshotDispatch(unittest.TestCase):
38+
def test_rejects_unsupported_driver(self):
39+
with self.assertRaises(UnsupportedWebDriverException):
40+
percy.percy_screenshot(MagicMock(), "name")
41+
42+
@patch("percy.percy_automate_screenshot", return_value={"link": "x"})
43+
def test_remote_connection_uses_automate_screenshot(self, mock_automate):
44+
driver = _WebDriver(_RemoteConnection())
45+
result = percy.percy_screenshot(driver, "name")
46+
self.assertEqual(result, {"link": "x"})
47+
mock_automate.assert_called_once()
48+
49+
def test_appium_connection_delegates_when_installed(self):
50+
fake_module = types.ModuleType("percy.screenshot")
51+
fake_module.percy_screenshot = MagicMock(return_value="delegated")
52+
with patch.dict(sys.modules, {"percy.screenshot": fake_module}):
53+
driver = _WebDriver(_AppiumConnection())
54+
result = percy.percy_screenshot(driver, "name")
55+
self.assertEqual(result, "delegated")
56+
fake_module.percy_screenshot.assert_called_once()
57+
58+
def test_appium_connection_raises_when_not_installed(self):
59+
# percy.screenshot is not shipped here; the import must fail and surface
60+
# a helpful "install percy-appium" error.
61+
with patch.dict(sys.modules, {"percy.screenshot": None}):
62+
driver = _WebDriver(_AppiumConnection())
63+
with self.assertRaises(ModuleNotFoundError) as cm:
64+
percy.percy_screenshot(driver, "name")
65+
self.assertIn("percy-appium", str(cm.exception))
66+
67+
def test_unknown_connection_returns_none(self):
68+
driver = _WebDriver(_OtherConnection())
69+
self.assertIsNone(percy.percy_screenshot(driver, "name"))
70+
71+
72+
class TestOptionalImportFallbacks(unittest.TestCase):
73+
"""Exercise the package's defensive optional-import fallbacks by reloading
74+
percy/__init__ with the relevant imports forced to fail."""
75+
76+
def test_robot_library_import_failure_is_swallowed(self):
77+
# Block percy.robot_library so `from percy.robot_library import
78+
# PercyLibrary` raises ImportError and the `except ImportError: pass`
79+
# branch runs.
80+
try:
81+
with patch.dict(sys.modules, {"percy.robot_library": None}):
82+
importlib.reload(percy)
83+
# package still loads past the swallowed import failure
84+
self.assertTrue(callable(percy.percy_screenshot))
85+
finally:
86+
importlib.reload(percy)
87+
88+
def test_percy_snapshot_fallback_when_snapshot_import_fails(self):
89+
# A stand-in percy.snapshot that provides percy_automate_screenshot (so
90+
# the top-level import on line 2 succeeds) but NOT percy_snapshot, so the
91+
# `from percy.snapshot import percy_snapshot` import fails and the
92+
# ModuleNotFoundError fallback is defined and executed.
93+
fake = types.ModuleType("percy.snapshot")
94+
fake.percy_automate_screenshot = lambda *a, **k: None
95+
try:
96+
with patch.dict(sys.modules, {"percy.snapshot": fake}):
97+
importlib.reload(percy)
98+
with self.assertRaises(ModuleNotFoundError) as cm:
99+
percy.percy_snapshot(driver=MagicMock())
100+
self.assertIn("percy-selenium", str(cm.exception))
101+
finally:
102+
importlib.reload(percy)
103+
104+
105+
if __name__ == "__main__":
106+
unittest.main()

tests/test_robot_library.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
"""Tests for Robot Framework library integration."""
2+
import importlib
3+
import sys
24
import unittest
35
from unittest.mock import MagicMock, patch
46

@@ -7,6 +9,7 @@
79
_parse_bool,
810
_parse_csv,
911
_parse_json,
12+
_parse_padding,
1013
_parse_widths,
1114
)
1215

@@ -65,6 +68,44 @@ def test_parse_json_dict(self):
6568
def test_parse_json_none(self):
6669
self.assertIsNone(_parse_json(None))
6770

71+
def test_parse_widths_unsupported_type(self):
72+
self.assertIsNone(_parse_widths(123))
73+
74+
def test_parse_csv_list(self):
75+
self.assertEqual(_parse_csv(["a", "b"]), ["a", "b"])
76+
77+
def test_parse_csv_unsupported_type(self):
78+
self.assertIsNone(_parse_csv(123))
79+
80+
def test_parse_json_unsupported_type(self):
81+
self.assertIsNone(_parse_json(123))
82+
83+
84+
class TestParsePadding(unittest.TestCase):
85+
def test_parse_padding_none(self):
86+
self.assertIsNone(_parse_padding(None))
87+
88+
def test_parse_padding_json_object_string(self):
89+
self.assertEqual(
90+
_parse_padding('{"top": 1, "bottom": 2, "left": 3, "right": 4}'),
91+
{"top": 1, "bottom": 2, "left": 3, "right": 4},
92+
)
93+
94+
def test_parse_padding_numeric_string(self):
95+
self.assertEqual(_parse_padding("10"), {"top": 10, "bottom": 10, "left": 10, "right": 10})
96+
97+
def test_parse_padding_invalid_string(self):
98+
self.assertIsNone(_parse_padding("not-json-not-int"))
99+
100+
def test_parse_padding_int(self):
101+
self.assertEqual(_parse_padding(5), {"top": 5, "bottom": 5, "left": 5, "right": 5})
102+
103+
def test_parse_padding_dict(self):
104+
self.assertEqual(_parse_padding({"top": 1}), {"top": 1})
105+
106+
def test_parse_padding_unsupported_type(self):
107+
self.assertIsNone(_parse_padding(["unexpected"]))
108+
68109

69110
class TestPercyLibraryKeywords(unittest.TestCase):
70111
@patch("percy.robot_library.percy_snapshot")
@@ -122,3 +163,62 @@ def test_create_percy_region_keyword(self, mock_create):
122163

123164
mock_create.assert_called_once()
124165
self.assertEqual(result["algorithm"], "ignore")
166+
167+
@patch("percy.robot_library.BuiltIn")
168+
def test_get_driver_requires_selenium_library(self, mock_builtin):
169+
mock_builtin.return_value.get_library_instance.side_effect = RuntimeError("not imported")
170+
lib = PercyLibrary()
171+
with self.assertRaises(RuntimeError) as cm:
172+
lib._get_driver() # pylint: disable=protected-access
173+
self.assertIn("SeleniumLibrary", str(cm.exception))
174+
175+
@patch("percy.robot_library.percy_automate_screenshot")
176+
@patch("percy.robot_library.BuiltIn")
177+
def test_percy_screenshot_keyword_basic(self, mock_builtin, mock_screenshot):
178+
mock_driver = MagicMock()
179+
mock_builtin.return_value.get_library_instance.return_value.driver = mock_driver
180+
lib = PercyLibrary()
181+
lib.percy_screenshot_keyword("Homepage")
182+
mock_screenshot.assert_called_once()
183+
args, kwargs = mock_screenshot.call_args
184+
self.assertIs(args[0], mock_driver)
185+
self.assertEqual(args[1], "Homepage")
186+
self.assertEqual(kwargs["options"], {})
187+
188+
@patch("percy.robot_library.percy_automate_screenshot")
189+
@patch("percy.robot_library.BuiltIn")
190+
def test_percy_screenshot_keyword_with_region_elements(self, mock_builtin, mock_screenshot):
191+
mock_driver = MagicMock()
192+
selib = mock_builtin.return_value.get_library_instance.return_value
193+
selib.driver = mock_driver
194+
selib.find_element.side_effect = lambda loc: f"el:{loc}"
195+
lib = PercyLibrary()
196+
lib.percy_screenshot_keyword(
197+
"Page",
198+
ignore_region_selenium_elements="id:banner, css:.ad",
199+
consider_region_selenium_elements="id:main",
200+
)
201+
options = mock_screenshot.call_args[1]["options"]
202+
self.assertEqual(options["ignore_region_selenium_elements"], ["el:id:banner", "el:css:.ad"])
203+
self.assertEqual(options["consider_region_selenium_elements"], ["el:id:main"])
204+
205+
206+
class TestRobotNotInstalled(unittest.TestCase):
207+
"""When robotframework is absent, PercyLibrary degrades to a stub that
208+
raises a clear, actionable error on use."""
209+
210+
def test_stub_library_raises_without_robotframework(self):
211+
from percy import robot_library as rl # pylint: disable=import-outside-toplevel
212+
blocked = {name: None for name in (
213+
'robot', 'robot.api', 'robot.api.deco',
214+
'robot.libraries', 'robot.libraries.BuiltIn', 'robot.version',
215+
)}
216+
with patch.dict(sys.modules, blocked):
217+
importlib.reload(rl)
218+
try:
219+
self.assertFalse(rl.ROBOT_AVAILABLE)
220+
with self.assertRaises(ImportError) as cm:
221+
rl.PercyLibrary()
222+
self.assertIn("robotframework is not installed", str(cm.exception))
223+
finally:
224+
importlib.reload(rl)

0 commit comments

Comments
 (0)