@ -1,21 +1,25 @@
""" pytest support functions. """
import os
import shutil
import threading
import logging
import json
import re
import tempfile
import logging
import urllib . request
from urllib . error import URLError
import pytest
from flask import url_for
from vasl_templates . webapp import app
app . testing = True
from vasl_templates . webapp . tests import utils
from vasl_templates . webapp . tests . control_tests import ControlTests
FLASK_WEBAPP_PORT = 5011
_pytest_options = None
_orig_url_for = app . url_for
# ---------------------------------------------------------------------
def pytest_addoption ( parser ) :
@ -25,7 +29,7 @@ def pytest_addoption( parser ):
# add test options
parser . addoption (
" --server-url " , action = " store " , dest = " server _url" , default = None ,
" --webapp " , action = " store " , dest = " webapp _url" , default = None ,
help = " Webapp server to test against. "
)
# NOTE: Chrome seems to be ~15% faster than Firefox, headless ~5% faster than headful.
@ -38,7 +42,7 @@ def pytest_addoption( parser ):
help = " Run the tests headless. "
)
parser . addoption (
" --window-size " , action = " store " , dest = " window_size " , default = " 100 0x700 " ,
" --window-size " , action = " store " , dest = " window_size " , default = " 102 0x700 " ,
help = " Browser window size. "
)
@ -48,31 +52,6 @@ def pytest_addoption( parser ):
help = " Run a shorter version of the test suite. "
)
# NOTE: Some tests require the VASL module file(s). We don't want to put these into source control,
# so we provide this option to allow the caller to specify where they live.
parser . addoption (
" --vasl-mods " , action = " store " , dest = " vasl_mods " , default = None ,
help = " Directory containing the VASL .vmod file(s). "
)
parser . addoption (
" --vasl-extensions " , action = " store " , dest = " vasl_extensions " , default = None ,
help = " Directory containing the VASL extensions. "
)
# NOTE: Some tests require VASSAL to be installed. This option allows the caller to specify
# where it is (multiple installations can be placed in sub-directories).
parser . addoption (
" --vassal " , action = " store " , dest = " vassal " , default = None ,
help = " Directory containing VASSAL installation(s). "
)
# NOTE: Some tests require Chapter H vehicle/ordnance notes. This is copyrighted material,
# so it is kept in a private repo.
parser . addoption (
" --vo-notes " , action = " store " , dest = " vo_notes " , default = None ,
help = " Directory containing Chapter H vehicle/ordnance notes and test results. "
)
# NOTE: It's not good to have the code run differently to how it will normally,
# but using the clipboard to retrieve snippets causes more trouble than it's worth :-/
# since any kind of clipboard activity while the tests are running could cause them to fail
@ -82,16 +61,53 @@ def pytest_addoption( parser ):
help = " Use the clipboard to get snippets. "
)
# ---------------------------------------------------------------------
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def pytest_configure ( config ) :
""" Called after command-line options have been parsed. """
global _pytest_options
_pytest_options = config . option
import vasl_templates . webapp . tests
vasl_templates . webapp . tests . pytest_options = config . option
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest . fixture ( scope = " session " )
def monkeypatch ( ) :
""" Override the default monkeypatch fixture. """
assert False , " Don ' t use monkeypatch! " # it won't work when testing against a remote server
# ---------------------------------------------------------------------
_webapp = None
@pytest . fixture ( scope = " function " )
def webapp ( ) :
""" Launch the webapp. """
# get the global webapp fixture
global _webapp
if _webapp is None :
_webapp = _make_webapp ( )
# reset the remote webapp server
_webapp . control_tests . start_tests ( )
# return the webapp to the caller
yield _webapp
# reset the remote webapp server
_webapp . control_tests . end_tests ( )
def _make_webapp ( ) :
""" Create the global webapp fixture. """
# initialize
server_url = pytest . config . option . server_url #pylint: disable=no-member
app . base_url = server_url if server_url else " http://localhost: {} " . format ( FLASK_WEBAPP_PORT )
logging . disable ( logging . CRITICAL )
webapp_url = _pytest_options . webapp_url
if webapp_url and not webapp_url . startswith ( " http:// " ) :
webapp_url = " http:// " + webapp_url
app . base_url = webapp_url if webapp_url else " http://localhost: {} " . format ( FLASK_WEBAPP_PORT )
_disable_console_logging ( )
# initialize
# WTF?! https://github.com/pallets/flask/issues/824
@ -105,32 +121,44 @@ def webapp():
# stop the browser from checking for a dirty scenario when leaving the page
kwargs [ " disable_close_window_check " ] = 1
# check if the tests are being run headless
if pytest . config . option . headless : #pylint: disable=no-member
if _pytest_options . headless :
# yup - there is no clipboard support :-/
pytest . config . option . use_clipboard = False #pylint: disable=no-member
_pytest_options . use_clipboard = False
# check if we should disable using the clipboard for snippets
if not pytest . config . option . use_clipboard : #pylint: disable=no-member
if not _pytest_options . use_clipboard :
# NOTE: It's not a bad idea to bypass the clipboard, even when running in a browser,
# to avoid problems if something else uses the clipboard while the tests are running.
kwargs [ " store_clipboard " ] = 1
url = url_for ( endpoint , _external = True , * * kwargs )
if kwargs . get ( " _external " ) is None :
kwargs [ " _external " ] = True
url = _orig_url_for ( endpoint , * * kwargs )
url = url . replace ( " http://localhost " , app . base_url )
return url
app . url_for = make_webapp_url
# check if we need to start a local webapp server
if not server _url:
if not webapp _url:
# yup - make it so
# NOTE: We run the server thread as a daemon so that it won't prevent the tests from finishing
# when they're done. We used to call $/shutdown after yielding the webapp fixture, but when
# we changed it from being per-session to per-function, we can no longer do that.
# This means that the webapp doesn't get a chance to shutdown properly (in particular,
# clean up the gRPC service), but since we send an EndTests message at the of each test,
# the remote server gets a chance to clean up then. It's not perfect (e.g. if the tests fail
# or otherwise finish early before they get a chance to send the EndTests message), but
# we can live with it.
thread = threading . Thread (
target = lambda : app . run ( host = " 0.0.0.0 " , port = FLASK_WEBAPP_PORT , use_reloader = False )
target = lambda : app . run ( host = " 0.0.0.0 " , port = FLASK_WEBAPP_PORT , use_reloader = False ) ,
daemon = True
)
thread . start ( )
# wait for the server to start up
def is_ready ( ) :
""" Try to connect to the webapp server. """
try :
resp = urllib . request . urlopen ( app . url_for ( " ping " ) ) . read ( )
assert resp . startswith ( b " pong: " )
url = app . url_for ( " ping " )
with urllib . request . urlopen ( url ) as resp :
assert resp . read ( ) . startswith ( b " pong: " )
return True
except URLError :
return False
@ -138,20 +166,41 @@ def webapp():
assert False , " Unexpected exception: {} " . format ( ex )
utils . wait_for ( 5 , is_ready )
# return the server to the caller
yield app
# set up control of the remote webapp server
try :
url = app . url_for ( " get_control_tests " )
with urllib . request . urlopen ( url ) as resp :
resp_data = json . load ( resp )
except urllib . error . HTTPError as ex :
if ex . code == 404 :
raise RuntimeError ( " Can ' t get the test control port - has remote test control been enabled? " ) from ex
raise
port_no = resp_data . get ( " port " )
if not port_no :
raise RuntimeError ( " The webapp server is not running the test control service. " )
mo = re . search ( r " ^http://(.+): \ d+$ " , app . base_url )
addr = " {} : {} " . format ( mo . group ( 1 ) , port_no )
app . control_tests = ControlTests ( addr )
# NOTE: We set the back-end webdriver to be the of the same type (Firefox or Chrome) as the browser
# being used to drive the tests, which, strictly speaking, doesn't make sense, since the two things
# don't have anything to do with each other. However, this is a convenient way to switch the backend
# webdriver's and exercise both of them. The webdriver binary must be on the path, but if it's not,
# we won't have even got this far, since it needs to be there to drive the browser.
# NOTE: This will have no effect if we're talking to a remote server, but we can live with that.
if _pytest_options . webdriver == " firefox " :
app . config [ " WEBDRIVER_PATH " ] = shutil . which ( " geckodriver " )
elif _pytest_options . webdriver == " chrome " :
app . config [ " WEBDRIVER_PATH " ] = shutil . which ( " chromedriver " )
# shutdown the local webapp server
if not server_url :
urllib . request . urlopen ( app . url_for ( " shutdown " ) ) . read ( )
thread . join ( )
return app
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@pytest . fixture ( scope = " session " )
def test_client ( ) :
""" Return a test client that can be used to connect to the webapp. """
logging . disable ( logging . CRITICAL )
_disable_console_logging ( )
return app . test_client ( )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -170,30 +219,24 @@ def webdriver( request ):
driver = request . config . getoption ( " --webdriver " )
from selenium import webdriver as wb
if driver == " firefox " :
options = wb . FirefoxOptions ( )
options . set_headless ( headless = pytest . config . option . headless ) #pylint: disable=no-member
driver = wb . Firefox (
firefox_options = options ,
log_path = os . path . join ( tempfile . gettempdir ( ) , " geckodriver.log " )
service = wb . firefox . service . Service (
log_output = os . path . join ( tempfile . gettempdir ( ) , " webdriver-pytest.log " )
)
options = wb . FirefoxOptions ( )
if _pytest_options . headless :
options . add_argument ( " --headless " )
driver = wb . Firefox ( options = options , service = service )
elif driver == " chrome " :
options = wb . ChromeOptions ( )
options . set_headless ( headless = pytest . config . option . headless ) #pylint: disable=no-member
driver = wb . Chrome ( chrome_options = options )
elif driver == " ie " :
# NOTE: IE11 requires a registry key to be set:
# https://github.com/SeleniumHQ/selenium/wiki/InternetExplorerDriver#required-configuration
options = wb . IeOptions ( )
if pytest . config . option . headless : #pylint: disable=no-member
raise RuntimeError ( " IE WebDriver cannot be run headless. " )
options . IntroduceInstabilityByIgnoringProtectedModeSettings = True
options . EnsureCleanSession = True
driver = wb . Ie ( ie_options = options )
if _pytest_options . headless :
options . add_argument ( " --headless " )
options . add_argument ( " --disable-gpu " )
driver = wb . Chrome ( options = options )
else :