Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[run]
source = percy

[report]
show_missing = True
fail_under = 100
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.10.3
3.10
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions development.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
httpretty==1.1.*
pylint==2.*
twine
coverage==7.*
15 changes: 15 additions & 0 deletions tests/test_android_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_app_automate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
20 changes: 20 additions & 0 deletions tests/test_app_percy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
68 changes: 68 additions & 0 deletions tests/test_cli_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down
10 changes: 10 additions & 0 deletions tests/test_generic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
43 changes: 43 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions tests/test_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}}
Expand Down
101 changes: 101 additions & 0 deletions tests/test_percy_automate.py
Original file line number Diff line number Diff line change
@@ -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()
Loading