diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1195664 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,7 @@ +[run] +source = percy +parallel = True + +[report] +show_missing = True +fail_under = 100 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b67b6ef..f55daa5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,4 +61,4 @@ jobs: yarn remove @percy/cli && yarn link `echo $PERCY_PACKAGES` npx percy --version - - run: make test + - run: make coverage diff --git a/.gitignore b/.gitignore index 5c03e29..f2f18c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ build **/__pycache__ node_modules* .DS_Store +.coverage +.coverage.* +htmlcov +package-lock.json +output_file.json diff --git a/Makefile b/Makefile index 8231892..f102e77 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ $(VENV)/$(MARKER): $(VENVDEPS) | $(VENV) $(VENV)/pip install $(foreach path,$(REQUIREMENTS),-r $(path)) touch $(VENV)/$(MARKER) -.PHONY: venv lint test clean build release +.PHONY: venv lint test coverage clean build release venv: $(VENV)/$(MARKER) @@ -24,6 +24,19 @@ test: venv $(VENV)/python -m unittest tests.test_cache $(VENV)/python -m unittest tests.test_driver_metadata $(VENV)/python -m unittest tests.test_robot_library + $(VENV)/python -m unittest tests.test_snapshot_units + $(VENV)/python -m unittest tests.test_init + +coverage: venv + $(VENV)/coverage erase + npx percy exec --testing -- $(VENV)/coverage run -p --source percy -m unittest tests.test_snapshot + $(VENV)/coverage run -p --source percy -m unittest tests.test_cache + $(VENV)/coverage run -p --source percy -m unittest tests.test_driver_metadata + $(VENV)/coverage run -p --source percy -m unittest tests.test_robot_library + $(VENV)/coverage run -p --source percy -m unittest tests.test_snapshot_units + $(VENV)/coverage run -p --source percy -m unittest tests.test_init + $(VENV)/coverage combine + $(VENV)/coverage report clean: rm -rf $$(cat .gitignore) diff --git a/development.txt b/development.txt index 33e95bf..b5867c0 100644 --- a/development.txt +++ b/development.txt @@ -3,3 +3,4 @@ pylint==2.* twine robotframework>=5.0 robotframework-seleniumlibrary>=5.0 +coverage==7.* diff --git a/tests/test_driver_metadata.py b/tests/test_driver_metadata.py index dd177d5..7895394 100644 --- a/tests/test_driver_metadata.py +++ b/tests/test_driver_metadata.py @@ -1,6 +1,6 @@ # pylint: disable=[abstract-class-instantiated, arguments-differ] import unittest -from unittest.mock import patch +from unittest.mock import patch, Mock from selenium.webdriver.remote.webdriver import WebDriver from percy.driver_metadata import DriverMetaData @@ -39,6 +39,17 @@ def test_command_executor_url(self): url = 'https://example-hub:4444/wd/hub' self.assertEqual(self.metadata.command_executor_url, url) + @patch('percy.cache.Cache.CACHE', {}) + def test_command_executor_url_falls_back_to_remote_server_addr(self): + # Newer Selenium clients drop command_executor._url; fall back to + # client_config.remote_server_addr instead of failing. + command_executor = Mock(spec=['client_config']) + command_executor.client_config.remote_server_addr = 'https://fallback-hub:4444/wd/hub' + self.mock_webdriver.command_executor = command_executor + self.assertEqual( + self.metadata.command_executor_url, 'https://fallback-hub:4444/wd/hub' + ) + @patch('percy.cache.Cache.CACHE', {}) def test_capabilities(self): capabilities = { diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..4c5b3c1 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,106 @@ +# pylint: disable=too-few-public-methods +"""Tests for the percy package entrypoint, focused on percy_screenshot +dispatch across connection types.""" +import importlib +import sys +import types +import unittest +from unittest.mock import MagicMock, patch + +import percy +from percy.exception import UnsupportedWebDriverException + + +class _WebDriver: # class name must be exactly "WebDriver" for the dispatch check + def __init__(self, command_executor): + self.command_executor = command_executor + + +class _RemoteConnection: + pass + + +class _AppiumConnection: + pass + + +class _OtherConnection: + pass + + +# the dispatch keys on the class name string, so name the wrapper classes to match +_WebDriver.__name__ = "WebDriver" +_RemoteConnection.__name__ = "RemoteConnection" +_AppiumConnection.__name__ = "AppiumConnection" + + +class TestPercyScreenshotDispatch(unittest.TestCase): + def test_rejects_unsupported_driver(self): + with self.assertRaises(UnsupportedWebDriverException): + percy.percy_screenshot(MagicMock(), "name") + + @patch("percy.percy_automate_screenshot", return_value={"link": "x"}) + def test_remote_connection_uses_automate_screenshot(self, mock_automate): + driver = _WebDriver(_RemoteConnection()) + result = percy.percy_screenshot(driver, "name") + self.assertEqual(result, {"link": "x"}) + mock_automate.assert_called_once() + + def test_appium_connection_delegates_when_installed(self): + fake_module = types.ModuleType("percy.screenshot") + fake_module.percy_screenshot = MagicMock(return_value="delegated") + with patch.dict(sys.modules, {"percy.screenshot": fake_module}): + driver = _WebDriver(_AppiumConnection()) + result = percy.percy_screenshot(driver, "name") + self.assertEqual(result, "delegated") + fake_module.percy_screenshot.assert_called_once() + + def test_appium_connection_raises_when_not_installed(self): + # percy.screenshot is not shipped here; the import must fail and surface + # a helpful "install percy-appium" error. + with patch.dict(sys.modules, {"percy.screenshot": None}): + driver = _WebDriver(_AppiumConnection()) + with self.assertRaises(ModuleNotFoundError) as cm: + percy.percy_screenshot(driver, "name") + self.assertIn("percy-appium", str(cm.exception)) + + def test_unknown_connection_returns_none(self): + driver = _WebDriver(_OtherConnection()) + self.assertIsNone(percy.percy_screenshot(driver, "name")) + + +class TestOptionalImportFallbacks(unittest.TestCase): + """Exercise the package's defensive optional-import fallbacks by reloading + percy/__init__ with the relevant imports forced to fail.""" + + def test_robot_library_import_failure_is_swallowed(self): + # Block percy.robot_library so `from percy.robot_library import + # PercyLibrary` raises ImportError and the `except ImportError: pass` + # branch runs. + try: + with patch.dict(sys.modules, {"percy.robot_library": None}): + importlib.reload(percy) + # package still loads past the swallowed import failure + self.assertTrue(callable(percy.percy_screenshot)) + finally: + importlib.reload(percy) + + def test_percy_snapshot_fallback_when_snapshot_import_fails(self): + # A stand-in percy.snapshot that provides percy_automate_screenshot (so + # the top-level import on line 2 succeeds) but NOT percy_snapshot, so the + # `from percy.snapshot import percy_snapshot` import fails and the + # ModuleNotFoundError fallback is defined and executed. + fake = types.ModuleType("percy.snapshot") + fake.percy_automate_screenshot = lambda *a, **k: None + try: + with patch.dict(sys.modules, {"percy.snapshot": fake}): + importlib.reload(percy) + with self.assertRaises(ModuleNotFoundError) as cm: + percy.percy_snapshot(driver=MagicMock()) + self.assertIn("percy-selenium", str(cm.exception)) + finally: + importlib.reload(percy) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_robot_library.py b/tests/test_robot_library.py index 2f676ec..fc6a640 100644 --- a/tests/test_robot_library.py +++ b/tests/test_robot_library.py @@ -1,4 +1,6 @@ """Tests for Robot Framework library integration.""" +import importlib +import sys import unittest from unittest.mock import MagicMock, patch @@ -7,6 +9,7 @@ _parse_bool, _parse_csv, _parse_json, + _parse_padding, _parse_widths, ) @@ -65,6 +68,44 @@ def test_parse_json_dict(self): def test_parse_json_none(self): self.assertIsNone(_parse_json(None)) + def test_parse_widths_unsupported_type(self): + self.assertIsNone(_parse_widths(123)) + + def test_parse_csv_list(self): + self.assertEqual(_parse_csv(["a", "b"]), ["a", "b"]) + + def test_parse_csv_unsupported_type(self): + self.assertIsNone(_parse_csv(123)) + + def test_parse_json_unsupported_type(self): + self.assertIsNone(_parse_json(123)) + + +class TestParsePadding(unittest.TestCase): + def test_parse_padding_none(self): + self.assertIsNone(_parse_padding(None)) + + def test_parse_padding_json_object_string(self): + self.assertEqual( + _parse_padding('{"top": 1, "bottom": 2, "left": 3, "right": 4}'), + {"top": 1, "bottom": 2, "left": 3, "right": 4}, + ) + + def test_parse_padding_numeric_string(self): + self.assertEqual(_parse_padding("10"), {"top": 10, "bottom": 10, "left": 10, "right": 10}) + + def test_parse_padding_invalid_string(self): + self.assertIsNone(_parse_padding("not-json-not-int")) + + def test_parse_padding_int(self): + self.assertEqual(_parse_padding(5), {"top": 5, "bottom": 5, "left": 5, "right": 5}) + + def test_parse_padding_dict(self): + self.assertEqual(_parse_padding({"top": 1}), {"top": 1}) + + def test_parse_padding_unsupported_type(self): + self.assertIsNone(_parse_padding(["unexpected"])) + class TestPercyLibraryKeywords(unittest.TestCase): @patch("percy.robot_library.percy_snapshot") @@ -122,3 +163,62 @@ def test_create_percy_region_keyword(self, mock_create): mock_create.assert_called_once() self.assertEqual(result["algorithm"], "ignore") + + @patch("percy.robot_library.BuiltIn") + def test_get_driver_requires_selenium_library(self, mock_builtin): + mock_builtin.return_value.get_library_instance.side_effect = RuntimeError("not imported") + lib = PercyLibrary() + with self.assertRaises(RuntimeError) as cm: + lib._get_driver() # pylint: disable=protected-access + self.assertIn("SeleniumLibrary", str(cm.exception)) + + @patch("percy.robot_library.percy_automate_screenshot") + @patch("percy.robot_library.BuiltIn") + def test_percy_screenshot_keyword_basic(self, mock_builtin, mock_screenshot): + mock_driver = MagicMock() + mock_builtin.return_value.get_library_instance.return_value.driver = mock_driver + lib = PercyLibrary() + lib.percy_screenshot_keyword("Homepage") + mock_screenshot.assert_called_once() + args, kwargs = mock_screenshot.call_args + self.assertIs(args[0], mock_driver) + self.assertEqual(args[1], "Homepage") + self.assertEqual(kwargs["options"], {}) + + @patch("percy.robot_library.percy_automate_screenshot") + @patch("percy.robot_library.BuiltIn") + def test_percy_screenshot_keyword_with_region_elements(self, mock_builtin, mock_screenshot): + mock_driver = MagicMock() + selib = mock_builtin.return_value.get_library_instance.return_value + selib.driver = mock_driver + selib.find_element.side_effect = lambda loc: f"el:{loc}" + lib = PercyLibrary() + lib.percy_screenshot_keyword( + "Page", + ignore_region_selenium_elements="id:banner, css:.ad", + consider_region_selenium_elements="id:main", + ) + options = mock_screenshot.call_args[1]["options"] + self.assertEqual(options["ignore_region_selenium_elements"], ["el:id:banner", "el:css:.ad"]) + self.assertEqual(options["consider_region_selenium_elements"], ["el:id:main"]) + + +class TestRobotNotInstalled(unittest.TestCase): + """When robotframework is absent, PercyLibrary degrades to a stub that + raises a clear, actionable error on use.""" + + def test_stub_library_raises_without_robotframework(self): + from percy import robot_library as rl # pylint: disable=import-outside-toplevel + blocked = {name: None for name in ( + 'robot', 'robot.api', 'robot.api.deco', + 'robot.libraries', 'robot.libraries.BuiltIn', 'robot.version', + )} + with patch.dict(sys.modules, blocked): + importlib.reload(rl) + try: + self.assertFalse(rl.ROBOT_AVAILABLE) + with self.assertRaises(ImportError) as cm: + rl.PercyLibrary() + self.assertIn("robotframework is not installed", str(cm.exception)) + finally: + importlib.reload(rl) diff --git a/tests/test_snapshot_units.py b/tests/test_snapshot_units.py new file mode 100644 index 0000000..4c9472c --- /dev/null +++ b/tests/test_snapshot_units.py @@ -0,0 +1,119 @@ +# pylint: disable=protected-access +"""Focused unit tests for snapshot.py helper functions and their error/ +fallback paths. These run as plain unittests (no Percy CLI needed) and +target branches the end-to-end suite in test_snapshot.py does not reach.""" +import os +import unittest +from unittest.mock import patch, MagicMock + +import percy.snapshot as local + + +class TestLog(unittest.TestCase): + @patch('percy.snapshot.PERCY_DEBUG', True) + @patch('percy.snapshot.requests.post', side_effect=Exception('network down')) + def test_log_swallows_post_failure(self, _mock_post): + # Failing to ship the log to the CLI must never raise to the caller. + local.log('hello', 'info') # should not raise + + +class TestWaitForReady(unittest.TestCase): + def _driver(self): + driver = MagicMock() + driver.timeouts.script = 5.0 + driver.execute_async_script.return_value = {'ok': True} + return driver + + def test_set_script_timeout_failure_is_best_effort(self): + driver = self._driver() + driver.set_script_timeout.side_effect = Exception('unsupported') + # timeoutMs > 0 so the timeout-adjust path runs; its failure is swallowed + result = local._wait_for_ready(driver, None, {'readiness': {'timeoutMs': 5000}}) + self.assertEqual(result, {'ok': True}) + + def test_restore_previous_timeout_failure_is_swallowed(self): + driver = self._driver() + # first call (set new timeout) succeeds, restore in finally raises + driver.set_script_timeout.side_effect = [None, Exception('cannot restore')] + result = local._wait_for_ready(driver, None, {'readiness': {'timeoutMs': 5000}}) + self.assertEqual(result, {'ok': True}) + + +class TestGetSerializedDomIframe(unittest.TestCase): + @patch('percy.snapshot._get_origin', side_effect=['https://page', Exception('bad url')]) + def test_iframe_origin_error_is_skipped(self, _mock_origin): + driver = MagicMock() + driver.execute_script.return_value = {'html': ''} + frame = MagicMock() + frame.get_attribute.return_value = 'https://other.example/widget' + driver.find_elements.return_value = [frame] + driver.current_url = 'https://page/index' + + result = local.get_serialized_dom( + driver, {'c': 1}, percy_config={}, percy_dom_script='PERCY_DOM', + ) + # the un-parseable iframe is skipped; serialize result still returns + self.assertEqual(result['cookies'], {'c': 1}) + self.assertNotIn('corsIframes', result) + + +class TestGetResponsiveWidths(unittest.TestCase): + @patch('percy.snapshot.requests.get') + def test_non_list_widths_raises_upgrade_hint(self, mock_get): + response = MagicMock() + response.raise_for_status.return_value = None + response.json.return_value = {'widths': 'not-a-list'} + mock_get.return_value = response + with self.assertRaises(Exception) as cm: + local.get_responsive_widths([375]) + self.assertIn('Update Percy CLI', str(cm.exception)) + + @patch('percy.snapshot.requests.get', side_effect=Exception('connection refused')) + def test_request_failure_raises_upgrade_hint(self, _mock_get): + with self.assertRaises(Exception) as cm: + local.get_responsive_widths([375]) + self.assertIn('Update Percy CLI', str(cm.exception)) + + +class TestChangeWindowDimension(unittest.TestCase): + def test_resize_falls_back_to_set_window_size(self): + driver = MagicMock() + driver.capabilities = {'browserName': 'firefox'} + # first resize attempt raises, fallback set_window_size succeeds + driver.set_window_size.side_effect = [Exception('resize failed'), None] + driver.execute_script.return_value = 1 # resize counter already matches + local.change_window_dimension_and_wait(driver, 800, 600, 1) + self.assertEqual(driver.set_window_size.call_count, 2) + + +class TestResponsiveSleep(unittest.TestCase): + @patch('percy.snapshot.RESPONSIVE_CAPTURE_SLEEP_TIME', 'not-a-number') + def test_invalid_sleep_time_is_ignored(self): + local._responsive_sleep() # int() fails -> swallowed, no raise + + @patch('percy.snapshot.RESPONSIVE_CAPTURE_SLEEP_TIME', None) + def test_unset_sleep_time_returns_early(self): + local._responsive_sleep() + + +class TestCaptureResponsiveDomMinHeight(unittest.TestCase): + def tearDown(self): + if os.path.exists('output_file.json'): + os.remove('output_file.json') + + @patch('percy.snapshot.PERCY_RESPONSIVE_CAPTURE_MIN_HEIGHT', True) + @patch('percy.snapshot.change_window_dimension_and_wait', MagicMock()) + @patch('percy.snapshot._wait_for_ready', MagicMock(return_value=None)) + @patch('percy.snapshot.get_responsive_widths', MagicMock(return_value=[])) + def test_invalid_min_height_falls_back_to_window_height(self): + driver = MagicMock() + driver.get_window_size.return_value = {'width': 1000, 'height': 800} + # minHeight is non-numeric -> int() fails -> logged, window height kept + result = local.capture_responsive_dom( + driver, {}, {}, percy_dom_script='PERCY_DOM', minHeight='abc' + ) + self.assertEqual(result, []) + + +if __name__ == '__main__': + unittest.main()