diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..89812dd --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = percy + +[report] +show_missing = True +fail_under = 100 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e936a3..450a237 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,4 +17,4 @@ jobs: path: ~/.cache/pip key: v1/${{ runner.os }}/pypi-${{matrix.python}}/${{ hashFiles('{requirements,development}.txt') }} restore-keys: v1/${{ runner.os }}/pypi-${matrix.python}/ - - run: make test + - run: make coverage diff --git a/.python-version b/.python-version index 7d4ef04..c8cfe39 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.10.3 +3.10 diff --git a/Makefile b/Makefile index 4435dcf..1f6e674 100644 --- a/Makefile +++ b/Makefile @@ -31,4 +31,5 @@ release: build $(VENV)/twine upload dist/* --username __token__ --password ${PYPI_TOKEN} coverage: venv - $(VENV)/coverage run --source percy -m unittest discover && $(VENV)/coverage html + $(VENV)/coverage run -m unittest discover + $(VENV)/coverage report diff --git a/development.txt b/development.txt index b5f2e73..b5ca3b9 100644 --- a/development.txt +++ b/development.txt @@ -1,3 +1,4 @@ httpretty==1.1.* pylint==2.* twine +coverage==7.* diff --git a/tests/test_android_metadata.py b/tests/test_android_metadata.py index f71f7be..17a8a2f 100644 --- a/tests/test_android_metadata.py +++ b/tests/test_android_metadata.py @@ -42,6 +42,21 @@ def test_get_system_bars(self): mock_get_system_bars.assert_called_once() + @patch.object(Metadata, 'session_id', PropertyMock(return_value='unique_session_id')) + def test_get_system_bars_falls_back_when_viewport_rect_malformed(self): + # viewportRect is present but missing required keys -> the derived + # calculation raises, is swallowed, and we fall back to the driver. + self.mock_webdriver.capabilities['viewportRect'] = {'left': 5} + driver_bars = {'statusBar': {'height': 7}, 'navigationBar': {'height': 9}} + mock_get_system_bars = Mock(return_value=driver_bars) + self.mock_webdriver.get_system_bars = mock_get_system_bars + try: + result = AndroidMetadata(self.mock_webdriver).get_system_bars() + mock_get_system_bars.assert_called_once() + self.assertEqual(result, driver_bars) + finally: + self.mock_webdriver.capabilities['viewportRect'] = None + def test_status_bar(self): mock_get_system_bars = {'statusBar': {'height': 1}} self.android_metadata.get_system_bars = Mock(return_value=mock_get_system_bars) diff --git a/tests/test_app_automate.py b/tests/test_app_automate.py index 680cf5d..1503bd7 100644 --- a/tests/test_app_automate.py +++ b/tests/test_app_automate.py @@ -97,3 +97,37 @@ def test_get_tiles(self): self.assertEqual(result.nav_bar_height, 150) self.assertEqual(result.header_height, 120) self.assertEqual(result.footer_height, 80) + + @patch('percy.providers.app_automate.log') + @patch.dict(os.environ, {"PERCY_DISABLE_REMOTE_UPLOADS": "true"}) + @patch.object(GenericProvider, '_get_tiles', MagicMock(return_value=['local-tile'])) + def test_get_tiles_falls_back_locally_when_remote_uploads_disabled(self, mock_log): + tiles = self.app_automate._get_tiles(fullpage=True) + # full page is unsupported with remote uploads disabled -> warn and use local tiles + mock_log.assert_called() + self.assertEqual(tiles, ['local-tile']) + + def test_execute_percy_screenshot_begin_returns_parsed_response(self): + self.mock_webdriver.execute_script.return_value = '{"deviceName": "Google Pixel 4", "osVersion": "12.0"}' + result = self.app_automate.execute_percy_screenshot_begin('Screenshot 1') + self.assertEqual(result, {'deviceName': 'Google Pixel 4', 'osVersion': '12.0'}) + + @patch('percy.providers.app_automate.log') + def test_execute_percy_screenshot_throws_error(self, mock_log): + self.mock_webdriver.execute_script.side_effect = Exception('SomeException') + with self.assertRaises(Exception): + self.app_automate.execute_percy_screenshot(1080, 'singlepage', 5) + mock_log.assert_called() + + @patch.object(Metadata, 'session_id', PropertyMock(return_value='unique_session_id')) + @patch.object(GenericProvider, 'screenshot', MagicMock(return_value={'link': 'https://link', 'data': 'd'})) + def test_screenshot_reraises_after_failure_notification(self): + self.app_automate.execute_percy_screenshot_begin = MagicMock(return_value=None) + # first ('success') notification raises; second ('failure') notification succeeds + mock_end = MagicMock(side_effect=[Exception('finalize failed'), None]) + self.app_automate.execute_percy_screenshot_end = mock_end + with self.assertRaises(Exception) as cm: + self.app_automate.screenshot('name') + self.assertEqual(str(cm.exception), 'finalize failed') + # a failure notification is attempted before the error propagates + mock_end.assert_called_with('name', 'https://link', 'failure', None, 'finalize failed') diff --git a/tests/test_app_percy.py b/tests/test_app_percy.py index 939dbea..33856e0 100644 --- a/tests/test_app_percy.py +++ b/tests/test_app_percy.py @@ -195,3 +195,23 @@ def test_screenshot_labels_not_string(self): with self.assertRaises(TypeError) as cm: app_percy.screenshot('screenshot name', labels=123) self.assertEqual(str(cm.exception), 'Argument labels should be a string') + + def test_screenshot_sync_not_bool(self): + app_percy = AppPercy(self.mock_android_webdriver) + with self.assertRaises(TypeError) as cm: + app_percy.screenshot('screenshot name', sync='yes') + self.assertEqual(str(cm.exception), 'Argument sync should be a boolean') + + def test_screenshot_test_case_not_string(self): + app_percy = AppPercy(self.mock_android_webdriver) + with self.assertRaises(TypeError) as cm: + app_percy.screenshot('screenshot name', test_case=123) + self.assertEqual(str(cm.exception), 'Argument test_case should be a string') + + def test_screenshot_th_test_case_execution_id_not_string(self): + app_percy = AppPercy(self.mock_android_webdriver) + with self.assertRaises(TypeError) as cm: + app_percy.screenshot('screenshot name', th_test_case_execution_id=123) + self.assertEqual( + str(cm.exception), 'Argument th_test_case_execution_id should be a string' + ) diff --git a/tests/test_cli_wrapper.py b/tests/test_cli_wrapper.py index 77c4436..e89fb3c 100644 --- a/tests/test_cli_wrapper.py +++ b/tests/test_cli_wrapper.py @@ -127,6 +127,74 @@ def test_request_body(self): self.assertEqual(response["th_test_case_execution_id"], th_test_case_execution_id) self.assertEqual(response["labels"], labels) + def test_is_percy_enabled_rejects_old_cli_minor_version(self): + cli_wrapper.CLIWrapper.is_percy_enabled.cache_clear() + try: + with patch("requests.get") as mock_get: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = { + "success": True, + "build": {"id": "build-1", "url": "build-url"}, + } + response.headers = {"x-percy-core-version": "1.26.0"} + mock_get.return_value = response + self.assertFalse(cli_wrapper.CLIWrapper.is_percy_enabled()) + finally: + cli_wrapper.CLIWrapper.is_percy_enabled.cache_clear() + + def test_is_percy_enabled_returns_false_when_healthcheck_unsuccessful(self): + cli_wrapper.CLIWrapper.is_percy_enabled.cache_clear() + try: + with patch("requests.get") as mock_get: + response = Mock() + response.raise_for_status.return_value = None + response.json.return_value = {"success": False, "error": "not ready"} + response.headers = {"x-percy-core-version": "1.27.0"} + mock_get.return_value = response + self.assertFalse(cli_wrapper.CLIWrapper.is_percy_enabled()) + finally: + cli_wrapper.CLIWrapper.is_percy_enabled.cache_clear() + + def test_post_failed_event_swallows_error(self): + with patch("requests.post") as mock_requests: + response = Mock() + response.json.return_value = {"error": "some error"} + response.status_code = 500 + response.raise_for_status.return_value = None + mock_requests.return_value = response + # raises CLIException internally; method must swallow it and return None + self.assertIsNone(self.cli_wrapper.post_failed_event("some-error")) + + def test_post_poa_screenshots(self): + with patch("requests.post") as mock_requests: + response = Mock() + response.json.return_value = {"data": {"link": "snapshot-url"}, "success": True} + response.status_code = 200 + response.raise_for_status.return_value = None + mock_requests.return_value = response + result = self.cli_wrapper.post_poa_screenshots( + "some-name", "session-id", "executor-url", {"platformName": "android"}, {} + ) + self.assertDictEqual(result, {"link": "snapshot-url"}) + + def test_post_poa_screenshots_throws_error(self): + with patch("requests.post") as mock_requests: + response = Mock() + response.json.return_value = {"error": "poa error"} + response.status_code = 500 + response.raise_for_status.return_value = None + mock_requests.return_value = response + self.assertRaises( + CLIException, + self.cli_wrapper.post_poa_screenshots, + "some-name", + "session-id", + "executor-url", + {"platformName": "android"}, + {}, + ) + def test_request_body_when_optional_values_are_null(self): tile = Tile("some-file-path", 10, 10, 20, 20) tag = {"name": "Tag"} diff --git a/tests/test_generic_provider.py b/tests/test_generic_provider.py index 8a5b818..aa33cb4 100644 --- a/tests/test_generic_provider.py +++ b/tests/test_generic_provider.py @@ -152,6 +152,16 @@ def test_post_screenshots(self): ) self.assertEqual(response, self.comparison_response) + @patch("percy.providers.generic_provider.log") + @patch.object( + Metadata, "session_id", PropertyMock(return_value="unique_session_id") + ) + def test_get_tiles_fullpage_falls_back_to_single_page(self, mock_log): + tile = self.generic_provider._get_tiles(fullpage=True)[0] + # full page is only supported on App Automate -> warn and produce a single tile + mock_log.assert_called() + os.remove(tile.filepath) + def test_supports(self): self.assertTrue(self.generic_provider.supports("some-dummy-url")) diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..116e4e8 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,43 @@ +# pylint: disable=[protected-access] +import importlib +import sys +import unittest +from unittest.mock import patch, Mock + +import percy + + +class TestPercyInitFallbacks(unittest.TestCase): + """The package exposes percy_snapshot/percy_screenshot with defensive + fallbacks that raise a helpful error when the companion package that + actually implements the command is not installed.""" + + def test_percy_snapshot_fallback_raises_when_selenium_missing(self): + # percy-appium-app does not ship percy.snapshot, so percy_snapshot is + # the fallback and must point users at the percy-selenium package. + with self.assertRaises(ModuleNotFoundError) as cm: + percy.percy_snapshot(driver=Mock()) + self.assertIn('percy-selenium', str(cm.exception)) + + def test_percySnapshot_delegates_to_percy_snapshot(self): + with patch('percy.percy_snapshot') as mock_snapshot: + mock_snapshot.return_value = 'delegated' + result = percy.percySnapshot(browser='driver', name='snap') + self.assertEqual(result, 'delegated') + mock_snapshot.assert_called_once_with(driver='driver', name='snap') + + def test_percy_screenshot_fallback_raises_when_appium_missing(self): + # Force the percy.screenshot import to fail and reload the package so + # the defensive percy_screenshot fallback is exercised, then restore. + with patch.dict(sys.modules, {'percy.screenshot': None}): + importlib.reload(percy) + try: + with self.assertRaises(ModuleNotFoundError) as cm: + percy.percy_screenshot(driver=Mock()) + self.assertIn('percy-appium-app', str(cm.exception)) + finally: + importlib.reload(percy) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2613f1b..77ec9ae 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -93,6 +93,19 @@ def test_metadata_os_version(self): self.mock_webdriver.capabilities = capabilities self.assertEqual(self.metadata.os_version, '') + def test_metadata_remote_url_new_appium_client(self): + client_config = type('C', (), {'remote_server_addr': 'https://new.hub/wd/hub'})() + command_executor = type('E', (), {'_client_config': client_config, '_url': 'https://old.hub/wd/hub'})() + self.mock_webdriver.command_executor = command_executor + self.assertEqual(self.metadata.remote_url, 'https://new.hub/wd/hub') + + def test_metadata_remote_url_falls_back_to_url_when_no_remote_addr(self): + # client_config present but without a remote_server_addr -> old-client fallback + client_config = type('C', (), {'remote_server_addr': None})() + command_executor = type('E', (), {'_client_config': client_config, '_url': 'https://old.hub/wd/hub'})() + self.mock_webdriver.command_executor = command_executor + self.assertEqual(self.metadata.remote_url, 'https://old.hub/wd/hub') + def test_metadata_value_from_devices_info_for_android(self): android_device = 'google pixel 7' android_device_info = {'13': {'status_bar': '118', 'nav_bar': '63'}} diff --git a/tests/test_percy_automate.py b/tests/test_percy_automate.py new file mode 100644 index 0000000..b2a8c0d --- /dev/null +++ b/tests/test_percy_automate.py @@ -0,0 +1,101 @@ +# pylint: disable=[arguments-differ, protected-access] +import copy +import unittest +from unittest.mock import patch, Mock, MagicMock, PropertyMock + +from appium.webdriver.webdriver import WebDriver +from percy.errors import DriverNotSupported +from percy.lib.percy_automate import PercyOnAutomate +from percy.lib.cli_wrapper import CLIWrapper +from percy.metadata.driver_metadata import DriverMetaData +from tests.mocks.mock_methods import android_capabilities + + +class TestPercyOnAutomate(unittest.TestCase): + poa_response = {'data': {'link': 'https://snapshot_url'}} + + @patch('appium.webdriver.webdriver.WebDriver') + def setUp(self, mock_webdriver): + mock_webdriver.__class__ = WebDriver + self.mock_webdriver = copy.deepcopy(mock_webdriver) + self.mock_webdriver.capabilities = copy.deepcopy(android_capabilities) + + def _disable_percy(self): + self.mock_webdriver.capabilities['percy:options'] = {'enabled': False} + self.mock_webdriver.capabilities['percyOptions'] = {'enabled': False} + + def test_invalid_driver(self): + with self.assertRaises(DriverNotSupported): + PercyOnAutomate(Mock()) + + def test_screenshot_returns_none_when_disabled(self): + self._disable_percy() + poa = PercyOnAutomate(self.mock_webdriver) + self.assertIsNone(poa.screenshot('screenshot 1')) + + def test_screenshot_name_not_string(self): + poa = PercyOnAutomate(self.mock_webdriver) + with self.assertRaises(TypeError) as cm: + poa.screenshot(123) + self.assertEqual(str(cm.exception), 'Argument name should be a string') + + def test_screenshot_kwargs_without_options(self): + poa = PercyOnAutomate(self.mock_webdriver) + with self.assertRaises(KeyError): + poa.screenshot('screenshot 1', sync=True) + + @patch.object(DriverMetaData, 'session_id', PropertyMock(return_value='session-1')) + @patch.object(DriverMetaData, 'command_executor_url', + PropertyMock(return_value='https://hub-cloud.browserstack.com/wd/hub')) + @patch.object(DriverMetaData, 'capabilities', PropertyMock(return_value={'platformName': 'android'})) + @patch.object(CLIWrapper, 'post_poa_screenshots', MagicMock(return_value=poa_response)) + def test_screenshot_happy_path(self): + poa = PercyOnAutomate(self.mock_webdriver) + result = poa.screenshot('screenshot 1', options={}) + self.assertEqual(result, self.poa_response) + CLIWrapper.post_poa_screenshots.assert_called_once() + # element id lists default to empty when no regions passed + sent_options = CLIWrapper.post_poa_screenshots.call_args.args[4] + self.assertEqual(sent_options['ignore_region_elements'], []) + self.assertEqual(sent_options['consider_region_elements'], []) + + @patch.object(DriverMetaData, 'session_id', PropertyMock(return_value='session-1')) + @patch.object(DriverMetaData, 'command_executor_url', + PropertyMock(return_value='https://hub-cloud.browserstack.com/wd/hub')) + @patch.object(DriverMetaData, 'capabilities', PropertyMock(return_value={'platformName': 'android'})) + @patch.object(CLIWrapper, 'post_poa_screenshots', MagicMock(return_value=poa_response)) + def test_screenshot_converts_alt_element_keys(self): + poa = PercyOnAutomate(self.mock_webdriver) + ignore_el = Mock() + ignore_el.id = 'ignore-id' + consider_el = Mock() + consider_el.id = 'consider-id' + options = { + 'ignoreRegionAppiumElements': [ignore_el], + 'considerRegionAppiumElements': [consider_el], + } + poa.screenshot('screenshot 1', options=options) + sent_options = CLIWrapper.post_poa_screenshots.call_args.args[4] + # alt camelCase keys are normalised, ids extracted, and raw element lists removed + self.assertEqual(sent_options['ignore_region_elements'], ['ignore-id']) + self.assertEqual(sent_options['consider_region_elements'], ['consider-id']) + self.assertNotIn('ignoreRegionAppiumElements', sent_options) + self.assertNotIn('considerRegionAppiumElements', sent_options) + self.assertNotIn('ignore_region_appium_elements', sent_options) + self.assertNotIn('consider_region_appium_elements', sent_options) + + @patch('percy.lib.percy_automate.log') + @patch.object(DriverMetaData, 'session_id', PropertyMock(return_value='session-1')) + @patch.object(DriverMetaData, 'command_executor_url', + PropertyMock(return_value='https://hub-cloud.browserstack.com/wd/hub')) + @patch.object(DriverMetaData, 'capabilities', PropertyMock(return_value={'platformName': 'android'})) + @patch.object(CLIWrapper, 'post_poa_screenshots', + MagicMock(side_effect=Exception('boom'))) + def test_screenshot_swallows_exception_and_returns_none(self, mock_log): + poa = PercyOnAutomate(self.mock_webdriver) + self.assertIsNone(poa.screenshot('screenshot 1', options={})) + mock_log.assert_called() + + +if __name__ == '__main__': + unittest.main()