diff --git a/.coveragerc b/.coveragerc index 05d680c..178423c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -8,6 +8,7 @@ omit = */__pycache__/* */site-packages/* */.venv/* + percy/robot_library.py [report] # Regexes for lines to exclude from consideration diff --git a/.pylintrc b/.pylintrc index 044d308..8f3eef0 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,9 +1,11 @@ [MASTER] fail-under=10.0 +ignore-patterns=.*\.robot$ disable= broad-except, broad-exception-raised, global-statement, + import-error, invalid-name, missing-class-docstring, missing-function-docstring, diff --git a/development.txt b/development.txt index 6583232..de715cc 100644 --- a/development.txt +++ b/development.txt @@ -2,3 +2,5 @@ httpretty==1.0.* pylint==2.* twine coverage==7.* +robotframework>=5.0 +robotframework-browser>=16.0 diff --git a/percy/__init__.py b/percy/__init__.py index 8cb0583..3cb2807 100644 --- a/percy/__init__.py +++ b/percy/__init__.py @@ -1,6 +1,12 @@ from percy.version import __version__ from percy.screenshot import percy_automate_screenshot +# Robot Framework support — graceful when robotframework is not installed +try: + from percy.robot_library import PercyLibrary # pragma: no cover +except ImportError: # pragma: no cover + pass + # import snapshot command try: from percy.screenshot import percy_snapshot diff --git a/percy/robot_library.py b/percy/robot_library.py new file mode 100644 index 0000000..6c95d54 --- /dev/null +++ b/percy/robot_library.py @@ -0,0 +1,267 @@ +"""Robot Framework library for Percy visual testing with Playwright. + +Provides keywords to capture Percy snapshots from Robot Framework tests +using the Browser library (robotframework-browser) as the Playwright backend. +All robot-specific imports are wrapped in try/except for graceful degradation +when robotframework is not installed. + +Usage in Robot Framework: + *** Settings *** + Library Browser + Library percy.robot_library.PercyLibrary + + *** Test Cases *** + Homepage Visual Test + New Browser chromium headless=true + New Page https://example.com + Percy Snapshot Homepage + Close Browser + +Run with: + percy exec -- robot tests/ +""" + +import json + +try: + from robot.api.deco import keyword, library + from robot.libraries.BuiltIn import BuiltIn + ROBOT_AVAILABLE = True +except ImportError: + ROBOT_AVAILABLE = False + +# Lazy imports — percy.screenshot requires playwright at import time, +# so we defer the import to when keywords are actually called. +_screenshot_module = None + +def _get_screenshot_module(): + global _screenshot_module # pylint: disable=global-statement + if _screenshot_module is None: + from percy import screenshot as _mod # pylint: disable=import-outside-toplevel + _screenshot_module = _mod + return _screenshot_module + + +def _parse_bool(val): + if val is None: + return None + return str(val).lower() in ("true", "1", "yes") + + +def _parse_widths(widths): + if not widths: + return None + if isinstance(widths, str): + return [int(w.strip()) for w in widths.split(",")] + if isinstance(widths, list): + return [int(w) for w in widths] + return None + + +def _parse_csv(val): + if not val: + return None + if isinstance(val, str): + return [v.strip() for v in val.split(",")] + if isinstance(val, list): + return val + return None + + +def _parse_json(val): + if not val: + return None + if isinstance(val, str): + return json.loads(val) + if isinstance(val, (dict, list)): + return val + return None + + +class _BrowserLibraryPageAdapter: + """Adapter to make Browser library (robotframework-browser) look like a Playwright page. + + Percy's playwright SDK expects a Playwright page object with methods like + evaluate(), url, and content(). This adapter bridges the gap by calling + Browser library keywords via Robot Framework's BuiltIn. + """ + + @property + def url(self): + return BuiltIn().run_keyword("Browser.Get Url") + + def evaluate(self, expression): + return BuiltIn().run_keyword("Browser.Evaluate JavaScript", "", expression) + + def content(self): + return BuiltIn().run_keyword("Browser.Get Page Source") + + +if ROBOT_AVAILABLE: + + @library(scope="GLOBAL") + class PercyLibrary: + """Percy visual testing library for Robot Framework (Playwright/Browser backend). + + Provides keywords to capture visual snapshots using Percy. + Requires Browser library (robotframework-browser) to be imported. + + Tests must be run under ``percy exec``: + | percy exec -- robot tests/ + """ + + def _get_page(self): + """Get a page adapter for the Browser library.""" + try: + BuiltIn().get_library_instance("Browser") + return _BrowserLibraryPageAdapter() + except RuntimeError as exc: + raise RuntimeError( + "PercyLibrary requires Browser library to be imported" + ) from exc + + # -------------------------------------------------------------- + # Percy Snapshot + # -------------------------------------------------------------- + + @keyword("Percy Snapshot") + def percy_snapshot_keyword( # pylint: disable=too-many-arguments,too-many-locals + self, name, widths=None, min_height=None, + percy_css=None, scope=None, scope_options=None, + enable_javascript=None, enable_layout=None, + disable_shadow_dom=None, labels=None, + test_case=None, sync=None, regions=None, + responsive_snapshot_capture=None, + ): + """Capture a Percy visual snapshot of the current page. + + ``name`` is the snapshot name shown in the Percy dashboard. + + ``widths`` is a comma-separated string of responsive widths + (e.g., ``375,768,1280``). + + ``min_height`` is the minimum screenshot height in pixels. + + ``percy_css`` is custom CSS injected before the snapshot. + + ``scope`` is a CSS selector to limit the snapshot area. + + ``enable_javascript`` enables JS execution in Percy rendering. + + ``enable_layout`` enables layout comparison mode. + + ``labels`` is a comma-separated string of tags/labels. + + ``regions`` is a JSON string of region definitions. + + ``responsive_snapshot_capture`` enables responsive capture mode. + + Examples: + | Percy Snapshot Homepage + | Percy Snapshot Login widths=375,1280 min_height=1024 + | Percy Snapshot Dashboard labels=dashboard,admin enable_layout=True + """ + page = self._get_page() + mod = _get_screenshot_module() + mod.percy_snapshot( + page, + name, + widths=_parse_widths(widths), + min_height=int(min_height) if min_height else None, + percy_css=percy_css, + scope=scope, + scope_options=_parse_json(scope_options), + enable_javascript=_parse_bool(enable_javascript), + enable_layout=_parse_bool(enable_layout), + disable_shadow_dom=_parse_bool(disable_shadow_dom), + labels=",".join(_parse_csv(labels)) if labels else None, + test_case=test_case, + sync=_parse_bool(sync), + regions=_parse_json(regions), + responsive_snapshot_capture=_parse_bool(responsive_snapshot_capture), + ) + + # -------------------------------------------------------------- + # Region helpers + # -------------------------------------------------------------- + + @keyword("Create Percy Region") + def create_percy_region_keyword( # pylint: disable=too-many-arguments + self, algorithm="ignore", + bounding_box=None, element_xpath=None, + element_css=None, padding=None, + diff_sensitivity=None, + image_ignore_threshold=None, + carousels_enabled=None, + banners_enabled=None, ads_enabled=None, + diff_ignore_threshold=None, + ): + """Create a region definition for Percy ignore/consider regions. + + ``algorithm`` is one of ``ignore``, ``standard``, or ``intelliignore``. + + ``element_css`` is a CSS selector for the region. + + ``element_xpath`` is an XPath selector for the region. + + ``bounding_box`` is a JSON string with x, y, width, height. + + ``padding`` is padding in pixels around the element. + + Returns a region dict to pass to ``Percy Snapshot`` via ``regions``. + + == Usage with Percy Snapshot == + | ${region}= Create Percy Region algorithm=ignore element_css=.ad-banner + | Percy Snapshot Homepage regions=${{json.dumps([${region}])}} + + == Multiple regions == + | ${ignore}= Create Percy Region element_css=h1 + | ${consider}= Create Percy Region + | ... algorithm=standard element_css=.content + | Percy Snapshot Mixed + | ... regions=${{json.dumps([${ignore}, ...])}} + + == With padding and bounding box == + | ${region}= Create Percy Region + | ... element_css=.banner padding=10 + | ${region}= Create Percy Region + | ... bounding_box={"x":0,"y":0,"width":200} + """ + mod = _get_screenshot_module() + return mod.create_region( + boundingBox=_parse_json(bounding_box), + elementXpath=element_xpath, + elementCSS=element_css, + padding=int(padding) if padding else None, + algorithm=algorithm, + diffSensitivity=int(diff_sensitivity) if diff_sensitivity else None, + imageIgnoreThreshold=( + float(image_ignore_threshold) if image_ignore_threshold else None + ), + carouselsEnabled=_parse_bool(carousels_enabled), + bannersEnabled=_parse_bool(banners_enabled), + adsEnabled=_parse_bool(ads_enabled), + diffIgnoreThreshold=float(diff_ignore_threshold) if diff_ignore_threshold else None, + ) + + # -------------------------------------------------------------- + # Utility + # -------------------------------------------------------------- + + @keyword("Percy Is Running") + def percy_is_running_keyword(self): + """Check if the Percy CLI server is running. + + Returns ``True`` if Percy is available, ``False`` otherwise. + """ + mod = _get_screenshot_module() + return bool(mod._is_percy_enabled()) # pylint: disable=protected-access + +else: + class PercyLibrary: # pylint: disable=function-redefined,too-few-public-methods + """Stub -- robotframework is not installed.""" + def __init__(self): + raise ImportError( + "robotframework is not installed. " + "Install it with: pip install robotframework robotframework-browser" + ) diff --git a/tests/test_robot_integration.robot b/tests/test_robot_integration.robot new file mode 100644 index 0000000..7dffbaa --- /dev/null +++ b/tests/test_robot_integration.robot @@ -0,0 +1,169 @@ +*** Settings *** +Library Browser +Library percy.robot_library.PercyLibrary +Library Collections + +Suite Setup Open Test Browser +Suite Teardown Close All Browsers + +*** Keywords *** +Open Test Browser + New Browser chromium headless=true + New Page https://example.com + +Close All Browsers + Close Browser + +*** Test Cases *** + +# =================================================================== +# BASIC SNAPSHOTS +# =================================================================== + +Basic Snapshot + [Documentation] Simplest snapshot -- just a name + Percy Snapshot Basic - Example.com + +Named Snapshot After Navigation + [Documentation] Navigate and take snapshot + Go To https://example.com + Percy Snapshot Navigation - After GoTo + +Multiple Snapshots Same Page + [Documentation] Multiple snapshots of the same page + Percy Snapshot Multi - First + Percy Snapshot Multi - Second + +# =================================================================== +# RESPONSIVE WIDTHS +# =================================================================== + +Single Width Mobile + [Documentation] Snapshot at mobile width only + Percy Snapshot Width - Mobile 375 widths=375 + +Multiple Widths + [Documentation] Snapshot at mobile, tablet, desktop + Percy Snapshot Width - All Breakpoints widths=375,768,1280 + +# =================================================================== +# MIN HEIGHT +# =================================================================== + +Min Height + [Documentation] Snapshot with minimum height + Percy Snapshot MinHeight - 1024 min_height=1024 + +Widths Plus Min Height + [Documentation] Combine widths and min height + Percy Snapshot Widths+Height widths=375,1280 min_height=1500 + +# =================================================================== +# PERCY CSS +# =================================================================== + +Percy CSS Hide Heading + [Documentation] Hide the h1 heading + Percy Snapshot CSS - Hide H1 percy_css=h1 { display: none !important; } + +Percy CSS Custom Background + [Documentation] Change background color + Percy Snapshot CSS - Background percy_css=body { background-color: #f0f0f0 !important; } + +# =================================================================== +# SCOPED SNAPSHOTS +# =================================================================== + +Scoped To Body Div + [Documentation] Capture only the main content div + Percy Snapshot Scope - Body Div scope=body > div + +# =================================================================== +# RENDERING OPTIONS +# =================================================================== + +JavaScript Enabled + [Documentation] Snapshot with JS enabled in Percy rendering + Percy Snapshot JS - Enabled enable_javascript=True + +Layout Mode + [Documentation] Layout comparison mode + Percy Snapshot Layout - Basic enable_layout=True + +Shadow DOM Disabled + [Documentation] Snapshot without Shadow DOM capture + Percy Snapshot ShadowDOM - Disabled disable_shadow_dom=True + +# =================================================================== +# LABELS / TAGS +# =================================================================== + +Single Label + [Documentation] Snapshot with one label + Percy Snapshot Labels - Single labels=smoke-test + +Multiple Labels + [Documentation] Snapshot with multiple labels + Percy Snapshot Labels - Multiple labels=regression,homepage,v2 + +# =================================================================== +# TEST CASE METADATA +# =================================================================== + +Test Case ID + [Documentation] Snapshot with test case identifier + Percy Snapshot TestCase - TC001 test_case=TC-001-homepage + +# =================================================================== +# IGNORE REGIONS +# =================================================================== + +Ignore Region By CSS + [Documentation] Create ignore region using CSS selector + ${region}= Create Percy Region algorithm=ignore element_css=h1 + Percy Snapshot Region - Ignore CSS regions=${{json.dumps([${region}])}} + +# =================================================================== +# CONSIDER REGIONS +# =================================================================== + +Consider Region Standard + [Documentation] Standard algorithm with diff sensitivity + ${region}= Create Percy Region algorithm=standard element_css=body > div diff_sensitivity=5 + Percy Snapshot Region - Standard regions=${{json.dumps([${region}])}} + +IntelliIgnore Region + [Documentation] IntelliIgnore with carousel detection + ${region}= Create Percy Region algorithm=intelliignore element_css=body > div carousels_enabled=True + Percy Snapshot Region - IntelliIgnore regions=${{json.dumps([${region}])}} + +# =================================================================== +# ALL OPTIONS COMBINED +# =================================================================== + +All Options Combined + [Documentation] Every option in a single snapshot call + ${ignore}= Create Percy Region algorithm=ignore element_css=h1 padding=5 + Percy Snapshot Full Options - Everything + ... widths=375,768,1280 + ... min_height=1024 + ... percy_css=a { text-decoration: none !important; } + ... enable_javascript=True + ... enable_layout=True + ... labels=full-test,regression,v2 + ... test_case=TC-999-all-options + ... regions=${{json.dumps([${ignore}])}} + +# =================================================================== +# UTILITY KEYWORDS +# =================================================================== + +Percy Is Running Returns True + [Documentation] Verify Percy Is Running keyword works + ${running}= Percy Is Running + Should Be True ${running} + +Conditional Snapshot + [Documentation] Take snapshot only if Percy is running + ${running}= Percy Is Running + Run Keyword If ${running} Percy Snapshot Conditional - If Running diff --git a/tests/test_robot_library.py b/tests/test_robot_library.py new file mode 100644 index 0000000..cc72a76 --- /dev/null +++ b/tests/test_robot_library.py @@ -0,0 +1,101 @@ +"""Tests for Robot Framework library integration. + +These tests mock playwright at module level so they can run without +playwright installed, since percy.screenshot imports it at top level. +""" +import sys +from unittest.mock import MagicMock, patch + +# Mock playwright before any percy imports pylint: disable=protected-access +_mock_playwright = MagicMock() +_mock_playwright._repo_version.version = "1.50.0" # pylint: disable=protected-access +_mock_playwright.sync_api.Error = Exception +_mock_playwright.sync_api.TimeoutError = TimeoutError +sys.modules.setdefault("playwright", _mock_playwright) +sys.modules.setdefault( # pylint: disable=protected-access + "playwright._repo_version", _mock_playwright._repo_version +) +sys.modules.setdefault("playwright.sync_api", _mock_playwright.sync_api) + +from percy.robot_library import ( # noqa: E402 pylint: disable=wrong-import-position + PercyLibrary, + _parse_bool, + _parse_csv, + _parse_json, + _parse_widths, +) + + +class TestParseHelpers: + def test_parse_bool_none(self): + assert _parse_bool(None) is None + + def test_parse_bool_true(self): + assert _parse_bool("True") is True + assert _parse_bool("true") is True + assert _parse_bool("1") is True + + def test_parse_bool_false(self): + assert _parse_bool("False") is False + assert _parse_bool("no") is False + + def test_parse_widths_string(self): + assert _parse_widths("375,768,1280") == [375, 768, 1280] + + def test_parse_widths_none(self): + assert _parse_widths(None) is None + + def test_parse_csv_string(self): + assert _parse_csv("regression, homepage") == ["regression", "homepage"] + + def test_parse_json_string(self): + assert _parse_json('{"key": true}') == {"key": True} + + def test_parse_json_none(self): + assert _parse_json(None) is None + + +class TestPercyLibraryKeywords: + def test_import_succeeds(self): + assert PercyLibrary is not None + + @patch("percy.robot_library._get_screenshot_module") + @patch("percy.robot_library.BuiltIn") + def test_percy_snapshot_keyword(self, mock_builtin, mock_get_mod): + mock_mod = MagicMock() + mock_get_mod.return_value = mock_mod + mock_builtin.return_value.get_library_instance.return_value = MagicMock() + + lib = PercyLibrary() + lib.percy_snapshot_keyword("Homepage", widths="375,1280", labels="regression,v2") + + mock_mod.percy_snapshot.assert_called_once() + call_kwargs = mock_mod.percy_snapshot.call_args[1] + assert call_kwargs["widths"] == [375, 1280] + assert call_kwargs["labels"] == ["regression", "v2"] + + @patch("percy.robot_library._get_screenshot_module") + def test_percy_is_running(self, mock_get_mod): + mock_mod = MagicMock() + mock_get_mod.return_value = mock_mod + + mock_mod._is_percy_enabled.return_value = {"session_type": "web"} # pylint: disable=protected-access + lib = PercyLibrary() + assert lib.percy_is_running_keyword() is True + + mock_mod._is_percy_enabled.return_value = False # pylint: disable=protected-access + assert lib.percy_is_running_keyword() is False + + @patch("percy.robot_library._get_screenshot_module") + def test_create_region(self, mock_get_mod): + mock_mod = MagicMock() + mock_mod.create_region.return_value = { + "algorithm": "ignore", + "elementSelector": {"elementCSS": ".ad"}, + } + mock_get_mod.return_value = mock_mod + + lib = PercyLibrary() + result = lib.create_percy_region_keyword(algorithm="ignore", element_css=".ad") + assert result["algorithm"] == "ignore" + mock_mod.create_region.assert_called_once()