From 0fd2c044a660c5659fefdeb5b127be15515c65c9 Mon Sep 17 00:00:00 2001 From: amit3200 Date: Thu, 13 Jun 2024 00:31:36 +0530 Subject: [PATCH 01/11] [Feat]: Percy Playwright Python Support --- .gitignore | 3 + .pylintrc | 14 +++ .python-version | 1 + Makefile | 34 ++++++++ README.md | 89 +++++++++++++++++++ development.txt | 3 + percy/__init__.py | 22 +++++ percy/cache.py | 42 +++++++++ percy/page_metadata.py | 44 ++++++++++ percy/screenshot.py | 168 ++++++++++++++++++++++++++++++++++++ percy/version.py | 1 + requirements.txt | 2 + setup.py | 41 +++++++++ tests/test_cache.py | 63 ++++++++++++++ tests/test_page_metadata.py | 51 +++++++++++ tests/test_screenshot.py | 126 +++++++++++++++++++++++++++ 16 files changed, 704 insertions(+) create mode 100644 .pylintrc create mode 100644 .python-version create mode 100644 Makefile create mode 100644 development.txt create mode 100644 percy/__init__.py create mode 100644 percy/cache.py create mode 100644 percy/page_metadata.py create mode 100644 percy/screenshot.py create mode 100644 percy/version.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_page_metadata.py create mode 100644 tests/test_screenshot.py 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() From fe6374bc89c1457d8fc0c4314a7ebbcf6f8c181e Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 13:45:35 +0530 Subject: [PATCH 02/11] Adding github actions --- .github/ISSUE_TEMPLATE/bug_report.md | 55 ++++++++++++++++++++++++ .github/dependabot.yml | 18 ++++++++ .github/release-drafter.yml | 28 ++++++++++++ .github/workflows/Semgrep.yml | 48 +++++++++++++++++++++ .github/workflows/changelog.yml | 11 +++++ .github/workflows/lint.yml | 17 ++++++++ .github/workflows/release.yml | 20 +++++++++ .github/workflows/stale.yml | 31 ++++++++++++++ .github/workflows/test.yml | 64 ++++++++++++++++++++++++++++ CODEOWNERS | 1 + Makefile | 4 +- 11 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/dependabot.yml create mode 100644 .github/release-drafter.yml create mode 100644 .github/workflows/Semgrep.yml create mode 100644 .github/workflows/changelog.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/test.yml create mode 100644 CODEOWNERS diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..789a142 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,55 @@ +--- +name: Bug report +about: Create a report to help us fix the issue +title: '' +labels: '' +assignees: '' +--- + + + +## The problem + +Briefly describe the issue you are experiencing (or the feature you want to see +added to Percy). Tell us what you were trying to do and what happened +instead. Remember, this is _not_ a place to ask questions. For that, go to +https://github.com/percy/cli/discussions/new + +## Environment + +- Node version: +- `@percy/cli` version: +- Version of Percy SDK you’re using: +- If needed, a build or snapshot ID: +- OS version: +- Type of shell command-line [interface]: + +## Details + +If necessary, describe the problem you have been experiencing in more detail. + +## Debug logs + +If you are reporting a bug, _always_ include logs! [Give the "Debugging SDKs" +document a quick read for how to gather logs](https://docs.percy.io/docs/debugging-sdks#debugging-sdks) + +Please do not trim or edit these logs, often times there are hints in the full +logs that help debug what is going on. + +## Code to reproduce issue + +Given the nature of testing/environment bugs, it’s best to try and isolate the +issue in a reproducible repo. This will make it much easier for us to diagnose +and fix. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ba4391f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: +- package-ecosystem: pip + directory: / + labels: + - ⬆️⬇️ dependencies + schedule: + interval: weekly + commit-message: + prefix: ⬆️ +- package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ⬆️👷 + labels: + - ⬆️⬇️ dependencies diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 0000000..05df3e2 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,28 @@ +name-template: 'v$RESOLVED_VERSION' +tag-template: 'v$RESOLVED_VERSION' +categories: + - title: '💥 Breaking Changes' + labels: + - 💥 breaking + - title: '✨ Enhancements' + labels: + - ✨ enhancement + - title: '🐛 Bug Fixes' + labels: + - 🐛 bug + - title: '🏗 Maintenance' + labels: + - 🧹 maintenance + - title: '⬆️⬇️ Dependency Updates' + labels: + - ⬆️⬇️ dependencies +change-title-escapes: '\<*_&#@' +version-resolver: + major: + labels: + - 💥 breaking + minor: + labels: + - ✨ enhancement + default: patch +template: '$CHANGES' diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml new file mode 100644 index 0000000..0347afd --- /dev/null +++ b/.github/workflows/Semgrep.yml @@ -0,0 +1,48 @@ +# Name of this GitHub Actions workflow. +name: Semgrep + +on: + # Scan changed files in PRs (diff-aware scanning): + # The branches below must be a subset of the branches above + pull_request: + branches: ["master", "main"] + push: + branches: ["master", "main"] + schedule: + - cron: '0 6 * * *' + + +permissions: + contents: read + +jobs: + semgrep: + # User definable name of this GitHub Actions job. + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + name: semgrep/ci + # If you are self-hosting, change the following `runs-on` value: + runs-on: ubuntu-latest + + container: + # A Docker image with Semgrep installed. Do not change this. + image: returntocorp/semgrep + + # Skip any PR created by dependabot to avoid permission issues: + if: (github.actor != 'dependabot[bot]') + + steps: + # Fetch project source with GitHub Actions Checkout. + - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + # Run the "semgrep ci" command on the command line of the docker image. + - run: semgrep ci --sarif --output=semgrep.sarif + env: + # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. + SEMGREP_RULES: p/default # more at semgrep.dev/explore + + - name: Upload SARIF file for GitHub Advanced Security Dashboard + uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 + with: + sarif_file: semgrep.sarif + if: always() \ No newline at end of file diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..ef80779 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,11 @@ +name: Changelog +on: + push: + branches: [master] +jobs: + update_draft: + runs-on: ubuntu-latest + steps: + - uses: release-drafter/release-drafter@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..a180105 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: Lint +on: push +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.11 + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: v1/${{ runner.os }}/pip/${{ hashFiles('{requirements,development}.txt') }} + restore-keys: v1/${{ runner.os }}/pip/ + - run: make lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1a6984c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,20 @@ +name: Release +on: + release: + types: [published] +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: v1/${{ runner.os }}/pip/${{ hashFiles('{requirements,development}.txt') }} + restore-keys: v1/${{ runner.os }}/pip/ + - run: make release + env: + PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..73d71d3 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,31 @@ +name: 'Close stale issues and PRs' +on: + schedule: + - cron: '0 19 * * 2' + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v8 + with: + stale-issue-message: >- + This issue is stale because it has been open for more than 14 days with no activity. + Remove stale label or comment or this will be closed in 14 days. + stale-pr-message: >- + This PR is stale because it has been open for more than 14 days with no activity. + Remove stale label or comment or this will be closed in 14 days. + close-issue-message: >- + This issue was closed because it has been stalled for 28 days with no activity. + close-pr-message: >- + This PR was closed because it has been stalled for 28 days with no activity. + days-before-issue-stale: 14 + days-before-pr-stale: 14 + # close 14 days _after_ initial warning + days-before-issue-close: 14 + days-before-pr-close: 14 + exempt-pr-labels: '❄️ on ice' + exempt-issue-labels: '🐛 bug,❄️ on ice,✨ enhancement' + exempt-all-assignees: true + stale-pr-label: '🍞 stale' + stale-issue-label: '🍞 stale' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fc51017 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,64 @@ +name: Test +on: + push: + workflow_dispatch: + inputs: + branch: + required: false + type: string + default: master + +jobs: + test: + name: Test + strategy: + matrix: + python: [3.6, 3.7, 3.8, 3.9] + runs-on: ${{ matrix.python == 3.6 && 'ubuntu-20.04' || 'ubuntu-latest' }} + + steps: + - uses: actions-ecosystem/action-regex-match@v2 + id: regex-match + if: ${{ github.event_name == 'workflow_dispatch' }} + with: + text: ${{ github.event.inputs.branch }} + regex: '^[a-zA-Z0-9_/\-]+$' + - name: Break on invalid branch name + run: exit 1 + if: ${{ github.event_name == 'workflow_dispatch' && steps.regex-match.outputs && steps.regex-match.outputs.match == '' }} + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + - uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: v1/${{ runner.os }}/pypi-${{matrix.python}}/${{ hashFiles('{requirements,development}.txt') }} + restore-keys: v1/${{ runner.os }}/pypi-${matrix.python}/ + - uses: actions/setup-node@v3 + with: + node-version: 16 + - name: Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "::set-output name=dir::$(yarn cache dir)" + - uses: actions/cache@v3 + with: + path: ${{ steps.yarn-cache-dir-path.outputs.dir }} + key: v1/${{ runner.os }}/node-${{ matrix.node }}/${{ hashFiles('**/yarn.lock') }} + restore-keys: v1/${{ runner.os }}/node-${{ matrix.node }}/ + - name: Set up @percy/cli from git + if: ${{ github.event_name == 'workflow_dispatch' }} + run: | + cd /tmp + git clone --branch ${{ github.event.inputs.branch }} --depth 1 https://github.com/percy/cli + cd cli + PERCY_PACKAGES=`find packages -mindepth 1 -maxdepth 1 -type d | sed -e 's/packages/@percy/g' | tr '\n' ' '` + git log -1 + yarn + yarn build + yarn global:link + cd ${{ github.workspace }} + yarn remove @percy/cli && yarn link `echo $PERCY_PACKAGES` + npx percy --version + + - run: make test diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..d45e79e --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @percy/percy-product-reviewers diff --git a/Makefile b/Makefile index 8acae82..be5cde8 100644 --- a/Makefile +++ b/Makefile @@ -30,5 +30,5 @@ clean: build: venv $(VENV)/python setup.py sdist bdist_wheel -# release: build -# $(VENV)/twine upload dist/* --username __token__ --password ${PYPI_TOKEN} +release: build + $(VENV)/twine upload dist/* --username __token__ --password ${PYPI_TOKEN} From 2f8e041a7cb26011df9dfd3d4c3ac04360ff3b58 Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 18:12:35 +0530 Subject: [PATCH 03/11] Updating README --- README.md | 64 +++++++++++++++++++++++++++++++++++++++++------ percy/__init__.py | 4 +-- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e564137..40b5c57 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ 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') +with sync_playwright() as playwright: + browser = playwright.chromium.connect() + page = browser.new_page() + page.goto('http://example.com') + ​ + # take a snapshot + percy_snapshot(browser, 'Python example') ``` Running the test above normally will result in the following log: @@ -54,7 +56,7 @@ $ percy exec -- [python test command] ## Configuration -`percy_snapshot(driver, name[, **kwargs])` +`percy_snapshot(page, name[, **kwargs])` - `page` (**required**) - A playwright page instance - `name` (**required**) - The snapshot name; must be unique to each snapshot @@ -81,10 +83,58 @@ desired_cap = { } 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") ``` +# take a snapshot +percy_screenshot(page, name = 'Screenshot 1') +``` + +- `page` (**required**) - A Playwright page instance +- `name` (**required**) - The screenshot name; must be unique to each screenshot +- `options` (**optional**) - There are various options supported by percy_screenshot to server further functionality. + - `sync` - Boolean value by default it falls back to `false`, Gives the processed result around screenshot [From CLI v1.28.9-beta.0+] + - `full_page` - Boolean value by default it falls back to `false`, Takes full page screenshot [From CLI v1.28.9-beta.0+] + - `freeze_animated_image` - Boolean value by default it falls back to `false`, you can pass `true` and percy will freeze image based animations. + - `freeze_image_by_selectors` -List of selectors. Images will be freezed which are passed using selectors. For this to work `freeze_animated_image` must be set to true. + - `freeze_image_by_xpaths` - List of xpaths. Images will be freezed which are passed using xpaths. For this to work `freeze_animated_image` must be set to true. + - `percy_css` - Custom CSS to be added to DOM before the screenshot being taken. Note: This gets removed once the screenshot is taken. + - `ignore_region_xpaths` - List of xpaths. elements in the DOM can be ignored using xpath + - `ignore_region_selectors` - List of selectors. elements in the DOM can be ignored using selectors. + - `custom_ignore_regions` - List of custom objects. elements can be ignored using custom boundaries. Just passing a simple object for it like below. + - example: ```{"top": 10, "right": 10, "bottom": 120, "left": 10}``` + - In above example it will draw rectangle of ignore region as per given coordinates. + - `top` (int): Top coordinate of the ignore region. + - `bottom` (int): Bottom coordinate of the ignore region. + - `left` (int): Left coordinate of the ignore region. + - `right` (int): Right coordinate of the ignore region. + - `consider_region_xpaths` - List of xpaths. elements in the DOM can be considered for diffing and will be ignored by Intelli Ignore using xpaths. + - `consider_region_selectors` - List of selectors. elements in the DOM can be considered for diffing and will be ignored by Intelli Ignore using selectors. + - `custom_consider_regions` - List of custom objects. elements can be considered for diffing and will be ignored by Intelli Ignore using custom boundaries + - example:```{"top": 10, "right": 10, "bottom": 120, "left": 10}``` + - In above example it will draw rectangle of consider region will be drawn. + - Parameters: + - `top` (int): Top coordinate of the consider region. + - `bottom` (int): Bottom coordinate of the consider region. + - `left` (int): Left coordinate of the consider region. + - `right` (int): Right coordinate of the consider region. + + +### Creating Percy on automate build +Note: Automate Percy Token starts with `auto` keyword. The command can be triggered using `exec` keyword. + +```sh-session +$ export PERCY_TOKEN=[your-project-token] +$ percy exec -- [python test command] +[percy] Percy has started! +[percy] [Python example] : Starting automate screenshot ... +[percy] Screenshot taken "Python example" +[percy] Stopping percy... +[percy] Finalized build #1: https://percy.io/[your-project] +[percy] Done! +``` + +Refer to docs here: [Percy on Automate](https://docs.percy.io/docs/integrate-functional-testing-with-visual-testing) diff --git a/percy/__init__.py b/percy/__init__.py index 71b0e35..cd25f51 100644 --- a/percy/__init__.py +++ b/percy/__init__.py @@ -6,7 +6,7 @@ from percy.screenshot import percy_snapshot except ImportError: - def percy_snapshot(driver, *a, **kw): + def percy_snapshot(page, *a, **kw): raise ModuleNotFoundError( "[percy] `percy-playwright-python` package is not installed, " "please install it to use percy_snapshot command" @@ -15,7 +15,7 @@ def percy_snapshot(driver, *a, **kw): # for better backwards compatibility def percySnapshot(browser, *a, **kw): - return percy_snapshot(driver=browser, *a, **kw) + return percy_snapshot(page=browser, *a, **kw) def percy_screenshot(page, *a, **kw): From a3f40d56a8065b22198d0274af122f8d59425797 Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 18:13:41 +0530 Subject: [PATCH 04/11] Updating semgrep --- .github/workflows/Semgrep.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml index 0347afd..0c66a1e 100644 --- a/.github/workflows/Semgrep.yml +++ b/.github/workflows/Semgrep.yml @@ -45,4 +45,4 @@ jobs: uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 with: sarif_file: semgrep.sarif - if: always() \ No newline at end of file + if: always() From 6147084f7e4523fe8dfaac5161c0714ae585ed0d Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 19:31:15 +0530 Subject: [PATCH 05/11] Making Test Changes --- .gitignore | 1 + Makefile | 5 +- package.json | 9 ++ percy/screenshot.py | 2 +- tests/test_screenshot.py | 213 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 3 deletions(-) create mode 100644 package.json diff --git a/.gitignore b/.gitignore index d5727bc..317e10e 100644 --- a/.gitignore +++ b/.gitignore @@ -161,5 +161,6 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ +package-lock.json node_modules yarn.lock diff --git a/Makefile b/Makefile index be5cde8..e109a00 100644 --- a/Makefile +++ b/Makefile @@ -7,12 +7,13 @@ $(VENV): python3 -m venv .venv $(VENV)/python -m pip install --upgrade pip setuptools wheel yarn + playwright install $(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 clean build release venv: $(VENV)/$(MARKER) @@ -20,7 +21,7 @@ lint: venv $(VENV)/pylint percy/* tests/* test: venv - $(VENV)/python -m unittest tests.test_screenshot + npx percy exec --testing -- $(VENV)/python -m unittest tests.test_screenshot $(VENV)/python -m unittest tests.test_cache $(VENV)/python -m unittest tests.test_page_metadata diff --git a/package.json b/package.json new file mode 100644 index 0000000..4237978 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "scripts": { + "test": "make test" + }, + "devDependencies": { + "@percy/cli": "1.28.7" + } +} diff --git a/percy/screenshot.py b/percy/screenshot.py index 9d3add9..39b894d 100644 --- a/percy/screenshot.py +++ b/percy/screenshot.py @@ -70,7 +70,7 @@ def percy_snapshot(page, name, **kwargs): if session_type == "automate": raise Exception( "Invalid function call - " - "percy_snapshot()." + "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" diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index d6580f1..15ebe80 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -2,7 +2,13 @@ import json import unittest import platform +from threading import Thread +import httpretty +from http.server import BaseHTTPRequestHandler, HTTPServer +import requests from unittest.mock import patch, MagicMock + +from playwright.sync_api import sync_playwright from playwright._repo_version import version as PLAYWRIGHT_VERSION from percy.version import __version__ as SDK_VERSION from percy.screenshot import ( @@ -11,7 +17,214 @@ percy_snapshot, percy_automate_screenshot, ) +import percy.screenshot as local +LABEL = local.LABEL + +# mock a simple webpage to snapshot +class MockServerRequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + self.send_response(200) + self.send_header('Content-Type', 'text/html') + self.end_headers() + self.wfile.write(('Snapshot Me').encode('utf-8')) + def log_message(self, format, *args): + return + +# daemon threads automatically shut down when the main process exits +mock_server = HTTPServer(('localhost', 8000), MockServerRequestHandler) +mock_server_thread = Thread(target=mock_server.serve_forever) +mock_server_thread.daemon = True +mock_server_thread.start() + +# initializing mock data +data_object = {"sync": "true", "diff": 0} + + +# mock helpers +def mock_healthcheck(fail=False, fail_how='error', session_type=None): + health_body = { "success": True } + health_headers = { 'X-Percy-Core-Version': '1.0.0' } + health_status = 200 + + if fail and fail_how == 'error': + health_body = { "success": False, "error": "test" } + health_status = 500 + elif fail and fail_how == 'wrong-version': + health_headers = { 'X-Percy-Core-Version': '2.0.0' } + elif fail and fail_how == 'no-version': + health_headers = {} + + if session_type: + health_body["type"] = session_type + + health_body = json.dumps(health_body) + httpretty.register_uri( + httpretty.GET, 'http://localhost:5338/percy/healthcheck', + body=health_body, + adding_headers=health_headers, + status=health_status) + httpretty.register_uri( + httpretty.GET, 'http://localhost:5338/percy/dom.js', + body='window.PercyDOM = { serialize: () => document.documentElement.outerHTML };', + status=200) + +def mock_snapshot(fail=False, data=False): + httpretty.register_uri( + httpretty.POST, 'http://localhost:5338/percy/snapshot', + body = json.dumps({ + "success": "false" if fail else "true", + "error": "test" if fail else None, + "data": data_object if data else None + }), + status=(500 if fail else 200)) + + + +class TestPercySnapshot(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.p = sync_playwright().start() + # Launch the browser + cls.browser = cls.p.chromium.launch(headless=False) # Set headless=True if you don't want to see the browser + context = cls.browser.new_context() + cls.page = context.new_page() + + @classmethod + def tearDownClass(cls): + cls.browser.close() + cls.p.stop() + + def setUp(self): + # clear the cached value for testing + local.is_percy_enabled.cache_clear() + local.fetch_percy_dom.cache_clear() + self.page.goto('http://localhost:8000') + httpretty.enable() + + def tearDown(self): + httpretty.disable() + httpretty.reset() + + def test_throws_error_when_a_page_is_not_provided(self): + with self.assertRaises(Exception): + percy_snapshot() + + def test_throws_error_when_a_name_is_not_provided(self): + with self.assertRaises(Exception): + percy_snapshot(self.page) + + def test_disables_snapshots_when_the_healthcheck_fails(self): + mock_healthcheck(fail=True) + + with patch('builtins.print') as mock_print: + percy_snapshot(self.page, 'Snapshot 1') + percy_snapshot(self.page, 'Snapshot 2') + + mock_print.assert_called_with(f'{LABEL} Percy is not running, disabling snapshots') + + self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + + def test_disables_snapshots_when_the_healthcheck_version_is_wrong(self): + mock_healthcheck(fail=True, fail_how='wrong-version') + + with patch('builtins.print') as mock_print: + percy_snapshot(self.page, 'Snapshot 1') + percy_snapshot(self.page, 'Snapshot 2') + + mock_print.assert_called_with(f'{LABEL} Unsupported Percy CLI version, 2.0.0') + + self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + + def test_disables_snapshots_when_the_healthcheck_version_is_missing(self): + mock_healthcheck(fail=True, fail_how='no-version') + + with patch('builtins.print') as mock_print: + percy_snapshot(self.page, 'Snapshot 1') + percy_snapshot(self.page, 'Snapshot 2') + + mock_print.assert_called_with( + 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') + + self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + + def test_posts_snapshots_to_the_local_percy_server(self): + mock_healthcheck() + mock_snapshot() + + percy_snapshot(self.page, 'Snapshot 1') + response = percy_snapshot(self.page, 'Snapshot 2', enable_javascript=True) + + self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + + s1 = httpretty.latest_requests()[2].parsed_body + self.assertEqual(s1['name'], 'Snapshot 1') + self.assertEqual(s1['url'], 'http://localhost:8000/') + self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') + self.assertRegex(s1['client_info'], r'percy-playwright-python/\d+') + self.assertRegex(s1['environment_info'][0], r'playwright/\d+') + self.assertRegex(s1['environment_info'][1], r'python/\d+') + + s2 = httpretty.latest_requests()[3].parsed_body + self.assertEqual(s2['name'], 'Snapshot 2') + self.assertEqual(s2['enable_javascript'], True) + self.assertEqual(response, None) + + def test_posts_snapshots_to_the_local_percy_server_for_sync(self): + mock_healthcheck() + mock_snapshot(False, True) + + percy_snapshot(self.page, 'Snapshot 1') + response = percy_snapshot(self.page, 'Snapshot 2', enable_javascript=True, sync=True) + + self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + + s1 = httpretty.latest_requests()[2].parsed_body + self.assertEqual(s1['name'], 'Snapshot 1') + self.assertEqual(s1['url'], 'http://localhost:8000/') + self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') + self.assertRegex(s1['client_info'], r'percy-playwright-python/\d+') + self.assertRegex(s1['environment_info'][0], r'playwright/\d+') + self.assertRegex(s1['environment_info'][1], r'python/\d+') + + s2 = httpretty.latest_requests()[3].parsed_body + self.assertEqual(s2['name'], 'Snapshot 2') + self.assertEqual(s2['enable_javascript'], True) + self.assertEqual(s2['sync'], True) + self.assertEqual(response, data_object) + + + mock_healthcheck() + mock_snapshot() + + percy_snapshot(self.page, 'Snapshot') + + self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + + s1 = httpretty.latest_requests()[-1].parsed_body + self.assertEqual(s1['name'], 'Snapshot') + self.assertEqual(s1['url'], 'http://localhost:8000/') + self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') + def test_handles_snapshot_errors(self): + mock_healthcheck(session_type="web") + mock_snapshot(fail=True) + + with patch('builtins.print') as mock_print: + percy_snapshot(self.page, 'Snapshot 1') + + mock_print.assert_any_call(f'{LABEL} Could not take DOM snapshot "Snapshot 1"') + + def test_raise_error_poa_token_with_snapshot(self): + mock_healthcheck(session_type="automate") + + with self.assertRaises(Exception) as context: + percy_snapshot(self.page, "Snapshot 1") + self.assertEqual("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", str(context.exception)) class TestPercyFunctions(unittest.TestCase): @patch("requests.get") From aeefe6bffe346e9db34dc9141bfa5743459c5e38 Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 19:51:31 +0530 Subject: [PATCH 06/11] update tests --- tests/test_screenshot.py | 195 +++++++++++++++++++++++---------------- 1 file changed, 114 insertions(+), 81 deletions(-) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 15ebe80..06300bd 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -18,20 +18,24 @@ percy_automate_screenshot, ) import percy.screenshot as local + LABEL = local.LABEL + # mock a simple webpage to snapshot class MockServerRequestHandler(BaseHTTPRequestHandler): def do_GET(self): self.send_response(200) - self.send_header('Content-Type', 'text/html') + self.send_header("Content-Type", "text/html") self.end_headers() - self.wfile.write(('Snapshot Me').encode('utf-8')) + self.wfile.write(("Snapshot Me").encode("utf-8")) + def log_message(self, format, *args): return + # daemon threads automatically shut down when the main process exits -mock_server = HTTPServer(('localhost', 8000), MockServerRequestHandler) +mock_server = HTTPServer(("localhost", 8000), MockServerRequestHandler) mock_server_thread = Thread(target=mock_server.serve_forever) mock_server_thread.daemon = True mock_server_thread.start() @@ -41,17 +45,17 @@ def log_message(self, format, *args): # mock helpers -def mock_healthcheck(fail=False, fail_how='error', session_type=None): - health_body = { "success": True } - health_headers = { 'X-Percy-Core-Version': '1.0.0' } +def mock_healthcheck(fail=False, fail_how="error", session_type=None): + health_body = {"success": True} + health_headers = {"X-Percy-Core-Version": "1.0.0"} health_status = 200 - if fail and fail_how == 'error': - health_body = { "success": False, "error": "test" } + if fail and fail_how == "error": + health_body = {"success": False, "error": "test"} health_status = 500 - elif fail and fail_how == 'wrong-version': - health_headers = { 'X-Percy-Core-Version': '2.0.0' } - elif fail and fail_how == 'no-version': + elif fail and fail_how == "wrong-version": + health_headers = {"X-Percy-Core-Version": "2.0.0"} + elif fail and fail_how == "no-version": health_headers = {} if session_type: @@ -59,25 +63,33 @@ def mock_healthcheck(fail=False, fail_how='error', session_type=None): health_body = json.dumps(health_body) httpretty.register_uri( - httpretty.GET, 'http://localhost:5338/percy/healthcheck', + httpretty.GET, + "http://localhost:5338/percy/healthcheck", body=health_body, adding_headers=health_headers, - status=health_status) + status=health_status, + ) httpretty.register_uri( - httpretty.GET, 'http://localhost:5338/percy/dom.js', - body='window.PercyDOM = { serialize: () => document.documentElement.outerHTML };', - status=200) + httpretty.GET, + "http://localhost:5338/percy/dom.js", + body="window.PercyDOM = { serialize: () => document.documentElement.outerHTML };", + status=200, + ) + def mock_snapshot(fail=False, data=False): httpretty.register_uri( - httpretty.POST, 'http://localhost:5338/percy/snapshot', - body = json.dumps({ - "success": "false" if fail else "true", - "error": "test" if fail else None, - "data": data_object if data else None - }), - status=(500 if fail else 200)) - + httpretty.POST, + "http://localhost:5338/percy/snapshot", + body=json.dumps( + { + "success": "false" if fail else "true", + "error": "test" if fail else None, + "data": data_object if data else None, + } + ), + status=(500 if fail else 200), + ) class TestPercySnapshot(unittest.TestCase): @@ -85,7 +97,9 @@ class TestPercySnapshot(unittest.TestCase): def setUpClass(cls): cls.p = sync_playwright().start() # Launch the browser - cls.browser = cls.p.chromium.launch(headless=False) # Set headless=True if you don't want to see the browser + cls.browser = cls.p.chromium.launch( + headless=False + ) # Set headless=True if you don't want to see the browser context = cls.browser.new_context() cls.page = context.new_page() @@ -98,7 +112,7 @@ def setUp(self): # clear the cached value for testing local.is_percy_enabled.cache_clear() local.fetch_percy_dom.cache_clear() - self.page.goto('http://localhost:8000') + self.page.goto("http://localhost:8000") httpretty.enable() def tearDown(self): @@ -116,104 +130,119 @@ def test_throws_error_when_a_name_is_not_provided(self): def test_disables_snapshots_when_the_healthcheck_fails(self): mock_healthcheck(fail=True) - with patch('builtins.print') as mock_print: - percy_snapshot(self.page, 'Snapshot 1') - percy_snapshot(self.page, 'Snapshot 2') + with patch("builtins.print") as mock_print: + percy_snapshot(self.page, "Snapshot 1") + percy_snapshot(self.page, "Snapshot 2") - mock_print.assert_called_with(f'{LABEL} Percy is not running, disabling snapshots') + mock_print.assert_called_with( + f"{LABEL} Percy is not running, disabling snapshots" + ) - self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + self.assertEqual(httpretty.last_request().path, "/percy/healthcheck") def test_disables_snapshots_when_the_healthcheck_version_is_wrong(self): - mock_healthcheck(fail=True, fail_how='wrong-version') + mock_healthcheck(fail=True, fail_how="wrong-version") - with patch('builtins.print') as mock_print: - percy_snapshot(self.page, 'Snapshot 1') - percy_snapshot(self.page, 'Snapshot 2') + with patch("builtins.print") as mock_print: + percy_snapshot(self.page, "Snapshot 1") + percy_snapshot(self.page, "Snapshot 2") - mock_print.assert_called_with(f'{LABEL} Unsupported Percy CLI version, 2.0.0') + mock_print.assert_called_with( + f"{LABEL} Unsupported Percy CLI version, 2.0.0" + ) - self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + self.assertEqual(httpretty.last_request().path, "/percy/healthcheck") def test_disables_snapshots_when_the_healthcheck_version_is_missing(self): - mock_healthcheck(fail=True, fail_how='no-version') + mock_healthcheck(fail=True, fail_how="no-version") - with patch('builtins.print') as mock_print: - percy_snapshot(self.page, 'Snapshot 1') - percy_snapshot(self.page, 'Snapshot 2') + with patch("builtins.print") as mock_print: + percy_snapshot(self.page, "Snapshot 1") + percy_snapshot(self.page, "Snapshot 2") mock_print.assert_called_with( - 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') + 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" + ) - self.assertEqual(httpretty.last_request().path, '/percy/healthcheck') + self.assertEqual(httpretty.last_request().path, "/percy/healthcheck") def test_posts_snapshots_to_the_local_percy_server(self): mock_healthcheck() mock_snapshot() - percy_snapshot(self.page, 'Snapshot 1') - response = percy_snapshot(self.page, 'Snapshot 2', enable_javascript=True) + percy_snapshot(self.page, "Snapshot 1") + response = percy_snapshot(self.page, "Snapshot 2", enable_javascript=True) - self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + self.assertEqual(httpretty.last_request().path, "/percy/snapshot") s1 = httpretty.latest_requests()[2].parsed_body - self.assertEqual(s1['name'], 'Snapshot 1') - self.assertEqual(s1['url'], 'http://localhost:8000/') - self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') - self.assertRegex(s1['client_info'], r'percy-playwright-python/\d+') - self.assertRegex(s1['environment_info'][0], r'playwright/\d+') - self.assertRegex(s1['environment_info'][1], r'python/\d+') + self.assertEqual(s1["name"], "Snapshot 1") + self.assertEqual(s1["url"], "http://localhost:8000/") + self.assertEqual( + s1["dom_snapshot"], "Snapshot Me" + ) + self.assertRegex(s1["client_info"], r"percy-playwright-python/\d+") + self.assertRegex(s1["environment_info"][0], r"playwright/\d+") + self.assertRegex(s1["environment_info"][1], r"python/\d+") s2 = httpretty.latest_requests()[3].parsed_body - self.assertEqual(s2['name'], 'Snapshot 2') - self.assertEqual(s2['enable_javascript'], True) + self.assertEqual(s2["name"], "Snapshot 2") + self.assertEqual(s2["enable_javascript"], True) self.assertEqual(response, None) def test_posts_snapshots_to_the_local_percy_server_for_sync(self): mock_healthcheck() mock_snapshot(False, True) - percy_snapshot(self.page, 'Snapshot 1') - response = percy_snapshot(self.page, 'Snapshot 2', enable_javascript=True, sync=True) + percy_snapshot(self.page, "Snapshot 1") + response = percy_snapshot( + self.page, "Snapshot 2", enable_javascript=True, sync=True + ) - self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + self.assertEqual(httpretty.last_request().path, "/percy/snapshot") s1 = httpretty.latest_requests()[2].parsed_body - self.assertEqual(s1['name'], 'Snapshot 1') - self.assertEqual(s1['url'], 'http://localhost:8000/') - self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') - self.assertRegex(s1['client_info'], r'percy-playwright-python/\d+') - self.assertRegex(s1['environment_info'][0], r'playwright/\d+') - self.assertRegex(s1['environment_info'][1], r'python/\d+') + self.assertEqual(s1["name"], "Snapshot 1") + self.assertEqual(s1["url"], "http://localhost:8000/") + self.assertEqual( + s1["dom_snapshot"], "Snapshot Me" + ) + self.assertRegex(s1["client_info"], r"percy-playwright-python/\d+") + self.assertRegex(s1["environment_info"][0], r"playwright/\d+") + self.assertRegex(s1["environment_info"][1], r"python/\d+") s2 = httpretty.latest_requests()[3].parsed_body - self.assertEqual(s2['name'], 'Snapshot 2') - self.assertEqual(s2['enable_javascript'], True) - self.assertEqual(s2['sync'], True) + self.assertEqual(s2["name"], "Snapshot 2") + self.assertEqual(s2["enable_javascript"], True) + self.assertEqual(s2["sync"], True) self.assertEqual(response, data_object) - mock_healthcheck() mock_snapshot() - percy_snapshot(self.page, 'Snapshot') + percy_snapshot(self.page, "Snapshot") - self.assertEqual(httpretty.last_request().path, '/percy/snapshot') + self.assertEqual(httpretty.last_request().path, "/percy/snapshot") s1 = httpretty.latest_requests()[-1].parsed_body - self.assertEqual(s1['name'], 'Snapshot') - self.assertEqual(s1['url'], 'http://localhost:8000/') - self.assertEqual(s1['dom_snapshot'], 'Snapshot Me') + self.assertEqual(s1["name"], "Snapshot") + self.assertEqual(s1["url"], "http://localhost:8000/") + self.assertEqual( + s1["dom_snapshot"], "Snapshot Me" + ) + def test_handles_snapshot_errors(self): mock_healthcheck(session_type="web") mock_snapshot(fail=True) - with patch('builtins.print') as mock_print: - percy_snapshot(self.page, 'Snapshot 1') + with patch("builtins.print") as mock_print: + percy_snapshot(self.page, "Snapshot 1") - mock_print.assert_any_call(f'{LABEL} Could not take DOM snapshot "Snapshot 1"') + mock_print.assert_any_call( + f'{LABEL} Could not take DOM snapshot "Snapshot 1"' + ) def test_raise_error_poa_token_with_snapshot(self): mock_healthcheck(session_type="automate") @@ -221,10 +250,14 @@ def test_raise_error_poa_token_with_snapshot(self): with self.assertRaises(Exception) as context: percy_snapshot(self.page, "Snapshot 1") - self.assertEqual("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", str(context.exception)) + self.assertEqual( + "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", + str(context.exception), + ) + class TestPercyFunctions(unittest.TestCase): @patch("requests.get") From 0f584ae17f5a61e13e77bf5f61648b8fb8f9ab8d Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 19:54:40 +0530 Subject: [PATCH 07/11] adding makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index e109a00..aa77350 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ $(VENV): python3 -m venv .venv $(VENV)/python -m pip install --upgrade pip setuptools wheel yarn - playwright install + python3 -m playwright install $(VENV)/$(MARKER): $(VENVDEPS) | $(VENV) $(VENV)/pip install $(foreach path,$(REQUIREMENTS),-r $(path)) From d809172897c01018f18e5ef2d8a9ce6b9f49a99c Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 19:57:18 +0530 Subject: [PATCH 08/11] Updating Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index aa77350..b65f097 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,10 @@ $(VENV): python3 -m venv .venv $(VENV)/python -m pip install --upgrade pip setuptools wheel yarn - python3 -m playwright install $(VENV)/$(MARKER): $(VENVDEPS) | $(VENV) $(VENV)/pip install $(foreach path,$(REQUIREMENTS),-r $(path)) + $(VENV)/python -m playwright install touch $(VENV)/$(MARKER) .PHONY: venv lint test clean build release From d89e3168f65a95d961a90e798780bd8940df8e9e Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 20:03:15 +0530 Subject: [PATCH 09/11] Updating Lint Statements --- tests/test_screenshot.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 06300bd..3ea0c21 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -3,10 +3,9 @@ import unittest import platform from threading import Thread -import httpretty from http.server import BaseHTTPRequestHandler, HTTPServer -import requests from unittest.mock import patch, MagicMock +import httpretty from playwright.sync_api import sync_playwright from playwright._repo_version import version as PLAYWRIGHT_VERSION @@ -252,7 +251,8 @@ def test_raise_error_poa_token_with_snapshot(self): self.assertEqual( "Invalid function call - " - "percy_snapshot(). Please use percy_screenshot() function while using Percy with Automate." + "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", str(context.exception), From b5576fe99d870943d3f2865e8f569a83290a694c Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 20:07:24 +0530 Subject: [PATCH 10/11] Updating test flow --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fc51017..013b561 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,7 +13,7 @@ jobs: name: Test strategy: matrix: - python: [3.6, 3.7, 3.8, 3.9] + python: [3.7, 3.8, 3.9] runs-on: ${{ matrix.python == 3.6 && 'ubuntu-20.04' || 'ubuntu-latest' }} steps: From 82d95b9b55876838fd2d3f5d125cca7c7d83a2a0 Mon Sep 17 00:00:00 2001 From: amit3200 Date: Fri, 14 Jun 2024 20:09:37 +0530 Subject: [PATCH 11/11] Updating Headless --- tests/test_screenshot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py index 3ea0c21..561803f 100644 --- a/tests/test_screenshot.py +++ b/tests/test_screenshot.py @@ -97,7 +97,7 @@ def setUpClass(cls): cls.p = sync_playwright().start() # Launch the browser cls.browser = cls.p.chromium.launch( - headless=False + headless=True ) # Set headless=True if you don't want to see the browser context = cls.browser.new_context() cls.page = context.new_page()