diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..cbb61af --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +custom: ['https://www.buymeacoffee.com/harmin'] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..5c43234 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +# Required +version: 2 + +# Set the OS, Python version and other tools you might need +build: + os: "ubuntu-22.04" + tools: + python: "3.11" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Explicitly set the version of Python and its requirements +python: + install: + - requirements: docs/requirements.txt + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..344d665 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Harmin Parra Rueda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 971d69c..d503218 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pytest-webtest-extras -pytest plugin to enhance pytest-html reports of webtest projects with screenshots, comments and webpages source code. +Pytest plugin to enhance pytest-html reports of webtest projects with screenshots, comments and webpage sources. ![](https://img.shields.io/badge/license-MIT%202.0-blue.svg) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa86cee --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["flit_core >=3.4"] +build-backend = "flit_core.buildapi" + + +[project] +name = "pytest-webtest-extras" +version = "0.0.1rc1" +description = "Pytest plugin to enhance pytest-html reports of webtest projects with screenshots, comments and webpage sources." +readme = "README.md" +#license = "MIT" +authors = [ + {name = "Harmin Parra Rueda", email="harmin.rueda@gmail.com"}, +] +requires-python = ">=3.8" +dependencies = [ + 'pytest >= 7.0.0', + 'pytest-html >= 4.0.0', +] +classifiers = [ + "Framework :: Pytest", + "License :: OSI Approved :: MIT License", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", +] +keywords = [ + "pytest", + "selenium", + "playwright", + "webtest", + "webtesting", +] + + +[project.entry-points.pytest11] +webtest_extras = "pytest_webtest_extras.plugin" + + +#[project.urls] +#Homepage = "https://pypi.org/project/pytest_webtest_extras" +#Documentation = "https://pytest_webtest_extras.readthedocs.io/en/stable/" +#Source = "https://github.com/harmin-parra/pytest_webtest_extras" diff --git a/src/pytest_webtest_extras/__init__.py b/src/pytest_webtest_extras/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pytest_webtest_extras/extras.py b/src/pytest_webtest_extras/extras.py new file mode 100644 index 0000000..927eb55 --- /dev/null +++ b/src/pytest_webtest_extras/extras.py @@ -0,0 +1,89 @@ +from . import utils + + +# Counter used for image and page source files naming +count = 0 + + +def counter(): + """ Returns a suffix used for image and page source file naming """ + global count + count += 1 + return count + + +class Extras(): + """ + Class to hold pytest-html 'extras' to be added for each test in the HTML report + """ + + def __init__(self, report_folder, fx_screenshots, fx_comments, fx_sources): + self.images = [] + self.sources = [] + self.comments = [] + self._fx_screenshots = fx_screenshots + self._fx_comments = fx_comments + self._fx_sources = fx_sources + self._folder = report_folder + + def save_extras(self, image: bytes, comment=None, source=None): + """ + Saves the pytest-html 'extras': screenshot, comment and webpage source. + The screenshot is saved in /screenshots folder. + The webpage source is saved in /sources folder. + + image (bytes): The screenshot. + comment (str): The comment of the screenshot. + source (str): The webpage source code. + """ + if self._fx_screenshots == 'none': + return + index = counter() + link_image = utils.save_image(self._folder, index, image) + self.images.append(link_image) + link_source = None + if source is not None: + link_source = utils.save_source(self._folder, index, source) + self.sources.append(link_source) + if self._fx_comments: + comment = "" if comment is None else comment + self.comments.append(comment) + + def save_for_selenium(self, driver, comment=None, full_page=True): + """ + Saves the pytest-html 'extras': screenshot, comment and webpage source. + + driver (WebDriver): The webdriver. + comment (str): The comment for the screenshot to take. + full_page (bool): Whether to take a full-page screenshot. + Only works for Firefox. + Defaults to True. + """ + from selenium.webdriver.remote.webdriver import WebDriver + if self._fx_screenshots == 'none': + return + if hasattr(driver, "get_full_page_screenshot_as_png") and full_page: + image = driver.get_full_page_screenshot_as_png() + else: + image = driver.get_screenshot_as_png() + source = None + if self._fx_sources: + source = driver.page_source + self.save_extras(image, comment, source) + + def save_for_playwright(self, page, comment=None, full_page=True): + """ + Saves the pytest-html 'extras': screenshot, comment and webpage source. + + page (Page): The page. + comment (str): The comment for the screenshot to take. + full_page (bool): Whether to take a full-page screenshot. + Defaults to True. + """ + if self._fx_screenshots == 'none': + return + image = page.screenshot(full_page=full_page) + source = None + if self._fx_sources: + source = page.content() + self.save_extras(image, comment, source) diff --git a/src/pytest_webtest_extras/plugin.py b/src/pytest_webtest_extras/plugin.py new file mode 100644 index 0000000..e23fd62 --- /dev/null +++ b/src/pytest_webtest_extras/plugin.py @@ -0,0 +1,169 @@ +import importlib +import os +import pytest +import re +from . import utils +from .extras import Extras + + +# +# Definition of test options +# +def pytest_addoption(parser): + parser.addini( + "extras_screenshots", + type="string", + default="all", + help="The screenshots to include in the report. Accepted values: all, last, none." + ) + parser.addini( + "extras_comments", + type="bool", + default=False, + help="Whether to include comments." + ) + parser.addini( + "extras_sources", + type="bool", + default=False, + help="Whether to include webpage sources." + ) + parser.addini( + "extras_description_tag", + type="string", + default="h2", + help="HTML tag for the test description. Accepted values: h1, h2, h3, p or pre.", + ) + + +# +# Read test parameters +# +@pytest.fixture(scope='session') +def screenshots(request): + value = request.config.getini("extras_screenshots") + if value in ("all", "last", "none"): + return value + else: + return "all" + + +@pytest.fixture(scope='session') +def report_folder(request): + htmlpath = request.config.getoption("--html") + return utils.get_folder(htmlpath) + + +@pytest.fixture(scope='session') +def report_css(request): + return request.config.getoption("--css") + + +@pytest.fixture(scope='session') +def description_tag(request): + tag = request.config.getini("extras_description_tag") + return tag if tag in ("h1", "h2", "h3", "p", "pre") else "h2" + + +@pytest.fixture(scope='session') +def comments(request): + return request.config.getini("extras_comments") + + +@pytest.fixture(scope='session') +def sources(request): + return request.config.getini("extras_sources") + + +@pytest.fixture(scope='session') +def check_options(request, report_folder): + utils.check_html_option(report_folder) + utils.create_assets(report_folder) + + +# +# Test fixture +# +@pytest.fixture(scope='function') +def webtest_extras(request, report_folder, screenshots, comments, sources, check_options): + return Extras(report_folder, screenshots, comments, sources) + + +# +# Hookers +# +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport(item, call): + """ Override report generation. """ + pytest_html = item.config.pluginmanager.getplugin('html') + outcome = yield + report = outcome.get_result() + extras = getattr(report, 'extras', []) + + # Let's deal with the HTML report + if report.when == 'call': + # Get function/method description + pkg = item.location[0].replace(os.sep, '.')[:-3] + index = pkg.rfind('.') + module = importlib.import_module(package=pkg[:index], name=pkg[index + 1:]) + # Is the called test a function ? + match_cls = re.search(r"^[^\[]*\.", item.location[2]) + if match_cls is None: + func = getattr(module, item.originalname) + else: + cls = getattr(module, match_cls[0][:-1]) + func = getattr(cls, item.originalname) + description = getattr(func, "__doc__") + + # Is the test item using the 'extras' fixtures? + if not ("request" in item.funcargs and "webtest_extras" in item.funcargs): + return + feature_request = item.funcargs['request'] + + # Get test fixture values + fx_extras = feature_request.getfixturevalue("webtest_extras") + fx_description_tag = feature_request.getfixturevalue("description_tag") + fx_screenshots = feature_request.getfixturevalue("screenshots") + fx_comments = feature_request.getfixturevalue("comments") + + # Append test description and execution exception trace, if any. + utils.append_header(call, report, extras, pytest_html, description, fx_description_tag) + + if fx_screenshots == "none" or len(fx_extras.images) == 0: + report.extras = extras + return + + if not utils.check_lists_length(report, item, fx_extras): + return + + links = "" + rows = "" + if fx_screenshots == "all": + if not fx_comments: + for i in range(len(fx_extras.images)): + links += utils.decorate_anchors(fx_extras.images[i], fx_extras.sources[i]) + else: + for i in range(len(fx_extras.images)): + rows += utils.get_table_row_tag(fx_extras.comments[i], fx_extras.images[i], fx_extras.sources[i]) + else: # fx_screenshots == "last" + if len(fx_extras.images) > 0: + if not fx_comments: + links = utils.decorate_anchors(fx_extras.images[-1], fx_extras.sources[-1]) + else: + rows += utils.get_table_row_tag(fx_extras.comments[-1], fx_extras.images[-1], fx_extras.sources[-1]) + + # Add horizontal line between the header and the comments/screenshots + if len(extras) > 0 and len(links) + len(rows) > 0: + extras.append(pytest_html.extras.html(f'
')) + + # Append extras + if links != "": + extras.append(pytest_html.extras.html(links)) + if rows != "": + rows = ( + '' + + rows + + "
" + ) + extras.append(pytest_html.extras.html(rows)) + report.extras = extras diff --git a/src/pytest_webtest_extras/utils.py b/src/pytest_webtest_extras/utils.py new file mode 100644 index 0000000..2d062c8 --- /dev/null +++ b/src/pytest_webtest_extras/utils.py @@ -0,0 +1,378 @@ +import os +import pathlib +import pytest +import shutil +import sys +import traceback +from .extras import Extras + + +# +# Auxiliary functions to check options and fixtures +# +def check_html_option(htmlpath): + if htmlpath is None: + msg = ("It seems you are using pytest-selenium-auto plugin.\n" + "pytest-html plugin is required.\n" + "'--html' option is missing.\n") + print(msg, file=sys.stderr) + sys.exit(pytest.ExitCode.USAGE_ERROR) + + +def getini(config, name): + """ Workaround for bug https://github.com/pytest-dev/pytest/issues/11282 """ + value = config.getini(name) + if not isinstance(value, str): + value = None + return value + + +def get_folder(filepath): + """ + Returns the folder of a filepath. + + Args: + filepath (str): The filepath. + """ + folder = None + if filepath is not None: + folder = os.path.dirname(filepath) + return folder + + +def check_lists_length(report, item, fx_extras: Extras): + """ Used to verify if the images, comments and page sources lists have coherent lenghts. """ + message = ('Lists "images", "comments" and "sources" have incoherent lengths. ' + "Screenshots won't be logged for this test.") + max_length = len(fx_extras.images) + max_length = len(fx_extras.comments) if len(fx_extras.comments) > max_length else max_length + max_length = len(fx_extras.sources) if len(fx_extras.sources) > max_length else max_length + if len(fx_extras.images) == max_length: + if ( + (len(fx_extras.comments) == max_length or len(fx_extras.comments) == 0) and + (len(fx_extras.sources) == max_length or len(fx_extras.sources) == 0) + ): + return True + log_error_message(report, item, message) + return False + + +def create_assets(report_folder): + """ Recreate screenshots, page sources and log folders. """ + # Recreate screenshots_folder + folder = "" + if report_folder is not None and report_folder != '': + folder = f"{report_folder}{os.sep}" + # Create page sources folder + shutil.rmtree(f"{folder}sources", ignore_errors=True) + pathlib.Path(f"{folder}sources").mkdir(parents=True) + # Create screenshots folder + shutil.rmtree(f"{folder}screenshots", ignore_errors=True) + pathlib.Path(f"{folder}screenshots").mkdir(parents=True) + # Copy error.png to screenshots folder + resources_path = pathlib.Path(__file__).parent.joinpath("resources") + error_img = pathlib.Path(resources_path, "error.png") + shutil.copy(str(error_img), f"{folder}screenshots") + + +# +# Persistence functions +# +def save_screenshot_selenium(report_folder, index, driver): + """ + Save a screenshot in 'screenshots' folder under the specified folder. + + Returns: + str: The filename for the anchor link. + """ + link = f"screenshots{os.sep}image-{index}.png" + folder = "" + if report_folder is not None and report_folder != '': + folder = f"{report_folder}{os.sep}" + filename = folder + link + try: + if hasattr(driver, "save_full_page_screenshot"): + driver.save_full_page_screenshot(filename) + else: + driver.save_screenshot(filename) + except Exception as e: + trace = traceback.format_exc() + link = f"screenshots{os.sep}error.png" + print(f"{str(e)}\n\n{trace}", file=sys.stderr) + finally: + return link + + +def save_image(report_folder, index, image): + link = f"screenshots{os.sep}image-{index}.png" + folder = "" + if report_folder is not None and report_folder != '': + folder = f"{report_folder}{os.sep}" + filename = folder + link + import base64 + try: + f = open(filename, 'wb') + #f.write(base64.decodebytes(image)) + f.write(image) + f.close() + except Exception as e: + trace = traceback.format_exc() + link = f"screenshots{os.sep}error.png" + print(f"{str(e)}\n\n{trace}", file=sys.stderr) + finally: + return link + + +def save_source(report_folder, index, source): + link = f"sources{os.sep}page-{index}.txt" + folder = "" + if report_folder is not None and report_folder != '': + folder = f"{report_folder}{os.sep}" + filename = folder + link + try: + f = open(filename, 'w') + f.write(source) + f.close() + except Exception as e: + trace = traceback.format_exc() + link = None + print(f"{str(e)}\n\n{trace}", file=sys.stderr) + finally: + return link + + +def save_page_source_selenium(report_folder, index, driver): + """ + Saves the HTML page source with TXT extension + in 'sources' folder under the specified folder. + + Returns: + str: The filename for the anchor link. + """ + link = f"sources{os.sep}page-{index}.txt" + folder = "" + if report_folder is not None and report_folder != '': + folder = f"{report_folder}{os.sep}" + filename = folder + link + try: + source = driver.page_source + # document_root = html.fromstring(source) + # source = etree.tostring(document_root, encoding='unicode', pretty_print=True) + f = open(filename, 'w') + f.write(source) + f.close() + except Exception as e: + trace = traceback.format_exc() + link = None + print(f"{str(e)}\n\n{trace}", file=sys.stderr) + finally: + return link + + +# +# Auxiliary functions for the report generation +# +def append_header(call, report, extras, pytest_html, + description, description_tag): + """ + Appends the description and the test execution exception trace, if any, to a test report. + + Args: + description (str): The test file docstring. + + description_tag (str): The HTML tag to use. + """ + # Append description + if description is not None: + description = escape_html(description).strip().replace('\n', "
") + extras.append(pytest_html.extras.html(f"<{description_tag}>{description}")) + + # Catch explicit pytest.fail and pytest.skip calls + if ( + hasattr(call, 'excinfo') and + call.excinfo is not None and + call.excinfo.typename in ('Failed', 'Skipped') and + hasattr(call.excinfo, "value") and + hasattr(call.excinfo.value, "msg") + ): + extras.append(pytest_html.extras.html( + "
"
+            f"{escape_html(call.excinfo.typename)}"
+            f" reason = {escape_html(call.excinfo.value.msg)}"
+            "
" + ) + ) + # Catch XFailed tests + if report.skipped and hasattr(report, 'wasxfail'): + extras.append(pytest_html.extras.html( + "
"
+            "XFailed"
+            f" reason = {escape_html(report.wasxfail)}"
+            "
" + ) + ) + # Catch XPassed tests + if report.passed and hasattr(report, 'wasxfail'): + extras.append(pytest_html.extras.html( + "
"
+            "XPassed"
+            f" reason = {escape_html(report.wasxfail)}"
+            "
" + ) + ) + # Catch explicit pytest.xfail calls and runtime exceptions in failed tests + if ( + hasattr(call, 'excinfo') and + call.excinfo is not None and + call.excinfo.typename not in ('Failed', 'Skipped') and + hasattr(call.excinfo, '_excinfo') and + call.excinfo._excinfo is not None and + isinstance(call.excinfo._excinfo, tuple) and + len(call.excinfo._excinfo) > 1 + ): + extras.append(pytest_html.extras.html( + "
"
+            f"{escape_html(call.excinfo.typename)}"
+            f" {escape_html(call.excinfo._excinfo[1])}"
+            "
" + ) + ) + # extras.append(pytest_html.extras.html("
")) + + +def escape_html(text): + """ Escapes the '<' and '>' characters. """ + return str(text).replace('<', "<").replace('>', ">") + + +def get_table_row_tag(comment, image, source, clazz="selenium_log_comment"): + """ + Returns the HTML table row of a test step. + + Args: + comment (str): The comment of the test step. + + image (str): The screenshot anchor element. + + source (str): The page source anchor element. + + clazz (str): The CSS class to apply. + + Returns: + str: The element. + """ + image = decorate_screenshot(image) + if isinstance(comment, dict): + comment = decorate_description(comment) + elif isinstance(comment, str): + comment = decorate_label(comment, clazz) + else: + comment = "" + if source is not None: + source = decorate_page_source(source) + return ( + f"" + f"{comment}" + f'
{image}
{source}
' + f"" + ) + else: + return ( + f"" + f"{comment}" + f'
{image}
' + "" + ) + + +def decorate_description(description): + """ Applies CSS style to a test step description. """ + if description is None: + return "" + + if 'comment' not in description: + description['comment'] = None + if 'url' not in description: + description['url'] = None + if 'value' not in description: + description['value'] = None + if 'locator' not in description: + description['locator'] = None + if 'attributes' not in description: + description['attributes'] = None + + if description['comment'] is not None: + return decorate_label(description['comment'], "selenium_log_comment") + label = decorate_label(description['action'], "selenium_log_action") + if description['url'] is not None: + label += " " + decorate_label(description['url'], "selenium_log_target") + else: + if description['value'] is not None: + label += " " + decorate_quote() + description['value'] + decorate_quote() + if description['locator'] is not None or description['attributes'] is not None: + label += "

" + if description['locator'] is not None: + locator = description['locator'].replace('"', decorate_quote()) + label += "Locator: " + decorate_label(locator, "selenium_log_target") + "

" + if description['attributes'] is not None: + label += "Attributes: " + decorate_label(description['attributes'], "selenium_log_target") + return decorate_label(label, "selenium_log_description") + + +def decorate_label(label, clazz): + """ + Applies a CSS style to a text. + + Args: + label (str): The text to decorate. + + clazz (str): The CSS class to apply. + + Returns: + The element. + """ + return f'{label}' + + +def decorate_anchors(image, source): + """ Applies CSS style to a screenshot and page source anchor elements. """ + image = decorate_screenshot(image) + if source is not None: + source = decorate_page_source(source) + return f'
{image}
{source}
' + else: + return image + + +def decorate_screenshot(filename, clazz="selenium_log_img"): + """ Applies CSS style to a screenshot anchor element. """ + return f'' + + +def decorate_page_source(filename, clazz="selenium_page_src"): + """ Applies CSS style to a page source anchor element. """ + return f'[page source]' + + +def decorate_quote(): + """ Applies CSS style to a quotation. """ + return decorate_label('"', "selenium_log_quote") + + +def log_error_message(report, item, message): + """ Appends error message in stderr section of a test report. """ + try: + i = -1 + for x in range(len(report.sections)): + if "stderr call" in report.sections[x][0]: + i = x + break + if i != -1: + report.sections[i] = ( + report.sections[i][0], + report.sections[i][1] + '\n' + message + '\n' + ) + else: + report.sections.append(('Captured stderr call', message)) + except: + pass