diff --git a/.gitignore b/.gitignore index 82f9275..d5727bc 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +node_modules +yarn.lock diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..044d308 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,14 @@ +[MASTER] +fail-under=10.0 +disable= + broad-except, + broad-exception-raised, + global-statement, + invalid-name, + missing-class-docstring, + missing-function-docstring, + missing-module-docstring, + multiple-statements, + no-member, + no-value-for-parameter, + redefined-builtin, diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..7d4ef04 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10.3 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8acae82 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +VENV=.venv/bin +REQUIREMENTS=$(wildcard requirements.txt development.txt) +MARKER=.initialized-with-makefile +VENVDEPS=$(REQUIREMENTS setup.py) + +$(VENV): + python3 -m venv .venv + $(VENV)/python -m pip install --upgrade pip setuptools wheel + yarn + +$(VENV)/$(MARKER): $(VENVDEPS) | $(VENV) + $(VENV)/pip install $(foreach path,$(REQUIREMENTS),-r $(path)) + touch $(VENV)/$(MARKER) + +# .PHONY: venv lint test clean build release + +venv: $(VENV)/$(MARKER) + +lint: venv + $(VENV)/pylint percy/* tests/* + +test: venv + $(VENV)/python -m unittest tests.test_screenshot + $(VENV)/python -m unittest tests.test_cache + $(VENV)/python -m unittest tests.test_page_metadata + +clean: + rm -rf $$(cat .gitignore) + +build: venv + $(VENV)/python setup.py sdist bdist_wheel + +# release: build +# $(VENV)/twine upload dist/* --username __token__ --password ${PYPI_TOKEN} diff --git a/README.md b/README.md index d8c5e37..e564137 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ # Percy playwright python +![Test](https://github.com/percy/percy-playwright-python/workflows/Test/badge.svg) + +[Percy](https://percy.io) visual testing for Python Playwright. + +## Installation + +npm install `@percy/cli`: + +```sh-session +$ npm install --save-dev @percy/cli +``` + +pip install Percy playwright package: + +```ssh-session +$ pip install percy-playwright +``` + +## Usage + +This is an example test using the `percy_snapshot` function. + +``` python +from percy import percy_snapshot + +browser = webdriver.Firefox() +browser.get('http://example.com') +​ +# take a snapshot +percy_snapshot(browser, 'Python example') +``` + +Running the test above normally will result in the following log: + +```sh-session +[percy] Percy is not running, disabling snapshots +``` + +When running with [`percy +exec`](https://github.com/percy/cli/tree/master/packages/cli-exec#percy-exec), and your project's +`PERCY_TOKEN`, a new Percy build will be created and snapshots will be uploaded to your project. + +```sh-session +$ export PERCY_TOKEN=[your-project-token] +$ percy exec -- [python test command] +[percy] Percy has started! +[percy] Created build #1: https://percy.io/[your-project] +[percy] Snapshot taken "Python example" +[percy] Stopping percy... +[percy] Finalized build #1: https://percy.io/[your-project] +[percy] Done! +``` + +## Configuration + +`percy_snapshot(driver, name[, **kwargs])` + +- `page` (**required**) - A playwright page instance +- `name` (**required**) - The snapshot name; must be unique to each snapshot +- `**kwargs` - [See per-snapshot configuration options](https://docs.percy.io/docs/cli-configuration#per-snapshot-configuration) + + +## Percy on Automate + +## Usage + +``` python +from playwright.sync_api import sync_playwright +from percy import percy_screenshot, percy_snapshot + +desired_cap = { + 'browser': 'chrome', + 'browser_version': 'latest', + 'os': 'osx', + 'os_version': 'ventura', + 'name': 'Percy Playwright PoA Demo', + 'build': 'percy-playwright-python-tutorial', + 'browserstack.username': 'username', + 'browserstack.accessKey': 'accesskey' +} + +with sync_playwright() as playwright: + desired_caps = {} + cdpUrl = 'wss://cdp.browserstack.com/playwright?caps=' + urllib.parse.quote(json.dumps(desired_cap)) + browser = playwright.chromium.connect(cdpUrl) + page = browser.new_page() + page.goto("https://percy.io/") + percy_screenshot(page, name = "Screenshot 1") +``` diff --git a/development.txt b/development.txt new file mode 100644 index 0000000..3dc2e70 --- /dev/null +++ b/development.txt @@ -0,0 +1,3 @@ +httpretty==1.0.* +pylint==2.* +twine diff --git a/percy/__init__.py b/percy/__init__.py new file mode 100644 index 0000000..71b0e35 --- /dev/null +++ b/percy/__init__.py @@ -0,0 +1,22 @@ +from percy.version import __version__ +from percy.screenshot import percy_automate_screenshot + +# import snapshot command +try: + from percy.screenshot import percy_snapshot +except ImportError: + + def percy_snapshot(driver, *a, **kw): + raise ModuleNotFoundError( + "[percy] `percy-playwright-python` package is not installed, " + "please install it to use percy_snapshot command" + ) + + +# for better backwards compatibility +def percySnapshot(browser, *a, **kw): + return percy_snapshot(driver=browser, *a, **kw) + + +def percy_screenshot(page, *a, **kw): + return percy_automate_screenshot(page, *a, **kw) diff --git a/percy/cache.py b/percy/cache.py new file mode 100644 index 0000000..594057c --- /dev/null +++ b/percy/cache.py @@ -0,0 +1,42 @@ +import time + + +class Cache: + CACHE = {} + CACHE_TIMEOUT = 5 * 60 # 300 seconds + TIMEOUT_KEY = "last_access_time" + + # Caching Keys + session_details = "session_details" + + @classmethod + def check_types(cls, session_id, property): + if not isinstance(session_id, str): + raise TypeError("Argument session_id should be string") + if not isinstance(property, str): + raise TypeError("Argument property should be string") + + @classmethod + def set_cache(cls, session_id, property, value): + cls.check_types(session_id, property) + session = cls.CACHE.get(session_id, {}) + session[cls.TIMEOUT_KEY] = time.time() + session[property] = value + cls.CACHE[session_id] = session + + @classmethod + def get_cache(cls, session_id, property): + cls.cleanup_cache() + cls.check_types(session_id, property) + session = cls.CACHE.get(session_id, {}) + return session.get(property, None) + + @classmethod + def cleanup_cache(cls): + now = time.time() + for session_id, session in cls.CACHE.items(): + timestamp = session[cls.TIMEOUT_KEY] + if now - timestamp >= cls.CACHE_TIMEOUT: + cls.CACHE[session_id] = { + cls.session_details: session[cls.session_details] + } diff --git a/percy/page_metadata.py b/percy/page_metadata.py new file mode 100644 index 0000000..b28e8f1 --- /dev/null +++ b/percy/page_metadata.py @@ -0,0 +1,44 @@ +# pylint: disable=protected-access +import json +from percy.cache import Cache + + +class PageMetaData: + def __init__(self, page): + self.page = page + + def __fetch_guid(self, obj): + return obj._impl_obj._guid + + @property + def framework(self): + return "playwright" + + @property + def page_guid(self): + return self.__fetch_guid(self.page) + + @property + def frame_guid(self): + return self.__fetch_guid(self.page.main_frame) + + @property + def browser_guid(self): + return self.__fetch_guid(self.page.context.browser) + + @property + def session_details(self): + session_details = Cache.get_cache(self.browser_guid, Cache.session_details) + if session_details is None: + session_details = json.loads( + self.page.evaluate( + "_ => {}", 'browserstack_executor: {"action": "getSessionDetails"}' + ) + ) + Cache.set_cache(self.browser_guid, Cache.session_details, session_details) + return session_details + return session_details + + @property + def automate_session_id(self): + return self.session_details.get("hashed_id") diff --git a/percy/screenshot.py b/percy/screenshot.py new file mode 100644 index 0000000..9d3add9 --- /dev/null +++ b/percy/screenshot.py @@ -0,0 +1,168 @@ +import os +import json +import platform +from functools import lru_cache +import requests + +from playwright._repo_version import version as PLAYWRIGHT_VERSION +from percy.version import __version__ as SDK_VERSION +from percy.page_metadata import PageMetaData + +# Collect client environment information +CLIENT_INFO = "percy-playwright-python/" + SDK_VERSION +ENV_INFO = ["playwright/" + PLAYWRIGHT_VERSION, "python/" + platform.python_version()] + +# Maybe get the CLI API address from the environment +PERCY_CLI_API = os.environ.get("PERCY_CLI_API") or "http://localhost:5338" +PERCY_DEBUG = os.environ.get("PERCY_LOGLEVEL") == "debug" + +# for logging +LABEL = "[\u001b[35m" + ("percy:python" if PERCY_DEBUG else "percy") + "\u001b[39m]" + + +# Check if Percy is enabled, caching the result so it is only checked once +@lru_cache(maxsize=None) +def is_percy_enabled(): + try: + response = requests.get(f"{PERCY_CLI_API}/percy/healthcheck", timeout=30) + response.raise_for_status() + data = response.json() + session_type = data.get("type", None) + + if not data["success"]: + raise Exception(data["error"]) + version = response.headers.get("x-percy-core-version") + + if not version: + print( + f"{LABEL} You may be using @percy/agent " + "which is no longer supported by this SDK. " + "Please uninstall @percy/agent and install @percy/cli instead. " + "https://docs.percy.io/docs/migrating-to-percy-cli" + ) + return False + + if version.split(".")[0] != "1": + print(f"{LABEL} Unsupported Percy CLI version, {version}") + return False + + return session_type + except Exception as e: + print(f"{LABEL} Percy is not running, disabling snapshots") + if PERCY_DEBUG: + print(f"{LABEL} {e}") + return False + + +# Fetch the @percy/dom script, caching the result so it is only fetched once +@lru_cache(maxsize=None) +def fetch_percy_dom(): + response = requests.get(f"{PERCY_CLI_API}/percy/dom.js", timeout=30) + response.raise_for_status() + return response.text + + +# Take a DOM snapshot and post it to the snapshot endpoint +def percy_snapshot(page, name, **kwargs): + session_type = is_percy_enabled() + if session_type is False: + return None # Since session_type can be None for old CLI version + if session_type == "automate": + raise Exception( + "Invalid function call - " + "percy_snapshot()." + "Please use percy_screenshot() function while using Percy with Automate. " + "For more information on usage of PercyScreenshot, " + "refer https://docs.percy.io/docs/integrate-functional-testing-with-visual-testing" + ) + + try: + # Inject the DOM serialization script + # print(fetch_percy_dom()) + page.evaluate(fetch_percy_dom()) + + # Serialize and capture the DOM + dom_snapshot_script = f"PercyDOM.serialize({json.dumps(kwargs)})" + + # Return the serialized DOM Snapshot + dom_snapshot = page.evaluate(dom_snapshot_script) + + # Post the DOM to the snapshot endpoint with snapshot options and other info + response = requests.post( + f"{PERCY_CLI_API}/percy/snapshot", + json={ + **kwargs, + **{ + "client_info": CLIENT_INFO, + "environment_info": ENV_INFO, + "dom_snapshot": dom_snapshot, + "url": page.url, + "name": name, + }, + }, + timeout=600, + ) + + # Handle errors + response.raise_for_status() + data = response.json() + + if not data["success"]: + raise Exception(data["error"]) + return data.get("data", None) + except Exception as e: + print(f'{LABEL} Could not take DOM snapshot "{name}"') + print(f"{LABEL} {e}") + return None + + +def percy_automate_screenshot(page, name, options=None, **kwargs): + session_type = is_percy_enabled() + if session_type is False: + return None # Since session_type can be None for old CLI version + if session_type == "web": + raise Exception( + "Invalid function call - " + "percy_screenshot(). Please use percy_snapshot() function for taking screenshot. " + "percy_screenshot() should be used only while using Percy with Automate. " + "For more information on usage of percy_snapshot(), " + "refer doc for your language https://docs.percy.io/docs/end-to-end-testing" + ) + + if options is None: + options = {} + + try: + metadata = PageMetaData(page) + + # Post to automateScreenshot endpoint with page options and other info + response = requests.post( + f"{PERCY_CLI_API}/percy/automateScreenshot", + json={ + **kwargs, + **{ + "client_info": CLIENT_INFO, + "environment_info": ENV_INFO, + "sessionId": metadata.automate_session_id, + "pageGuid": metadata.page_guid, + "frameGuid": metadata.frame_guid, + "framework": metadata.framework, + "snapshotName": name, + "options": options, + }, + }, + timeout=600, + ) + + # Handle errors + response.raise_for_status() + data = response.json() + + if not data["success"]: + raise Exception(data["error"]) + + return data.get("data", None) + except Exception as e: + print(f'{LABEL} Could not take Screenshot "{name}"') + print(f"{LABEL} {e}") + return None diff --git a/percy/version.py b/percy/version.py new file mode 100644 index 0000000..7dc4b0a --- /dev/null +++ b/percy/version.py @@ -0,0 +1 @@ +__version__ = "1.0.0-beta.0" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ebd602 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +playwright==1.28.* +requests==2.* diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0c6452f --- /dev/null +++ b/setup.py @@ -0,0 +1,41 @@ +from setuptools import setup +from os import path +import percy + +# read the README for long_description +cwd = path.abspath(path.dirname(__file__)) +with open(path.join(cwd, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='percy-playwright', + description='Python client for visual testing with Percy', + long_description=long_description, + long_description_content_type='text/markdown', + version=percy.__version__, + license='MIT', + author='Perceptual Inc.', + author_email='team@percy.io', + url='https://github.com/percy/percy-playwright-python', + keywords='percy visual testing', + packages=['percy'], + include_package_data=True, + install_requires=[ + 'playwright>=1.28.0', + 'requests==2.*' + ], + python_requires='>=3.6', + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: MIT License', + 'Natural Language :: English', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], + test_suite='tests', + tests_require=['playwright', 'httpretty'], + zip_safe=False +) diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..be4e69b --- /dev/null +++ b/tests/test_cache.py @@ -0,0 +1,63 @@ +# . # pylint: disable=[arguments-differ, protected-access] +import time +import unittest +from unittest.mock import patch +from percy.cache import Cache + + +class TestCache(unittest.TestCase): + def setUp(self) -> None: + self.cache = Cache + self.session_id = "session_id_123" + self.session_details = { + "browser": "chrome", + "platform": "windows", + "browserVersion": "115.0.1", + "hashed_id": "abcdef", + } + + self.cache.set_cache( + self.session_id, Cache.session_details, self.session_details + ) + self.cache.set_cache(self.session_id, "key-1", "some-value") + + def test_set_cache(self): + with self.assertRaises(Exception) as e: + self.cache.set_cache(123, 123, 123) + self.assertEqual(str(e.exception), "Argument session_id should be string") + + with self.assertRaises(Exception) as e: + self.cache.set_cache(self.session_id, 123, 123) + self.assertEqual(str(e.exception), "Argument property should be string") + + self.assertIn(self.session_id, self.cache.CACHE) + self.assertDictEqual( + self.cache.CACHE[self.session_id][Cache.session_details], + self.session_details, + ) + + def test_get_cache_invalid_args(self): + with self.assertRaises(Exception) as e: + self.cache.get_cache(123, 123) + self.assertEqual(str(e.exception), "Argument session_id should be string") + + with self.assertRaises(Exception) as e: + self.cache.get_cache(self.session_id, 123) + self.assertEqual(str(e.exception), "Argument property should be string") + + @patch.object(Cache, "cleanup_cache") + def test_get_cache_success(self, mock_cleanup_cache): + session_details = self.cache.get_cache(self.session_id, Cache.session_details) + self.assertDictEqual(session_details, self.session_details) + mock_cleanup_cache.assert_called() + + @patch("percy.cache.Cache.CACHE_TIMEOUT", 1) + def test_cleanup_cache(self): + cache_timeout = self.cache.CACHE_TIMEOUT + time.sleep(cache_timeout + 1) + self.assertIn(self.session_id, self.cache.CACHE) + self.assertIn("key-1", self.cache.CACHE[self.session_id]) + self.cache.cleanup_cache() + self.assertIn(self.session_id, self.cache.CACHE) + self.assertIn("session_details", self.cache.CACHE[self.session_id]) + self.assertNotIn("key-1", self.cache.CACHE[self.session_id]) diff --git a/tests/test_page_metadata.py b/tests/test_page_metadata.py new file mode 100644 index 0000000..e0024f7 --- /dev/null +++ b/tests/test_page_metadata.py @@ -0,0 +1,51 @@ +# pylint: disable=[abstract-class-instantiated, arguments-differ, protected-access] +import json +import unittest +from unittest.mock import MagicMock, patch +from percy.cache import Cache +from percy.page_metadata import PageMetaData + + +class TestPageMetaData(unittest.TestCase): + @patch("percy.cache.Cache.get_cache") + @patch("percy.cache.Cache.set_cache") + def test_page_metadata(self, mock_set_cache, mock_get_cache): + # Mock the page and its properties + page = MagicMock() + page._impl_obj._guid = "page-guid" + page.main_frame._impl_obj._guid = "frame-guid" + page.context.browser._impl_obj._guid = "browser-guid" + page.evaluate.return_value = json.dumps({"hashed_id": "session-id"}) + + # Set up the mocks + mock_get_cache.return_value = None + + # Create an instance of PageMetaData + page_metadata = PageMetaData(page) + + # Test framework property + self.assertEqual(page_metadata.framework, "playwright") + + # Test page_guid property + self.assertEqual(page_metadata.page_guid, "page-guid") + + # Test frame_guid property + self.assertEqual(page_metadata.frame_guid, "frame-guid") + + # Test browser_guid property + self.assertEqual(page_metadata.browser_guid, "browser-guid") + + # Test session_details property when cache is empty + self.assertEqual(page_metadata.session_details, {"hashed_id": "session-id"}) + mock_set_cache.assert_called_once_with( + "browser-guid", Cache.session_details, {"hashed_id": "session-id"} + ) + + # Test session_details property when cache is not empty + mock_get_cache.return_value = {"hashed_id": "cached-session-id"} + self.assertEqual( + page_metadata.session_details, {"hashed_id": "cached-session-id"} + ) + + # Test automate_session_id property + self.assertEqual(page_metadata.automate_session_id, "cached-session-id") diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 0000000..d6580f1 --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,126 @@ +# pylint: disable=[abstract-class-instantiated, arguments-differ, protected-access] +import json +import unittest +import platform +from unittest.mock import patch, MagicMock +from playwright._repo_version import version as PLAYWRIGHT_VERSION +from percy.version import __version__ as SDK_VERSION +from percy.screenshot import ( + is_percy_enabled, + fetch_percy_dom, + percy_snapshot, + percy_automate_screenshot, +) + + +class TestPercyFunctions(unittest.TestCase): + @patch("requests.get") + def test_is_percy_enabled(self, mock_get): + # Mock successful health check + mock_get.return_value.status_code = 200 + mock_get.return_value.json.return_value = {"success": True, "type": "web"} + mock_get.return_value.headers = {"x-percy-core-version": "1.0.0"} + + self.assertEqual(is_percy_enabled(), "web") + + # Clear the cache to test the unsuccessful scenario + is_percy_enabled.cache_clear() + + # Mock unsuccessful health check + mock_get.return_value.json.return_value = {"success": False, "error": "error"} + self.assertFalse(is_percy_enabled()) + + @patch("requests.get") + def test_fetch_percy_dom(self, mock_get): + # Mock successful fetch of dom.js + mock_get.return_value.status_code = 200 + mock_get.return_value.text = "some_js_code" + + self.assertEqual(fetch_percy_dom(), "some_js_code") + + @patch("requests.post") + @patch("percy.screenshot.fetch_percy_dom") + @patch("percy.screenshot.is_percy_enabled") + def test_percy_snapshot( + self, mock_is_percy_enabled, mock_fetch_percy_dom, mock_post + ): + # Mock Percy enabled + mock_is_percy_enabled.return_value = "web" + mock_fetch_percy_dom.return_value = "some_js_code" + page = MagicMock() + page.evaluate.side_effect = [ + "dom_snapshot", + json.dumps({"hashed_id": "session-id"}), + ] + page.url = "http://example.com" + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "success": True, + "data": "snapshot_data", + } + + # Call the function + result = percy_snapshot(page, "snapshot_name") + + # Check the results + self.assertEqual(result, "snapshot_data") + mock_post.assert_called_once() + + @patch("requests.post") + @patch("percy.screenshot.is_percy_enabled") + def test_percy_automate_screenshot(self, mock_is_percy_enabled, mock_post): + # Mock Percy enabled for automate + is_percy_enabled.cache_clear() + mock_is_percy_enabled.return_value = "automate" + page = MagicMock() + + page._impl_obj._guid = "page@abc" + page.main_frame._impl_obj._guid = "frame@abc" + page.context.browser._impl_obj._guid = "browser@abc" + page.evaluate.return_value = '{"hashed_id": "session_id"}' + + # Mock the response for the POST request + mock_post.return_value.status_code = 200 + mock_post.return_value.json.return_value = { + "success": True, + "data": "screenshot_data", + } + + # Call the function + result = percy_automate_screenshot(page, "screenshot_name") + + # Assertions + self.assertEqual(result, "screenshot_data") + mock_post.assert_called_once_with( + "http://localhost:5338/percy/automateScreenshot", + json={ + "client_info": f"percy-playwright-python/{SDK_VERSION}", + "environment_info": [ + f"playwright/{PLAYWRIGHT_VERSION}", + f"python/{platform.python_version()}", + ], + "sessionId": "session_id", + "pageGuid": "page@abc", + "frameGuid": "frame@abc", + "framework": "playwright", + "snapshotName": "screenshot_name", + "options": {}, + }, + timeout=600, + ) + + @patch("percy.screenshot.is_percy_enabled") + def test_percy_automate_screenshot_invalid_call(self, mock_is_percy_enabled): + # Mock Percy enabled for web + mock_is_percy_enabled.return_value = "web" + page = MagicMock() + + # Call the function and expect an exception + with self.assertRaises(Exception) as context: + percy_automate_screenshot(page, "screenshot_name") + + self.assertTrue("Invalid function call" in str(context.exception)) + + +if __name__ == "__main__": + unittest.main()