Source code for plugin

import asyncio
import inspect
import logging
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional

import pytest
from pyppeteer import launch

from pytest_pyppeteer.models import Browser, Options
from pytest_pyppeteer.utils import CHROME_EXECUTABLE, current_platform

if TYPE_CHECKING:
    from _pytest.config import Config, PytestPluginManager
    from _pytest.config.argparsing import Parser
    from _pytest.fixtures import FixtureRequest
    from _pytest.nodes import Item
    from _pytest.runner import TestReport

LOGGER = logging.getLogger(__name__)


[docs]def pytest_addhooks(pluginmanager: "PytestPluginManager") -> None: """Add new hooks. :param _pytest.config.PytestPluginManager pluginmanager: :return: ``None`` """ from . import hooks pluginmanager.add_hookspecs(hooks)
[docs]def pytest_configure(config: "Config") -> None: """Perform initial configuration as follows: * Add ``pytest.mark.option`` marker to the ini-file option. :param _pyest.config.Config config: pytest config object. :return: None .. note:: This is a ``pytest hook function`` which is called for every plugin and initial conftest file after command line options have been parsed. After that, the hook is called for other conftest files as they are imported. """ config.addinivalue_line( "markers", "options(kwargs): add or change existing options. " "specify options as keyword argument, e.g. " "@pytest.mark.options(devtools=True)", )
[docs]@pytest.mark.tryfirst def pytest_collection_modifyitems(items: List["Item"]) -> None: """Modify the items collected by pytest as follows: * Because all test items using pyppeteer should be an asyncio coroutine, here the `pytest.mark.asyncio`_ marker is automatically added to each test item collected by pytest. :param List[pytest.Item] items: the list of items objects collected by pytest. :return: None .. note:: This is a ``pytest hook function`` which is called after collection of all test items is completed. .. _pytest.mark.asyncio: https://github.com/pytest-dev/pytest-asyncio#pytestmarkasyncio """ items[:] = [add_asyncio_marker(item) for item in items]
[docs]def add_asyncio_marker(item: "Item") -> "Item": """Add `pytest.mark.asyncio`_ marker to the specified item. If the marker is already exists, return the item directly. :param pytest.Item item: the pytest item object. :return: the marked item object. .. _pytest.mark.asyncio: https://github.com/pytest-dev/pytest-asyncio#pytestmarkasyncio """ if "asyncio" not in item.keywords and is_coroutine(item.obj): item.add_marker(pytest.mark.asyncio) return item
[docs]def is_coroutine(obj: Any) -> bool: """Check to see if an object is really an asyncio coroutine. :param Any obj: any object. :return: `True` or `False`. """ return asyncio.iscoroutinefunction(obj) or inspect.isgeneratorfunction(obj)
[docs]@pytest.hookimpl(hookwrapper=True, tryfirst=True) def pytest_runtest_makereport(item: "Item") -> None: """Implement this pytest hook in wrapper mode, the added behaviors as follows: * Register a new :py:func:`hooks.pytest_pyppeteer_runtest_makereport_call_debug` hook which called when a actual failing test calls not setup/teardown. :param pytest.Item item: the pytest item object. :return: None """ # execute all other hooks to obtain the report object outcome = yield res: TestReport = outcome.get_result() setattr(item, f"res_{res.when}", res) # we only deal with actual failing test calls, not setup/teardown if res.when == "call" and res.failed: hook: List[ Awaitable ] = item.ihook.pytest_pyppeteer_runtest_makereport_call_debug(item=item) if hook: loop = asyncio.get_event_loop() loop.run_until_complete(hook[0])
[docs]def pytest_addoption(parser: "Parser") -> None: """Register new command line arguments and ini-file values. Create a new command line option group named `pyppeteer`, and add the following new options in it: * ``--executable-path``: path to a Chromium or Chrome executable. * ``--headless``: run browser in headless mode. * ``--args``: additional args to pass to the browser instance. more details refer to :py:func:`args` fixture. * ``--window-size``: set the initial browser window size. Defaults to 800 * 600. ``--window-size 0 0`` means to starts the browser maximized. :param _pytest.config.argparsing.Parser parser: parser for command line arguments and ini-file values. :return: None .. note:: This is a ``pytest hook function`` which be called once at the beginning of a test run to register argparse-style options and ini-style config values. There are two ways to register new options, respectively: * To register a command line option, call :py:meth:`parser.addoption(...) <pytest:_pytest.config.argparsing.Parser.addoption>`. * To register an ini-file option, call :py:meth:`parser.addini(...) <pytest:_pytest.config.argparsing.Parser.addini>`. And the options can later be accessed through the :py:class:`Config <pytest:_pytest.config.Config>` object, respectively: * To retrieve the value of a command line option, call :py:meth:`config.getoption(name) <pytest:_pytest.config.Config.getoption>`. * To retrieve a value read from an ini-style file, call :py:meth:`config.getini(name) <pytest:_pytest.config.Config.getini>`. The ``Config`` object is passed around on many pytest internal objects via the ``.config`` attribute or can be retrieved as the :py:func:`pytestconfig <pytest:_pytest.fixtures.pytestconfig>` fixture. """ # Create a option group named "pyppeteer" group = parser.getgroup("pyppeteer", description="pyppeteer") group.addoption( "--executable-path", help="path to a Chromium or Chrome executable." ) group.addoption( "--headless", action="store_true", help="run browser in headless mode." ) group.addoption( "--args", action="append", nargs="+", help="additional args to pass to the browser instance.", ) group.addoption( "--window-size", nargs=2, default=["800", "600"], help="set the initial browser window size.", )
[docs]@pytest.fixture(scope="session") def executable_path(pytestconfig: "Config") -> Optional[str]: """``Session-scoped fixture`` that return Chrome or Chromium executable path. The fixture behaviors follow this procedure: 1. Return the value passed in from command line option of `--executable-path`, if it's not ``None``. 2. Return the default installation location of Chrome in current platform, but now only support ``win64``, ``win32`` and ``mac`` platform. For other platforms, pyppeteer will downloads the recent version of Chromium when called first time. If you don't prefer this behavior, you can specify an exact path by overwrite this fixture: Example:: @pytest.fixture(scope="session") def executable_path(executable_path): if executable_path is None: return "path/to/Chrome/or/Chromium" return executable_path :param _pytest.config.Config pytestconfig: a session-scoped fixture that return config object. :return: return Chrome or Chromium executable path string. but if current platform isn't supported, return ``None``. """ path = pytestconfig.getoption("--executable-path") if path: return path try: return CHROME_EXECUTABLE[current_platform()] except OSError as e: # Unsupported platform LOGGER.error(e) return None
[docs]@pytest.fixture(scope="session") def args(pytestconfig: "Config") -> List[str]: """``Session-scoped fixture`` that return a list of additional args in the `List of Chromium Command Line Arguments`_ to pass to the browser instance. You can use it by command-line option: Example:: $ pytest --args proxy-server "localhost:5555,direct://" --args proxy-bypass-list "192.0.0.1/8;10.0.0.1/8" Or overwrite it in your test: Example:: @pytest.fixture(scope="session") def args(args) -> List[str]: return args + [ "--proxy-server=localhost:5555,direct://", "--proxy-bypass-list=192.0.0.1/8;10.0.0.1/8", ] :param _pytest.config.Config pytestconfig: a session-scoped fixture that return config object. :return: a list of arguments string. return ``list()`` if no ``--args`` passed in the command-line. .. _List of Chromium Command Line Arguments: https://peter.sh/experiments/chromium-command-line-switches/ """ args: list = pytestconfig.getoption("--args") if args: return ["--" + "=".join(arg_list) for arg_list in args] else: return list()
[docs]@pytest.fixture(scope="session") def session_options( pytestconfig: "Config", args: List[str], executable_path: str ) -> "Options": """``Session-scoped fixture`` that return a :py:class:`models.Options` object used to initialize browser. :param _pytest.config.Config pytestconfig: a session-scoped fixture that return config object. :param List[str] args: a session-scoped fixture that return a list of additional args to pass to the browser instance. :param str executable_path: a session-scoped fixture that return Chrome or Chromium executable path. :return: a :py:class:`models.Options` object used to initialize browser. """ headless: bool = pytestconfig.getoption("--headless") width, hight = pytestconfig.getoption("--window-size") if width == "0" and hight == "0": if current_platform() == "mac": args.append("--start-fullscreen") else: args.append("--start-maximized") else: args.append("--window-size={},{}".format(width, hight)) return Options(args=args, executablePath=executable_path, headless=headless)
[docs]@pytest.fixture def options(request: "FixtureRequest", session_options: "Options") -> "Options": """``Function-scoped fixture`` that return a :py:class:`models.Options` object used to initialize browser. This fixture contains all of :py:func:`session_options`, plus any options specified by the options markers. Any change to these options will apply only to the tests covered by scope of the fixture override. Example:: @pytest.mark.options(devtools=True) async def test_options_mark(pyppeteer): ... :param _pytest.fixture.FixtureRequest request: A request object gives access to the requesting test context. :param Options session_options: a :py:class:`models.Options` object from :py:func:`session_options`. :return: a :py:class:`models.Options` object used to initialize browser. """ return session_options.copy(update=get_options_from_markers(request.node))
[docs]def get_options_from_markers(item: "Item") -> dict: """Get the options from the ``options`` markers of test item. And there are only apply on the current test item. :param Item item: the test item object. :return: an dict contains options. """ options_dict = dict() for node, mark in item.iter_markers_with_node("options"): LOGGER.debug( "{0} marker <{1.name}> " "contains kwargs <{1.kwargs}>".format(node.__class__.__name__, mark) ) options_dict.update(mark.kwargs) LOGGER.info("Options from markers: {}".format(options_dict)) return options_dict
[docs]@pytest.fixture async def pyppeteer_factory(options: "Options") -> Callable: """``Function-scoped fixture`` that return a pyppeteer browser factory. :param Options options: a :py:class:`models.Options` object used to initialize browser :yield: a pyppeteer browser factory. """ browsers: List[Browser] = list() async def _factory() -> "Browser": LOGGER.info("Options to initialize browser instance: {}".format(options)) b = Browser(pyppeteer_browser=await launch(options=options.dict())) browsers.append(b) return b yield _factory for browser in browsers: await browser.close()
[docs]@pytest.fixture async def pyppeteer(pyppeteer_factory: "Callable") -> "Browser": """``Function-scoped fixture`` that return a pyppeteer browser instance. :param Callable pyppeteer_factory: pyppeteer factory :yield: a pyppeteer browser instance. """ yield await pyppeteer_factory()