diff --git a/vasl_templates/tools/webdriver_stress_test.py b/vasl_templates/tools/webdriver_stress_test.py new file mode 100755 index 0000000..976b6b8 --- /dev/null +++ b/vasl_templates/tools/webdriver_stress_test.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +""" Stress-test the shared WebDriver. """ + +import os +import threading +import signal +import http.client +import time +import datetime +import base64 +import random +import json +import logging +from collections import defaultdict + +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys +import click + +from vasl_templates.webapp.webdriver import WebDriver +from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario +from vasl_templates.webapp.tests.utils import wait_for, find_child, find_snippet_buttons, \ + select_tab, select_menu_option, click_dialog_button, set_stored_msg, get_stored_msg + +shutdown_event = threading.Event() +thread_count = None + +stats_lock = threading.Lock() +stats = defaultdict( lambda: [0,0] ) # nb: [ #runs, total elapsed time ] + +# --------------------------------------------------------------------- + +@click.command() +@click.option( "--server-url", default="http://localhost:5010", help="Webapp server URL." ) +@click.option( "--snippet-images", default=1, help="Number of 'snippet image' threads to run." ) +@click.option( "--update-vsav", default=1, help="Number of 'update VSAV' threads to run." ) +@click.option( "--vsav","vsav_fname", help="VASL scenario file (.vsav) to be updated." ) +def main( server_url, snippet_images, update_vsav, vsav_fname ): + """Stress-test the shared WebDriver.""" + + # initialize + logging.disable( logging.CRITICAL ) + + # read the VASL scenario file + vsav_data = None + if update_vsav > 0: + vsav_data = open( vsav_fname, "rb" ).read() + + # prepare the test threads + threads = [] + for i in range(0,snippet_images): + threads.append( threading.Thread( + target = snippet_images_thread, + name = "snippet-images/{:02d}".format( 1+i ), + args = ( server_url, ) + ) ) + for i in range(0,update_vsav): + threads.append( threading.Thread( + target = update_vsav_thread, + name = "update-vsav/{:02d}".format( 1+i ), + args = ( server_url, vsav_fname, vsav_data ) + ) ) + + # launch the test threads + start_time = time.time() + global thread_count + thread_count = len(threads) + for thread in threads: + thread.start() + + # wait for Ctrl-C + def on_sigint( signum, stack ): #pylint: disable=missing-docstring,unused-argument + print( "\n*** SIGINT received ***\n" ) + shutdown_event.set() + signal.signal( signal.SIGINT, on_sigint ) + while not shutdown_event.is_set(): + time.sleep( 1 ) + + # wait for the test threads to shutdown + for thread in threads: + print( "Waiting for thread to finish:", thread ) + thread.join() + elapsed_time = time.time() - start_time + print() + + # output stats + print( "=== STATS ===") + print() + print( "Total run time: {}".format( datetime.timedelta( seconds=int(elapsed_time) ) ) ) + for key,val in stats.items(): + print( "- {:<14} {}".format( key+":", val[0] ), end="" ) + if val[0] > 0: + print( " (avg={:.3f}s)".format( float(val[1])/val[0] ) ) + else: + print() + +# --------------------------------------------------------------------- + +def snippet_images_thread( server_url ): + """Test generating snippet images.""" + + with WebDriver() as webdriver: + + # initialize + webdriver = webdriver.driver + init_webapp( webdriver, server_url, + [ "snippet_image_persistence", "scenario_persistence" ] + ) + + # load a scenario (so that we get some sortable's) + scenario_data = { + "SCENARIO_NOTES": [ { "caption": "Scenario note #1" } ], + "OB_SETUPS_1": [ { "caption": "OB setup note #1" } ], + "OB_NOTES_1": [ { "caption": "OB note #1" } ], + "OB_SETUPS_2": [ { "caption": "OB setup note #2" } ], + "OB_NOTES_2": [ { "caption": "OB note #2" } ], + } + load_scenario( scenario_data, webdriver ) + + # locate all the "generate snippet" buttons + snippet_btns = find_snippet_buttons( webdriver ) + tab_ids = list( snippet_btns.keys() ) + + while not shutdown_event.is_set(): + + try: + # clear the return buffer + ret_buffer = find_child( "#_snippet-image-persistence_", webdriver ) + assert ret_buffer.tag_name == "textarea" + webdriver.execute_script( "arguments[0].value = arguments[1]", ret_buffer, "" ) + + # generate a snippet + tab_id = random.choice( tab_ids ) + btn = random.choice( snippet_btns[ tab_id ] ) + log( "Getting snippet image: {}", btn.get_attribute("data-id") ) + select_tab( tab_id, webdriver ) + start_time = time.time() + ActionChains( webdriver ) \ + .key_down( Keys.SHIFT ) \ + .click( btn ) \ + .key_up( Keys.SHIFT ) \ + .perform() + + # wait for the snippet image to be generated + wait_for( 10*thread_count, lambda: ret_buffer.get_attribute( "value" ) ) + _, img_data = ret_buffer.get_attribute( "value" ).split( "|", 1 ) + elapsed_time = time.time() - start_time + + # update the stats + with stats_lock: + stats["snippet image"][0] += 1 + stats["snippet image"][1] += elapsed_time + + # FUDGE! Generating the snippet image for a sortable entry is sometimes interpreted as + # a request to edit the entry (Selenium problem?) - we dismiss the dialog here and continue. + dlg = find_child( ".ui-dialog", webdriver ) + if dlg and dlg.is_displayed(): + click_dialog_button( "Cancel", webdriver ) + + except ( ConnectionRefusedError, ConnectionResetError, http.client.RemoteDisconnected ): + if shutdown_event.is_set(): + break + raise + + # check the generated snippet + img_data = base64.b64decode( img_data ) + log( "Received snippet image: #bytes={}", len(img_data) ) + assert img_data[:6] == b"\x89PNG\r\n" + +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +def update_vsav_thread( server_url, vsav_fname, vsav_data ): + """Test updating VASL scenario files.""" + + # initialize + vsav_data_b64 = base64.b64encode( vsav_data ).decode( "utf-8" ) + + with WebDriver() as webdriver: + + # initialize + webdriver = webdriver.driver + init_webapp( webdriver, server_url, + [ "vsav_persistence", "scenario_persistence" ] + ) + + # load a test scenario + fname = os.path.join( os.path.split(__file__)[0], "../webapp/tests/fixtures/update-vsav/full.json" ) + saved_scenario = json.load( open( fname, "r" ) ) + load_scenario( saved_scenario, webdriver ) + + while not shutdown_event.is_set(): + + try: + + # send the VSAV data to the front-end to be updated + log( "Updating VSAV: {}", vsav_fname ) + set_stored_msg( "_vsav-persistence_", vsav_data_b64, webdriver ) + select_menu_option( "update_vsav", webdriver ) + start_time = time.time() + + # wait for the front-end to receive the data + wait_for( 2*thread_count, + lambda: get_stored_msg( "_vsav-persistence_", webdriver ) == "" + ) + + # wait for the updated data to arrive + wait_for( 60*thread_count, + lambda: get_stored_msg( "_vsav-persistence_", webdriver ) != "" + ) + elapsed_time = time.time() - start_time + + # get the updated VSAV data + updated_vsav_data = get_stored_msg( "_vsav-persistence_", webdriver ) + if updated_vsav_data.startswith( "ERROR: " ): + raise RuntimeError( updated_vsav_data ) + updated_vsav_data = base64.b64decode( updated_vsav_data ) + + # check the updated VSAV + log( "Received updated VSAV data: #bytes={}", len(updated_vsav_data) ) + assert updated_vsav_data[:2] == b"PK" + + # update the stats + with stats_lock: + stats["update vsav"][0] += 1 + stats["update vsav"][1] += elapsed_time + + except (ConnectionRefusedError, ConnectionResetError, http.client.RemoteDisconnected): + if shutdown_event.is_set(): + break + raise + +# --------------------------------------------------------------------- + +def log( fmt, *args, **kwargs ): + """Log a message.""" + now = time.time() + msec = now - int(now) + now = "{}.{:03d}".format( time.strftime("%H:%M:%S",time.localtime(now)), int(msec*1000) ) + msg = fmt.format( *args, **kwargs ) + msg = "{} | {:17} | {}".format( now, threading.current_thread().name, msg ) + print( msg ) + +# --------------------------------------------------------------------- + +def init_webapp( webdriver, server_url, options ): + """Initialize the webapp.""" + log( "Initializing the webapp." ) + url = server_url + "?" + "&".join( "{}=1".format(opt) for opt in options ) + url += "&store_msgs=1" # nb: stop notification balloons from building up + webdriver.get( url ) + wait_for( 5, lambda: find_child("#_page-loaded_",webdriver) is not None ) + +# --------------------------------------------------------------------- + +if __name__ == "__main__": + main() #pylint: disable=no-value-for-parameter diff --git a/vasl_templates/webapp/__init__.py b/vasl_templates/webapp/__init__.py index 1ab2a5d..4ef3a8d 100644 --- a/vasl_templates/webapp/__init__.py +++ b/vasl_templates/webapp/__init__.py @@ -2,6 +2,7 @@ import sys import os +import signal import configparser import logging import logging.config @@ -28,6 +29,16 @@ def load_debug_config( fname ): # --------------------------------------------------------------------- +cleanup_handlers = [] + +def on_sigint( signum, stack ): #pylint: disable=unused-argument + """Clean up after a SIGINT.""" + for handler in cleanup_handlers: + handler() + raise SystemExit() + +# --------------------------------------------------------------------- + # initialize Flask app = Flask( __name__ ) @@ -65,6 +76,9 @@ if app.config.get( "ENABLE_REMOTE_TEST_CONTROL" ): print( "*** WARNING: Remote test control enabled! ***" ) import vasl_templates.webapp.testing #pylint: disable=cyclic-import +# install our signal handler (must be done in the main thread) +signal.signal( signal.SIGINT, on_sigint ) + # --------------------------------------------------------------------- @app.context_processor diff --git a/vasl_templates/webapp/snippets.py b/vasl_templates/webapp/snippets.py index 2376050..e98bdfb 100644 --- a/vasl_templates/webapp/snippets.py +++ b/vasl_templates/webapp/snippets.py @@ -120,7 +120,7 @@ def make_snippet_image(): # generate an image for the snippet snippet = request.data.decode( "utf-8" ) try: - with WebDriver() as webdriver: + with WebDriver.get_instance() as webdriver: img = webdriver.get_snippet_screenshot( None, snippet ) except SimpleError as ex: return "ERROR: {}".format( ex ) diff --git a/vasl_templates/webapp/static/snippets.js b/vasl_templates/webapp/static/snippets.js index e890d84..683a581 100644 --- a/vasl_templates/webapp/static/snippets.js +++ b/vasl_templates/webapp/static/snippets.js @@ -31,21 +31,26 @@ function generate_snippet( $btn, evt, extra_params ) // check if the user is requesting the snippet as an image if ( evt.shiftKey ) { // yup - send the snippet to the backend to generate the image - var $dlg = $( "#make-snippet-image" ).dialog( { - dialogClass: "make-snippet-image", - modal: true, - width: 300, - height: 60, - resizable: false, - closeOnEscape: false, - } ) ; + var $dlg = null ; + var timeout_id = setTimeout( function() { + $dlg = $( "#make-snippet-image" ).dialog( { + dialogClass: "make-snippet-image", + modal: true, + width: 300, + height: 60, + resizable: false, + closeOnEscape: false, + } ) ; + }, 1*1000 ) ; $.ajax( { url: gMakeSnippetImageUrl, type: "POST", data: snippet.content, contentType: "text/html", } ).done( function( resp ) { - $dlg.dialog( "close" ) ; + clearTimeout( timeout_id ) ; + if ( $dlg ) + $dlg.dialog( "close" ) ; if ( resp.substr( 0, 6 ) === "ERROR:" ) { showErrorMsg( resp.substr(7) ) ; return ; @@ -68,7 +73,9 @@ function generate_snippet( $btn, evt, extra_params ) download( atob(resp), _make_snippet_image_filename(snippet), "image/png" ) ; } } ).fail( function( xhr, status, errorMsg ) { - $dlg.dialog( "close" ) ; + clearTimeout( timeout_id ) ; + if ( $dlg ) + $dlg.dialog( "close" ) ; showErrorMsg( "Can't get the snippet image:
" + escapeHTML(errorMsg) + "
" ) ; } ) ; return ; diff --git a/vasl_templates/webapp/tests/test_scenario_persistence.py b/vasl_templates/webapp/tests/test_scenario_persistence.py index bdb5688..a98f661 100644 --- a/vasl_templates/webapp/tests/test_scenario_persistence.py +++ b/vasl_templates/webapp/tests/test_scenario_persistence.py @@ -287,12 +287,14 @@ def test_unknown_vo( webapp, webdriver ): # --------------------------------------------------------------------- -def load_scenario( scenario ): +def load_scenario( scenario, webdriver=None ): """Load a scenario into the UI.""" - set_stored_msg( "_scenario-persistence_", json.dumps(scenario) ) - _ = set_stored_msg_marker( "_last-info_" ) - select_menu_option( "load_scenario" ) - wait_for( 2, lambda: get_stored_msg("_last-info_") == "The scenario was loaded." ) + set_stored_msg( "_scenario-persistence_", json.dumps(scenario), webdriver ) + _ = set_stored_msg_marker( "_last-info_", webdriver ) + select_menu_option( "load_scenario", webdriver ) + wait_for( 2, + lambda: get_stored_msg( "_last-info_", webdriver ) == "The scenario was loaded." + ) def save_scenario(): """Save the scenario.""" diff --git a/vasl_templates/webapp/tests/test_snippets.py b/vasl_templates/webapp/tests/test_snippets.py index f6f31e4..69f3a5b 100644 --- a/vasl_templates/webapp/tests/test_snippets.py +++ b/vasl_templates/webapp/tests/test_snippets.py @@ -4,7 +4,7 @@ from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys from vasl_templates.webapp.tests.utils import \ - init_webapp, select_tab, select_tab_for_elem, set_template_params, wait_for, wait_for_clipboard, \ + init_webapp, select_tab, find_snippet_buttons, set_template_params, wait_for, wait_for_clipboard, \ get_stored_msg, set_stored_msg_marker, find_child, find_children, adjust_html, \ for_each_template, add_simple_note, edit_simple_note, \ get_sortable_entry_count, generate_sortable_entry_snippet, drag_sortable_entry_to_trash, \ @@ -33,12 +33,6 @@ def test_snippet_ids( webapp, webdriver ): def check_snippet( btn ): """Generate a snippet and check that it has an ID.""" - select_tab_for_elem( btn ) - if not btn.is_displayed(): - # FUDGE! All nationality-specific buttons are created on each OB tab, and the ones not relevant - # to each player are just hidden. This is not real good since we have multiple elements with the same ID :-/ - # but we work around this by checking if the button is visible. Sigh... - return btn.click() wait_for_clipboard( 2, "