""" Helper utilities for the test suite. """ import os import urllib.request import json import time import itertools import uuid import base64 import sqlalchemy import sqlalchemy.orm import sqlalchemy.sql.expression from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.remote.webelement import WebElement from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.by import By from selenium.common.exceptions import NoSuchElementException, StaleElementReferenceException, TimeoutException from asl_articles.utils import to_bool import asl_articles.models _webdriver = None _flask_app = None # nb: this may not be set (if we're talking to an existing Flask server) # --------------------------------------------------------------------- def init_tests( webdriver, flask_app, dbconn, **kwargs ): """Prepare to run tests.""" # initialize global _webdriver, _flask_app _webdriver = webdriver _flask_app = flask_app fixtures_dir = os.path.join( os.path.dirname( __file__ ), "fixtures/" ) # initialize the database fixtures = kwargs.pop( "fixtures", None ) if dbconn: Session = sqlalchemy.orm.sessionmaker( bind=dbconn ) session = Session() load_fixtures( session, fixtures ) else: assert fixtures is None session = None # re-initialize the search engine if flask_app: url = flask_app.url_for( "init_search_for_test" ) with urllib.request.urlopen( url ) as resp: _ = resp.read() # initialize the documents directory dname = kwargs.pop( "docs", None ) if dname: flask_app.config[ "EXTERNAL_DOCS_BASEDIR" ] = os.path.join( fixtures_dir, dname ) else: if flask_app: flask_app.config.pop( "EXTERNAL_DOCS_BASEDIR", None ) # never highlight search results unless explicitly enabled if "no_sr_hilite" not in kwargs: kwargs[ "no_sr_hilite" ] = 1 # load the home page if webdriver: if to_bool( kwargs.pop( "disable_constraints", True ) ): kwargs[ "disable_constraints" ] = 1 if to_bool( kwargs.pop( "disable_confirm_discard_changes", True ) ): kwargs[ "disable_confirm_discard_changes" ] = 1 webdriver.get( webdriver.make_url( "", **kwargs ) ) # FUDGE! Since we switched from running the test Flask server with app.run() to make_server().serve_forever(), # stopping and starting the server seems to be much quicker, but refreshing the page can be slower when # running multiple tests :shrug: wait_for_elem( 10, "#search-form" ) return session # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def load_fixtures( session, fname ): """Load fixtures into the database.""" # load the fixtures if fname: dname = os.path.join( os.path.split(__file__)[0], "fixtures/" ) fname = os.path.join( dname, fname ) with open( fname, "r", encoding="utf-8" ) as fp: data = json.load( fp ) else: data = {} # save the fixture data in the database table_names = [ "publisher", "publication", "article" ] table_names.extend( [ "author", "article_author" ] ) table_names.extend( [ "publisher_image", "publication_image", "article_image" ] ) table_names.extend( [ "scenario", "article_scenario" ] ) for table_name in table_names: model = asl_articles.models.get_model_from_table_name( table_name ) session.query( model ).delete() if table_name in data: session.bulk_insert_mappings( model, data[table_name] ) session.commit() # --------------------------------------------------------------------- def do_search( query ): """Run a search.""" # submit the search query curr_seqno = get_search_seqno() form = find_child( "#search-form" ) assert form elem = find_child( ".query", form ) # FUDGE! Calling elem.clear() then send_keys(query) has a weird effect in Chromium if the query # is empty. The previous query gets repeated instead - is the browser auto-filling the field? actions = ActionChains( _webdriver ).move_to_element( elem ).click() \ .key_down( Keys.CONTROL ).send_keys( "a" ).key_up( Keys.CONTROL ) \ .send_keys( Keys.DELETE ) if query: actions = actions.send_keys( query ) actions.perform() find_child( "button[type='submit']", form ).click() # return the results wait_for( 2, lambda: get_search_seqno() != curr_seqno ) return get_search_results() def get_search_results(): """Get the search results.""" return find_children( "#search-results .search-result" ) def get_search_result_names( results=None ): """Get the names from the search results.""" if not results: results = get_search_results() return [ find_child( ".name", r ).text for r in results ] def find_search_result( name, results=None, wait=0 ): """Find a search result.""" def find_sr(): matches = [ r for r in results or get_search_results() if find_child( ".name", r ).text == name ] return matches[0] if len(matches) == 1 else None return wait_for( wait, find_sr ) def check_search_result( sr, check, expected ): """Check a search result in the UI.""" # figure out which search result to check if not sr: # NOTE: If the caller doesn't explicitly provide a search result, we assume we're working with # a single search result that is already on-screen. results = get_search_results() assert len(results) == 1 sr = results[0] elif isinstance( sr, str ): sr = find_search_result( sr ) else: assert isinstance( sr, WebElement ) # wait for the search result to match what we expect def check_sr(): try: if check( sr, expected ): return sr return None except StaleElementReferenceException: return None # nb: the web page updated while we were checking it return wait_for( 2, check_sr ) def get_search_seqno(): """Get the current search seq#.""" elem = find_child( "#search-results" ) if not elem: return None return elem.get_attribute( "seqno" ) # --------------------------------------------------------------------- def do_test_confirm_discard_changes( menu_id, update_fields=None ): #pylint: disable=too-many-statements """Test confirmation of discarding changes made to a dialog.""" # initialize image_fname = os.path.join( os.path.split(__file__)[0], "fixtures/images/1.gif" ) def get_input_fields( dlg ): input_fields = itertools.chain( find_children( "input", dlg ), find_children( "textarea", dlg ) ) input_fields = { get_field_id(f): f for f in input_fields if f.is_displayed() } # NOTE: Publishers, publications and articles all have an image, but requires special handling. input_fields[ "image" ] = None return input_fields def get_field_id( elem ): if elem.get_attribute( "class" ) == "edition": # FUDGE! The publication dialog has a row with two fields ("name" and "edition"). # We return the "edition" field, the "name" field is handled as a ReactSelect. return "edition" if elem.get_attribute( "class" ) == "pageno": # FUDGE! The article dialog has a row with two fields ("publication" and "pageno"). # We return the "pageno" field, the "publication" field is handled as a ReactSelect. return "pageno" elem = find_parent_by_class( elem, "row" ) classes = set( elem.get_attribute( "class" ).split() ) classes.remove( "row" ) assert len(classes) == 1 return classes.pop() # locate all the input fields select_main_menu_option( menu_id ) dlg = wait_for_elem( 2, ".MuiDialog-root" ) field_ids = get_input_fields( dlg ).keys() find_child( ".cancel", dlg ).click() def update_field( field_id, dlg, elem, setVal, val=None ): # check if we're updating the image if field_id == "image": if setVal: change_image( dlg, image_fname ) else: remove_image( dlg ) return None # check if a custom update function has been provided if update_fields and field_id in update_fields: update_fields[ field_id ][ 0 if setVal else 1 ]( elem ) return None # update the field as text prev_val = elem.get_attribute( "value" ) if val is None: val = "TEST: {}".format( field_id ) if setVal else "" set_elem_text( elem, val ) elem.send_keys( Keys.RETURN ) # nb: in case we have a ReactSelect return prev_val def do_test( open_dialog, setVals ): # test each input field for field_id in field_ids: # NOTE: We can't unset a publication's name once it's been set, so there's no point continuing. if menu_id == "new-publication" and field_id == "name" and not setVals: continue # open the form dialog open_dialog() dlg = wait_for_elem( 2, ".MuiDialog-root" ) input_fields = get_input_fields( dlg ) # change the next input field prev_val = update_field( field_id, dlg, input_fields[field_id], setVals ) # try to cancel the dialog (should get a confirmation dialog) find_child( ".cancel", dlg ).click() ask = wait_for_elem( 2, "#ask" ) assert "Do you want to discard your changes?" in find_child( ".MuiDialogContent-root", ask ).text find_child( ".cancel", ask ).click() # NOTE: We can't unset a publication's name once it's been set, so there's no point continuing. if menu_id == "new-publication" and field_id == "name": find_child( ".cancel", dlg ).click() ask = wait_for_elem( 2, "#ask" ) find_child( ".ok", ask ).click() continue # NOTE: Changing the image will always trigger a confirmation dialog, so there's no point continuing. if field_id == "image" and not setVals: find_child( ".cancel", dlg ).click() ask = wait_for_elem( 2, "#ask" ) find_child( ".ok", ask ).click() continue # restore the original value if isinstance( prev_val, str ): prev_val = " {} ".format( prev_val ) update_field( field_id, dlg, input_fields[field_id], not setVals, prev_val ) # try to cancel the dialog (should work without confirmation) find_child( ".cancel", dlg ).click() ask = wait_for_not_elem( 2, ".MuiDialog-root" ) # test using a blank object do_test( lambda: select_main_menu_option( menu_id ), True ) # test using an object with every field filled in select_main_menu_option( menu_id ) dlg = wait_for_elem( 2, ".MuiDialog-root" ) input_fields = get_input_fields( dlg ) for field_id in input_fields: update_field( field_id, dlg, input_fields[field_id], True ) find_child( ".ok", dlg ).click() results = wait_for( 2, get_search_results ) assert len(results) == 1 do_test( lambda: select_sr_menu_option( results[0], "edit" ), False ) # --------------------------------------------------------------------- def wait_for( timeout, func ): """Wait for a condition to become true.""" return WebDriverWait( _webdriver, timeout, 0.1 ).until( lambda wd: func() ) def wait_for_elem( timeout, sel, visible=True ): """Wait for an element to appear in the DOM.""" func = EC.visibility_of_element_located if visible else EC.presence_of_element_located return WebDriverWait( _webdriver, timeout, 0.1 ).until( func( ( By.CSS_SELECTOR, sel ) ) ) def wait_for_not_elem( timeout, sel ): """Wait for an element to be removed from the DOM.""" return WebDriverWait( _webdriver, timeout, 0.1 ).until( EC.invisibility_of_element_located( ( By.CSS_SELECTOR, sel ) ) ) # --------------------------------------------------------------------- def find_child( sel, parent=None ): """Find a child element.""" try: return (parent if parent else _webdriver).find_element( By.CSS_SELECTOR, sel ) except NoSuchElementException: return None def find_children( sel, parent=None ): """Find child elements.""" try: return (parent if parent else _webdriver).find_elements( By.CSS_SELECTOR, sel ) except NoSuchElementException: return None def find_parent_by_class( elem, class_name ): """Find a parent element with the specified class.""" while True: elem = elem.find_element( By.XPATH, ".." ) if not elem: return None classes = set( elem.get_attribute( "class" ).split() ) if class_name in classes: return elem # --------------------------------------------------------------------- def get_stored_msg( msg_id ): """Get a message stored for us by the front-end.""" elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver ) assert elem.tag_name == "textarea" return elem.get_attribute( "value" ) def set_stored_msg( msg_id, val ): """Set a message for the front-end.""" elem = find_child( _make_stored_msg_elem_id( msg_id ), _webdriver ) assert elem.tag_name == "textarea" _webdriver.execute_script( "arguments[0].value = arguments[1]", elem, val ) def set_stored_msg_marker( msg_id ): """Store something in the message buffer (so we can tell if the front-end changes it).""" marker = "marker:{}:{}".format( msg_id, uuid.uuid4() ) set_stored_msg( msg_id, marker ) return marker def _make_stored_msg_elem_id( msg_id ): return "#_stored_msg-{}_".format( msg_id ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - _TOAST_TYPES = [ "info", "warning", "error" ] def get_toast( toast_type ): """Get a toast message stored for us by the front-end.""" buf = get_stored_msg( _make_toast_stored_msg_id( toast_type ) ) if buf.startswith( "