Skip to content

Pytest Fixtures

The pytest plugin provides a variety of helpful fixtures to streamline the process of setting up a browser and pointing it at your test environment.

webpack_server

The URL of the test frontend.

Using this fixture has the side affect of starting a node server in a background process.

Configuring the server is done via environment variables. Any environment variables prefixed with PYPPETEER_ have that prefix stripped off, and are then passed to the environment of the background process. Additionally, all of those variables are formatted with the following values:

Key Value
live_server live_server.url, the URL of the
live_server fixture

For example, if PYPPETEER_VUE_APP_API_URL_ROOT was set to {live_server}/api/v3/ in your tox.ini, the environment variable VUE_APP_API_URL_ROOT might be set to something like http://localhost:48201/api/v3/ in the webpack server context when tests are run, depending on which port the live_server is allocated.

This fixture uses two environment variables when starting the server:

  • PYPPETEER_TEST_CLIENT_COMMAND - The command to run the server. Generally npm run serve
  • or yarn run serve.
  • PYPPETEER_TEST_CLIENT_DIR - The directory containing the frontend project.
  • PYPPETEER_TEST_CLIENT_COMMAND will be run inside this directory.
Source code in girder_pytest_pyppeteer/plugin.py
@pytest.fixture(scope='session')
def webpack_server(request, _pyppeteer_config, live_server):
    """
    The URL of the test frontend.

    Using this fixture has the side affect of starting a node server in a background process.

    Configuring the server is done via environment variables. Any environment variables prefixed
    with `PYPPETEER_` have that prefix stripped off, and are then passed to the environment of the
    background process. Additionally, all of those variables are formatted with the following
    values:

    Key|Value
    ---|---
    `live_server`|`live_server.url`, the URL of the
    [`live_server` fixture](https://pytest-django.readthedocs.io/en/latest/helpers.html#live-server)

    For example, if `PYPPETEER_VUE_APP_API_URL_ROOT` was set to `{live_server}/api/v3/` in your
    `tox.ini`, the environment variable `VUE_APP_API_URL_ROOT` might be set to something like
    `http://localhost:48201/api/v3/` in the webpack server context when tests are run, depending
    on which port the `live_server` is allocated.

    This fixture uses two environment variables when starting the server:

    * **`PYPPETEER_TEST_CLIENT_COMMAND`** - The command to run the server. Generally `npm run serve`
    *   or `yarn run serve`.
    * **`PYPPETEER_TEST_CLIENT_DIR`** - The directory containing the frontend project.
    *   `PYPPETEER_TEST_CLIENT_COMMAND` will be run inside this directory.
    """
    skip_if_pyppeteer_disabled(request)
    env = {
        # The path must be passed so that npm/yarn can be found
        **{'PATH': os.getenv('PATH')},
        # Pass everything from the pyppeteer config, formatted for the current environment
        **{
            key: value.format(live_server=live_server.url)
            for (key, value) in _pyppeteer_config.items()
        },
    }
    command = ['/usr/bin/env'] + shlex.split(_pyppeteer_config['TEST_CLIENT_COMMAND'])
    log.debug(f'Launching node server with {command}')
    process = Popen(
        command,
        cwd=_pyppeteer_config['TEST_CLIENT_DIR'],
        env=env,
        stdout=PIPE,
        stderr=PIPE,
        preexec_fn=os.setsid,
    )
    try:
        # Wait until the server starts by polling stdout
        max_timeout = 60
        retry_interval = 3
        err = b''
        for _ in range(0, max_timeout // retry_interval):
            try:
                _out, err = process.communicate(timeout=retry_interval)
            except TimeoutExpired as e:
                match = re.search(
                    b'App running at:\n  - Local:   (http[s]?://[a-z]+:[0-9]+/?) \n', e.stdout
                )
                if match:
                    url = match.group(1).decode('utf-8')
                    break
        else:
            raise Exception(f'webpack server failed to start: {err}')
        yield url
    finally:
        # Kill every process in the webpack server's process group
        try:
            os.killpg(os.getpgid(process.pid), signal.SIGTERM)
            # TODO set up some signal handlers to ensure it always gets cleaned up
        except ProcessLookupError:
            # The process has already terminated, no need to intervene
            pass

page

A pyppeteer page in a fresh browser environment with some sane defaults set.

Pyppeteer offers a number of arguments to configure the browser during initialization. Currently, a subset of these arguments are configurable using environment variables:

Environment Variable Pyppeteer Equivalent Values Default
PYPPETEER_BROWSER_IGNORE_HTTPS_ERRORS ignoreHTTPSErrors "True"/"1" or "False"/"0" "True"
PYPPETEER_BROWSER_HEADLESS headless "True"/"1" or "False"/"0" "True"
PYPPETEER_BROWSER_WIDTH defaultViewport.width int 1024
PYPPETEER_BROWSER_HEIGHT defaultViewport.height int 800
PYPPETEER_BROWSER_DUMPIO dumpio "True"/"1" or "False"/"0" "True"

You can set these in your tox.ini setenv block, or name them in the passenv section and set them manually in the shell prior to running tox.

Source code in girder_pytest_pyppeteer/plugin.py
@pytest.fixture
async def page(request, _pyppeteer_config):
    """
    A pyppeteer page in a fresh browser environment with some sane defaults set.

    Pyppeteer offers a number of arguments to configure the browser during initialization.
    Currently, a subset of these arguments are configurable using environment variables:

    Environment Variable|Pyppeteer Equivalent|Values|Default
    ---|---|---|---
    PYPPETEER_BROWSER_IGNORE_HTTPS_ERRORS|ignoreHTTPSErrors|"True"/"1" or "False"/"0"|"True"
    PYPPETEER_BROWSER_HEADLESS|headless|"True"/"1" or "False"/"0"|"True"
    PYPPETEER_BROWSER_WIDTH|defaultViewport.width|int|1024
    PYPPETEER_BROWSER_HEIGHT|defaultViewport.height|int|800
    PYPPETEER_BROWSER_DUMPIO|dumpio|"True"/"1" or "False"/"0"|"True"

    You can set these in your `tox.ini` `setenv` block, or name them in the `passenv` section and
    set them manually in the shell prior to running tox.
    """
    skip_if_pyppeteer_disabled(request)
    from pyppeteer.errors import BrowserError
    from pyppeteer.launcher import Launcher
    import pytest_asyncio  # noqa: F401

    launch_kwargs = {
        'ignoreHTTPSErrors': True,
        'headless': True,
        'defaultViewport': {'width': 1024, 'height': 800},
        'args': ['--no-sandbox'],
        'dumpio': True,
    }

    def parse_bool(value):
        if value in ('True', '1'):
            return True
        elif value in ('False', '0'):
            return False
        raise ValueError(f"invalid boolean: '{value}'")

    for key, value in _pyppeteer_config.items():
        if key == 'BROWSER_IGNORE_HTTPS_ERRORS':
            launch_kwargs['ignoreHTTPSErrors'] = parse_bool(value)
        if key == 'BROWSER_HEADLESS':
            launch_kwargs['headless'] = parse_bool(value)
        if key == 'BROWSER_WIDTH':
            launch_kwargs['defaultViewport']['width'] = int(value)
        if key == 'BROWSER_HEIGHT':
            launch_kwargs['defaultViewport']['height'] = int(value)
        if key == 'BROWSER_DUMPIO':
            launch_kwargs['dumpio'] = parse_bool(value)

    launcher = Launcher(**launch_kwargs)
    try:
        browser = await launcher.launch()
    except BrowserError as e:
        launch_command = ' '.join(launcher.cmd)
        log.error('The pyppeteer browser failed to launch.')
        log.error(
            'You may be able to get more information on the error'
            ' by starting the browser process yourself:'
        )
        log.error(launch_command)

        raise e
    page = await browser.newPage()

    @page.on('console')
    def _console_log_handler(message):
        log.debug(f'{message.type} {message.args} {message.text}')

    yield page
    await browser.close()

oauth_application

An OAuth2 Application that can be used to log in to the webpack_server.

This fixture assumes that you are using the oauth2_provider from django-oauth-toolkit. It will generate an OAuth Application that is configured to work with the webpack_server fixture.

The client_id of the application defaults to test-oauth-client-id, but can be overriden by specifying the PYPPETEER_VUE_APP_OAUTH_CLIENT_ID environment variable.

Source code in girder_pytest_pyppeteer/plugin.py
@pytest.fixture
def oauth_application(_pyppeteer_config, webpack_server: str):
    """
    An OAuth2 Application that can be used to log in to the `webpack_server`.

    This fixture assumes that you are using the `oauth2_provider` from
    [django-oauth-toolkit](https://github.com/jazzband/django-oauth-toolkit).
    It will generate an OAuth Application that is configured to work with the
    `webpack_server` fixture.

    The `client_id` of the application defaults to `test-oauth-client-id`, but can be overriden by
    specifying the `PYPPETEER_VUE_APP_OAUTH_CLIENT_ID` environment variable.
    """
    from oauth2_provider.models import get_application_model

    Application = get_application_model()  # noqa: N806
    application = Application(
        name='test-client-application',
        client_id=_pyppeteer_config['VUE_APP_OAUTH_CLIENT_ID'],
        client_secret='',
        client_type='public',
        redirect_uris=webpack_server if webpack_server.endswith('/') else f'{webpack_server}/',
        authorization_grant_type='authorization-code',
        skip_authorization=True,
    )
    application.save()
    return application

page_login

A function that logs a user into the page.

This fixture fakes a login for a given user by generating the cookie that would have been generated from a successful login and setting that cookie directly in the given page.

Note this fixture will only authenticate the user with the API server. To authenticate with the web client, the full OAuth flow must be completed. This involves the web client redirecting the user to the API server, which sees the injected cookie, generates a session token, and redirects back to the web client, which keeps the session token in local storage.

The UX of this flow is different for different apps, so it is recommended that you write your own fixture that performs the necessary steps in the web client to initiate/complete the login process.

This fixture relies on the oauth_application fixture to provide the OAuth Application to log in to.

Source code in girder_pytest_pyppeteer/plugin.py
@pytest.fixture
def page_login(live_server, webpack_server, oauth_application, client):
    """
    A function that logs a user into the page.

    This fixture fakes a login for a given user by generating the cookie that would have been
    generated from a successful login and setting that cookie directly in the given `page`.

    Note this fixture will only authenticate the user with the API server. To authenticate with the
    web client, the full OAuth flow must be completed. This involves the web client redirecting the
    user to the API server, which sees the injected cookie, generates a session token, and
    redirects back to the web client, which keeps the session token in local storage.

    The UX of this flow is different for different apps, so it is recommended that you write your
    own fixture that performs the necessary steps in the web client to initiate/complete the login
    process.

    This fixture relies on the `oauth_application` fixture to provide the OAuth Application to log
    in to.
    """

    async def _page_login(page, user):
        client.force_login(user)
        sessionid = client.cookies['sessionid'].value
        await page.setCookie(
            {
                'name': 'sessionid',
                'value': sessionid,
                'url': live_server.url,
                'path': '/',
            }
        )
        await page.waitFor(2_000)  # TODO more reliable wait

    return _page_login