Skip to content
Open
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
7 changes: 7 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[run]
source = percy
parallel = True

[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 @@ -61,4 +61,4 @@ jobs:
yarn remove @percy/cli && yarn link `echo $PERCY_PACKAGES`
npx percy --version

- run: make test
- run: make coverage
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ build
**/__pycache__
node_modules*
.DS_Store
.coverage
.coverage.*
htmlcov
package-lock.json
output_file.json
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ pylint==2.*
twine
robotframework>=5.0
robotframework-seleniumlibrary>=5.0
coverage==7.*
13 changes: 12 additions & 1 deletion tests/test_driver_metadata.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
106 changes: 106 additions & 0 deletions tests/test_init.py
Original file line number Diff line number Diff line change
@@ -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()
100 changes: 100 additions & 0 deletions tests/test_robot_library.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""Tests for Robot Framework library integration."""
import importlib
import sys
import unittest
from unittest.mock import MagicMock, patch

Expand All @@ -7,6 +9,7 @@
_parse_bool,
_parse_csv,
_parse_json,
_parse_padding,
_parse_widths,
)

Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Loading
Loading