""" Test helper functions and base classes. """ import functools import json import os import sys from datetime import datetime from unittest import SkipTest, TestCase import requests from bok_choy.javascript import js_defined from bok_choy.page_object import XSS_INJECTION from bok_choy.promise import EmptyPromise, Promise from bok_choy.web_app_test import WebAppTest from opaque_keys.edx.locator import CourseLocator from path import Path as path from pymongo import MongoClient # lint-amnesty, pylint: disable=unused-import from selenium.common.exceptions import StaleElementReferenceException from selenium.webdriver.common.keys import Keys from selenium.webdriver.support.select import Select from selenium.webdriver.support.ui import WebDriverWait from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from common.test.acceptance.fixtures.course import XBlockFixtureDesc from common.test.acceptance.pages.common.auto_auth import AutoAuthPage from xmodule.partitions.partitions import UserPartition MAX_EVENTS_IN_FAILURE_OUTPUT = 20 def skip_if_browser(browser): """ Method decorator that skips a test if browser is `browser` Args: browser (str): name of internet browser Returns: Decorated function """ def decorator(test_function): """ The decorator to be applied to the test function. """ @functools.wraps(test_function) def wrapper(self, *args, **kwargs): if self.browser.name == browser: raise SkipTest(f'Skipping as this test will not work with {browser}') test_function(self, *args, **kwargs) return wrapper return decorator def is_youtube_available(): """ Check if the required youtube urls are available. If a URL in `youtube_api_urls` is not reachable then subsequent URLs will not be checked. Returns: bool: """ # TODO: Design and implement a better solution that is reliable and repeatable, # reflects how the application works in production, and limits the third-party # network traffic (e.g. repeatedly retrieving the js from youtube from the browser). youtube_api_urls = { 'main': 'https://www.youtube.com/', 'player': 'https://www.youtube.com/iframe_api', # For transcripts, you need to check an actual video, so we will # just specify our default video and see if that one is available. 'transcript': 'http://video.google.com/timedtext?lang=en&v=3_yD_cEKoCk', } for url in youtube_api_urls.values(): try: response = requests.get(url, allow_redirects=False) except requests.exceptions.ConnectionError: return False if response.status_code >= 300: return False return True def is_focused_on_element(browser, selector): """ Check if the focus is on the element that matches the selector. """ return browser.execute_script(f"return $('{selector}').is(':focus')") def load_data_str(rel_path): """ Load a file from the "data" directory as a string. `rel_path` is the path relative to the data directory. """ full_path = path(__file__).abspath().dirname() / "data" / rel_path with open(full_path) as data_file: return data_file.read() def remove_file(filename): """ Remove a file if it exists """ if os.path.exists(filename): os.remove(filename) def disable_animations(page): """ Disable jQuery and CSS3 animations. """ disable_jquery_animations(page) disable_css_animations(page) def enable_animations(page): """ Enable jQuery and CSS3 animations. """ enable_jquery_animations(page) enable_css_animations(page) @js_defined('window.jQuery') def disable_jquery_animations(page): """ Disable jQuery animations. """ page.browser.execute_script("jQuery.fx.off = true;") @js_defined('window.jQuery') def enable_jquery_animations(page): """ Enable jQuery animations. """ page.browser.execute_script("jQuery.fx.off = false;") def disable_css_animations(page): """ Disable CSS3 animations, transitions, transforms. """ page.browser.execute_script(""" var id = 'no-transitions'; // if styles were already added, just do nothing. if (document.getElementById(id)) { return; } var css = [ '* {', '-webkit-transition: none !important;', '-moz-transition: none !important;', '-o-transition: none !important;', '-ms-transition: none !important;', 'transition: none !important;', '-webkit-transition-property: none !important;', '-moz-transition-property: none !important;', '-o-transition-property: none !important;', '-ms-transition-property: none !important;', 'transition-property: none !important;', '-webkit-transform: none !important;', '-moz-transform: none !important;', '-o-transform: none !important;', '-ms-transform: none !important;', 'transform: none !important;', '-webkit-animation: none !important;', '-moz-animation: none !important;', '-o-animation: none !important;', '-ms-animation: none !important;', 'animation: none !important;', '}' ].join(''), head = document.head || document.getElementsByTagName('head')[0], styles = document.createElement('style'); styles.id = id; styles.type = 'text/css'; if (styles.styleSheet){ styles.styleSheet.cssText = css; } else { styles.appendChild(document.createTextNode(css)); } head.appendChild(styles); """) def enable_css_animations(page): """ Enable CSS3 animations, transitions, transforms. """ page.browser.execute_script(""" var styles = document.getElementById('no-transitions'), head = document.head || document.getElementsByTagName('head')[0]; head.removeChild(styles) """) def select_option_by_text(select_browser_query, option_text, focus_out=False): """ Chooses an option within a select by text (helper method for Select's select_by_visible_text method). Wrap this in a Promise to prevent a StaleElementReferenceException from being raised while the DOM is still being rewritten """ def select_option(query, value): """ Get the first select element that matches the query and select the desired value. """ try: select = Select(query.first.results[0]) select.select_by_visible_text(value) if focus_out: query.first.results[0].send_keys(Keys.TAB) return True except StaleElementReferenceException: return False msg = f'Selected option {option_text}' EmptyPromise(lambda: select_option(select_browser_query, option_text), msg).fulfill() def get_selected_option_text(select_browser_query): """ Returns the text value for the first selected option within a select. Wrap this in a Promise to prevent a StaleElementReferenceException from being raised while the DOM is still being rewritten """ def get_option(query): """ Get the first select element that matches the query and return its value. """ try: select = Select(query.first.results[0]) return (True, select.first_selected_option.text) except StaleElementReferenceException: return (False, None) text = Promise(lambda: get_option(select_browser_query), 'Retrieved selected option text').fulfill() return text def get_options(select_browser_query): """ Returns all the options for the given select. """ return Select(select_browser_query.first.results[0]).options def generate_course_key(org, number, run): """ Makes a CourseLocator from org, number and run """ default_store = os.environ.get('DEFAULT_STORE', 'draft') return CourseLocator(org, number, run, deprecated=(default_store == 'draft')) def select_option_by_value(browser_query, value, focus_out=False): """ Selects a html select element by matching value attribute """ select = Select(browser_query.first.results[0]) select.select_by_value(value) def options_selected(): """ Returns True if all options in select element where value attribute matches `value`. if any option is not selected then returns False and select it. if value is not an option choice then it returns False. """ all_options_selected = True has_option = False for opt in select.options: if opt.get_attribute('value') == value: has_option = True if not opt.is_selected(): all_options_selected = False opt.click() if all_options_selected and not has_option: all_options_selected = False if focus_out: browser_query.first.results[0].send_keys(Keys.TAB) return all_options_selected # Make sure specified option is actually selected EmptyPromise(options_selected, "Option is selected").fulfill() def create_multiple_choice_xml(correct_choice=2, num_choices=4): """ Return the Multiple Choice Problem XML, given the name of the problem. """ # all choices are incorrect except for correct_choice choices = [False for _ in range(num_choices)] choices[correct_choice] = True choice_names = [f'choice_{index}' for index in range(num_choices)] question_text = f'The correct answer is Choice {correct_choice}' return MultipleChoiceResponseXMLFactory().build_xml( question_text=question_text, choices=choices, choice_names=choice_names, ) def create_multiple_choice_problem(problem_name): """ Return the Multiple Choice Problem Descriptor, given the name of the problem. """ xml_data = create_multiple_choice_xml() return XBlockFixtureDesc( 'problem', problem_name, data=xml_data, metadata={'rerandomize': 'always'} ) def auto_auth(browser, username, email, staff, course_id, **kwargs): """ Logout and login with given credentials. """ AutoAuthPage(browser, username=username, email=email, course_id=course_id, staff=staff, **kwargs).visit() class EventsTestMixin(TestCase): """ Helpers and setup for running tests that evaluate events emitted """ def setUp(self): super().setUp() mongo_host = 'edx.devstack.mongo' if 'BOK_CHOY_HOSTNAME' in os.environ else 'localhost' self.event_collection = MongoClient(mongo_host)["test"]["events"] self.start_time = datetime.now() class AcceptanceTest(WebAppTest): """ The base class of all acceptance tests. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # Use long messages so that failures show actual and expected values self.longMessage = True # pylint: disable=invalid-name def tearDown(self): self._save_console_log() super().tearDown() def _save_console_log(self): """ Retrieve any JS errors caught by our error handler in the browser and save them to a log file. This is a workaround for Firefox not supporting the Selenium log capture API yet; for details, see https://github.com/mozilla/geckodriver/issues/284 """ browser_name = os.environ.get('SELENIUM_BROWSER', 'firefox') if browser_name != 'firefox': return result = sys.exc_info() exception_type = result[0] # Do not save for skipped tests. if exception_type is SkipTest: return # If the test failed, save the browser console log. # The exception info will either be an assertion error (on failure) # or an actual exception (on error) if result != (None, None, None): logs = self.browser.execute_script("return window.localStorage.getItem('console_log_capture');") if not logs: return logs = json.loads(logs) log_dir = os.environ.get('SELENIUM_DRIVER_LOG_DIR') if log_dir and not os.path.exists(log_dir): os.makedirs(log_dir) log_path = os.path.join(log_dir, f'{self.id()}_browser.log') with open(log_path, 'w') as browser_log: for (message, url, line_no, col_no, stack) in logs: browser_log.write("{}:{}:{}: {}\n {}\n".format( url, line_no, col_no, message, (stack or "").replace('\n', '\n ') )) class UniqueCourseTest(AcceptanceTest): """ Test that provides a unique course ID. """ def setUp(self): super().setUp() self.course_info = { 'org': 'test_org', 'number': self.unique_id, 'run': 'test_run', 'display_name': 'Test Course' + XSS_INJECTION + self.unique_id } @property def course_id(self): """ Returns the serialized course_key for the test """ # TODO - is there a better way to make this agnostic to the underlying default module store? default_store = os.environ.get('DEFAULT_STORE', 'draft') course_key = CourseLocator( self.course_info['org'], self.course_info['number'], self.course_info['run'], deprecated=(default_store == 'draft') ) return str(course_key) class YouTubeConfigError(Exception): """ Error occurred while configuring YouTube Stub Server. """ pass # lint-amnesty, pylint: disable=unnecessary-pass class YouTubeStubConfig: """ Configure YouTube Stub Server. """ YOUTUBE_HOSTNAME = os.environ.get('BOK_CHOY_HOSTNAME', '127.0.0.1') PORT = 9080 URL = f'http://{YOUTUBE_HOSTNAME}:{PORT}/' @classmethod def configure(cls, config): """ Allow callers to configure the stub server using the /set_config URL. Arguments: config (dict): Configuration dictionary. Raises: YouTubeConfigError """ youtube_stub_config_url = cls.URL + 'set_config' config_data = {param: json.dumps(value) for param, value in config.items()} response = requests.put(youtube_stub_config_url, data=config_data) if not response.ok: raise YouTubeConfigError( 'YouTube Server Configuration Failed. URL {}, Configuration Data: {}, Status was {}'.format( youtube_stub_config_url, config, response.status_code)) @classmethod def reset(cls): """ Reset YouTube Stub Server Configurations using the /del_config URL. Raises: YouTubeConfigError """ youtube_stub_config_url = cls.URL + 'del_config' response = requests.delete(youtube_stub_config_url) if not response.ok: raise YouTubeConfigError( 'YouTube Server Configuration Failed. URL: {} Status was {}'.format( youtube_stub_config_url, response.status_code)) @classmethod def get_configuration(cls): """ Allow callers to get current stub server configuration. Returns: dict """ youtube_stub_config_url = cls.URL + 'get_config' response = requests.get(youtube_stub_config_url) if response.ok: return json.loads(response.content.decode('utf-8')) else: return {} def click_and_wait_for_window(page, element): """ To avoid a race condition, click an element that launces a new window, and wait for that window to launch. To check this, make sure the number of window_handles increases by one. Arguments: page (PageObject): Page object to perform method on element (WebElement): Clickable element that triggers the new window to open """ num_windows = len(page.browser.window_handles) element.click() WebDriverWait(page.browser, 10).until( lambda driver: len(driver.window_handles) > num_windows ) def create_user_partition_json(partition_id, name, description, groups, scheme="random"): """ Helper method to create user partition JSON. If scheme is not supplied, "random" is used. """ # All that is persisted about a scheme is its name. class MockScheme: name = scheme return UserPartition( partition_id, name, description, groups, MockScheme() ).to_json()