""" CMS Video """ import os import os.path import time import requests from bok_choy.javascript import js_defined, wait_for_js from bok_choy.promise import EmptyPromise, Promise from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.keys import Keys from common.test.acceptance.pages.common.utils import sync_on_notification # lint-amnesty, pylint: disable=no-name-in-module from common.test.acceptance.pages.lms.video.video import VideoPage from common.test.acceptance.tests.helpers import YouTubeStubConfig CLASS_SELECTORS = { 'video_container': '.video', 'video_init': '.is-initialized', 'video_xmodule': '.xmodule_VideoBlock', 'video_spinner': '.video-wrapper .spinner', 'video_controls': '.video-controls', 'attach_asset': '.upload-dialog > input[type="file"]', 'upload_dialog': '.wrapper-modal-window-assetupload', 'xblock': '.add-xblock-component', 'slider_range': '.slider-range', 'error': '.transcripts-error-message', 'url_inputs': '.videolist-settings-item input.input', 'collapse_bar': '.videolist-extra-videos', 'status': '.transcripts-message-status', 'attach_transcript': '.file-chooser > input[type="file"]', 'basic_metadata': '.basic_metadata_edit', } BUTTON_SELECTORS = { 'create_video': 'button[data-category="video"]', 'handout_download': '.wrapper-handouts .btn-link', 'handout_download_editor': '.wrapper-comp-setting.file-uploader .download-action', 'upload_asset': '.upload-action', 'asset_submit': '.action-upload', 'handout_clear': '.wrapper-comp-setting.file-uploader .setting-clear', 'translations_clear': '.metadata-video-translations .setting-clear', 'translation_add': '.wrapper-translations-settings > a', 'import': '.setting-import', 'download_to_edit': '.setting-download', 'disabled_download_to_edit': '.setting-download.is-disabled', 'upload_new_timed_transcripts': '.setting-upload', 'replace': '.setting-replace', 'choose': '.setting-choose', 'use_existing': '.setting-use-existing', 'collapse_link': '.collapse-action.collapse-setting', } DROP_DOWN_SELECTORS = { 'transcript_language': '.wrapper-translations-settings .list-settings .list-settings-item select' } DISPLAY_NAME = "Component Display Name" DEFAULT_SETTINGS = [ # basic [DISPLAY_NAME, 'Video', False], ['Default Video URL', 'https://www.youtube.com/watch?v=3_yD_cEKoCk, , ', False], ['Video ID', '', False], # advanced [DISPLAY_NAME, 'Video', False], ['Download Transcript Allowed', 'False', False], ['Downloadable Transcript URL', '', False], ['Show Transcript', 'True', False], ['Transcript Languages', '', False], ['Upload Handout', '', False], ['Video Available on Web Only', 'False', False], ['Video Download Allowed', 'False', False], ['Video File URLs', '', False], ['Video ID', '', False], ['Video Start Time', '00:00:00', False], ['Video Stop Time', '00:00:00', False], ['YouTube ID', '3_yD_cEKoCk', False], ['YouTube ID for .75x speed', '', False], ['YouTube ID for 1.25x speed', '', False], ['YouTube ID for 1.5x speed', '', False] ] # field names without clear button FIELDS_WO_CLEAR = [ 'Transcript Languages' ] # We should wait 300 ms for event handler invocation + 200ms for safety. DELAY = 0.5 @js_defined('window.Video', 'window.jQuery', 'window.XModule', 'window.XBlock', 'window.MathJax') class VideoComponentPage(VideoPage): """ CMS Video Component Page """ url = None @wait_for_js def is_browser_on_page(self): return ( self.q(css='div{}'.format(CLASS_SELECTORS['video_xmodule'])).present or self.q(css='div{}'.format(CLASS_SELECTORS['xblock'])).present ) def get_element_selector(self, class_name, vertical=False): return super().get_element_selector(class_name, vertical=vertical) def _wait_for(self, check_func, desc, result=False, timeout=30): """ Calls the method provided as an argument until the Promise satisfied or BrokenPromise Arguments: check_func (callable): Promise function to be fulfilled. desc (str): Description of the Promise, used in log messages. result (bool): Indicates whether we need result from Promise or not timeout (float): Maximum number of seconds to wait for the Promise to be satisfied before timing out. """ if result: return Promise(check_func, desc, timeout=timeout).fulfill() else: return EmptyPromise(check_func, desc, timeout=timeout).fulfill() def wait_for_video_component_render(self): """ Wait until video component rendered completely """ if not YouTubeStubConfig.get_configuration().get('youtube_api_blocked'): self._wait_for(lambda: self.q(css=CLASS_SELECTORS['video_init']).present, 'Video Player Initialized') self._wait_for(lambda: not self.q(css=CLASS_SELECTORS['video_spinner']).visible, 'Video Buffering Completed') self._wait_for(self.is_controls_visible, 'Player Controls are Visible') def wait_for_message(self, message_type, expected_message): """ Wait until the message of the requested type is as expected. """ self._wait_for(lambda: self.message(message_type) == expected_message, "Waiting for message update.") @wait_for_js def is_controls_visible(self): """ Get current visibility sate of all video controls. Returns: bool: True means video controls are visible for all videos, False means video controls are not visible for one or more videos """ return self.q(css=CLASS_SELECTORS['video_controls']).visible def click_button_subtitles(self): """ Click .setting-replace button after first hovering to it. """ element = self.q(css='.setting-replace')[0] ActionChains(self.browser).move_to_element(element).click(element).perform() def click_button(self, button_name, index=0, require_notification=False): """ Click on a button as specified by `button_name` Arguments: button_name (str): button name index (int): query index """ self.scroll_to_button(button_name, index) self.q(css=BUTTON_SELECTORS[button_name]).nth(index).click() if require_notification: sync_on_notification(self) self.wait_for_ajax() def scroll_to_button(self, button_name, index=0): """ Scroll to a button specified by `button_name` Arguments: button_name (str): button name index (int): query index """ element = self.q(css=BUTTON_SELECTORS[button_name])[index] self.browser.execute_script("arguments[0].scrollIntoView();", element) def get_drop_down_items(self, drop_down_name, index=0): """ Get the items from a drop down list specified by `drop_down_name` Arguments: drop_down_name (str): name of the drop down list index (int): query index """ drop_downs = self.q(css=DROP_DOWN_SELECTORS[drop_down_name]) return drop_downs[index].find_elements_by_tag_name("option") def is_language_disabled(self, lang_code): """ Determine whether or not a lanuage is disabled in a drop down Arguments: lang_code (str): two letter language code """ language_options = self.get_drop_down_items('transcript_language', index=1) language = [l for l in language_options if l.get_attribute('value') == lang_code][0] return language.get_attribute("disabled") @staticmethod def file_path(filename): """ Construct file path to be uploaded to assets. Arguments: filename (str): asset filename """ return os.sep.join(os.path.abspath(__file__).split(os.sep)[:-5]) + '/data/uploads/' + filename def upload_handout(self, handout_filename): """ Upload a handout file to assets Arguments: handout_filename (str): handout file name """ self.upload_asset(handout_filename) def upload_asset(self, asset_filename, asset_type='handout', index=0): """ Upload a asset file to assets Arguments: asset_filename (str): asset file name asset_type (str): one of `handout`, `transcript` index (int): query index """ asset_file_path = self.file_path(asset_filename) self.scroll_to_button('upload_asset') self.click_button('upload_asset', index) self.q(css=CLASS_SELECTORS['attach_asset']).results[0].send_keys(asset_file_path) # Only srt format transcript files can be uploaded, If an error # occurs due to incorrect transcript file we will return from here if asset_type == 'transcript' and self.q(css='#upload_error').present: return self.click_button('asset_submit') # confirm upload completion self._wait_for(lambda: not self.q(css=CLASS_SELECTORS['upload_dialog']).present, 'Upload Completed') def clear_handout(self): """ Clear handout from settings """ self.click_button('handout_clear') def _get_handout(self, url): """ Download handout at `url` """ kwargs = dict() session_id = [{i['name']: i['value']} for i in self.browser.get_cookies() if i['name'] == 'sessionid'] if session_id: kwargs.update({ 'cookies': session_id[0] }) response = requests.get(url, **kwargs) return response.status_code < 400, response.headers def download_handout(self, mime_type, is_editor=False): """ Download handout with mime type specified by `mime_type` Arguments: mime_type (str): mime type of handout file Returns: tuple: Handout download result. """ selector = BUTTON_SELECTORS['handout_download_editor'] if is_editor else BUTTON_SELECTORS['handout_download'] handout_url = self.q(css=selector).attrs('href')[0] result, headers = self._get_handout(handout_url) return result, headers['content-type'] == mime_type @property def is_handout_button_visible(self): """ Check if handout download button is visible """ return self.q(css=BUTTON_SELECTORS['handout_download']).visible def create_video(self): """ Create a Video Component by clicking on Video button and wait for rendering completion. """ # Create video self.click_button('create_video', require_notification=True) self.wait_for_video_component_render() def xblocks(self): """ Tells the total number of video xblocks present on current unit page. Returns: (int): total video xblocks """ return len(self.q(css='.xblock-header').filter( lambda el: 'xblock-header-video' in el.get_attribute('class')).results) def focus_caption_line(self, line_number): """ Focus a caption line as specified by `line_number` Arguments: line_number (int): caption line number """ caption_line_selector = ".subtitles li span[data-index='{index}']".format(index=line_number - 1) self.q(css=caption_line_selector).results[0].send_keys(Keys.ENTER) def is_caption_line_focused(self, line_number): """ Check if a caption line focused Arguments: line_number (int): caption line number """ caption_line_selector = ".subtitles li span[data-index='{index}']".format(index=line_number - 1) caption_container = self.q(css=caption_line_selector).results[0].find_element_by_xpath('..') return 'focused' in caption_container.get_attribute('class').split() @property def is_slider_range_visible(self): """ Return True if slider range is visible. """ return self.q(css=CLASS_SELECTORS['slider_range']).visible def verify_settings(self): """ Verify that video component has correct default settings. """ def _check_settings_length(): """Check video settings""" query = '.wrapper-comp-setting' settings = self.q(css=query).results if len(DEFAULT_SETTINGS) == len(settings): return True, settings return (False, None) settings = Promise(_check_settings_length, 'All video fields are present').fulfill() for counter, setting in enumerate(settings): is_verified = self._verify_setting_entry( setting, DEFAULT_SETTINGS[counter][0], DEFAULT_SETTINGS[counter][1] ) if not is_verified: return is_verified return True @staticmethod def _verify_setting_entry(setting, field_name, field_value): """ Verify a `setting` entry. Arguments: setting (WebElement): Selenium WebElement field_name (str): Name of field field_value (str): Value of field Returns: bool: Does `setting` have correct value. """ if field_name != setting.find_element_by_class_name('setting-label').get_attribute('innerHTML'): return False # Get class attribute values classes = setting.get_attribute('class').split() list_type_classes = ['metadata-list-enum', 'metadata-dict', 'metadata-video-translations'] is_list_type = any(list_type in classes for list_type in list_type_classes) if is_list_type: current_value = ', '.join( ele.get_attribute('value') for ele in setting.find_elements_by_class_name('list-settings-item')) elif 'metadata-videolist-enum' in setting.get_attribute('class'): current_value = ', '.join(item.find_element_by_tag_name('input').get_attribute('value') for item in setting.find_elements_by_class_name('videolist-settings-item')) else: current_value = setting.find_element_by_class_name('setting-input').get_attribute('value') if field_value != current_value: return False # Verify if clear button is active for expected video fields if field_name not in FIELDS_WO_CLEAR and 'metadata-videolist-enum' not in setting.get_attribute('class'): setting_clear_button = setting.find_elements_by_class_name('setting-clear')[0] if 'active' not in setting_clear_button.get_attribute('class'): return False return True def set_field_value(self, field_name, field_value, field_type='input'): """ Set settings input `field` with `value` Arguments: field_name (str): Name of field field_value (str): Name of value field_type (str): `input`, `select` etc(more to be added later) """ query = '.wrapper-comp-setting > label:nth-child(1)' field_id = '' if field_type == 'input': for index, _ in enumerate(self.q(css=query)): if field_name in self.q(css=query).nth(index).text[0]: field_id = self.q(css=query).nth(index).attrs('for')[0] break self.q(css=f'#{field_id}').fill(field_value) elif field_type == 'select': self.q(css=f'select[name="{field_name}"] option[value="{field_value}"]').first.click() def verify_field_value(self, field_name, field_value): """ Get settings value of `field_name` Arguments: field_name (str): Name of field field_value (str): Name of value Returns: bool: If `field_name` has `field_value` """ _, setting = self._get_setting_entry(field_name) return self._verify_setting_entry(setting, field_name, field_value) def _get_setting_entry(self, field_name): """ Get setting entry of `field_name` Arguments: field_name (str): Name of field Returns: setting (WebElement): Selenium WebElement """ for index, setting in enumerate(self.q(css='.wrapper-comp-setting').results): if setting.find_element_by_class_name('setting-label').get_attribute('innerHTML') == field_name: return index, setting def translations_count(self): """ Get count of translations. """ return len(self.q(css='.wrapper-translations-settings .list-settings-item').results) def select_translation_language(self, language_code, index=0): """ Select translation language as specified by `language_code` Arguments: language_code (str): index (int): query index """ translations_items = '.wrapper-translations-settings .list-settings-item' language_selector = translations_items + f' select option[value="{language_code}"]' self.q(css=language_selector).nth(index).click() def upload_translation(self, transcript_name, language_code): """ Upload a translation file. Arguments: transcript_name (str): language_code (str): """ self.click_button('translation_add') translations_count = self.translations_count() self.select_translation_language(language_code, translations_count - 1) self.upload_asset(transcript_name, asset_type='transcript', index=translations_count - 1) def replace_translation(self, old_lang_code, new_lang_code, transcript_name): """ Replace a translation. Arguments: old_lang_code (str): new_lang_code (str): transcript_name (str): """ language_codes = self.translations() index = language_codes.index(old_lang_code) self.select_translation_language(new_lang_code, index) self.upload_asset(transcript_name, asset_type='transcript', index=index) def translations(self): """ Extract translations Returns: list: list of translation language codes """ translations_selector = '.metadata-video-translations .list-settings-item' return self.q(css=translations_selector).attrs('data-original-lang') def download_translation(self, language_code, text_to_search): """ Download a translation having `language_code` and containing `text_to_search` Arguments: language_code (str): language code text_to_search (str): text to search in translation Returns: bool: whether download was successful """ mime_type = 'application/x-subrip' lang_code = f'?language_code={language_code}' link = [link for link in self.q(css='.download-action').attrs('href') if lang_code in link] result, headers, content = self._get_transcript(link[0]) # lint-amnesty, pylint: disable=no-member return result is True and mime_type in headers['content-type'] and text_to_search in content.decode('utf-8') def remove_translation(self, language_code): """ Remove a translation having `language_code` Arguments: language_code (str): language code """ selector = '.metadata-video-translations .list-settings-item' translation = self.q(css=selector).filter(lambda el: language_code == el.get_attribute('data-original-lang')) translation[0].find_element_by_class_name('remove-action').click() @property def upload_status_message(self): """ Get asset upload status message """ return self.q(css='#upload_error').text[0] def captions_lines(self): """ Extract partial caption lines. As all the captions lines are exactly same so only getting partial lines will work. """ self.wait_for_captions() # lint-amnesty, pylint: disable=no-member selector = '.subtitles li:nth-child({})' return ' '.join([self.q(css=selector.format(i)).text[0] for i in range(1, 6)]) def set_url_field(self, url, field_number): """ Set video url field in basic settings tab. Arguments: url (str): video url field_number (int): video url field number """ if self.q(css=CLASS_SELECTORS['collapse_bar']).visible is False: self.click_button('collapse_link') self.q(css=CLASS_SELECTORS['url_inputs']).nth(field_number - 1).fill(url) time.sleep(DELAY) self.wait_for_ajax() def message(self, message_type): """ Get video url field status/error message. Arguments: message_type(str): type(status, error) of message Returns: str: status/error message """ if message_type == 'status': self.wait_for_element_visibility(CLASS_SELECTORS[message_type], f'{message_type.title()} message is Visible') return self.q(css=CLASS_SELECTORS[message_type]).text[0] def url_field_status(self, *field_numbers): """ Get video url field status(enable/disable). Arguments: url (str): video url field_numbers (tuple or None): field numbers to check status for, None means get status for all. tuple items will be integers and must start from 1 Returns: dict: field numbers as keys and field status(bool) as values, False means a field is disabled """ if field_numbers: index_list = [number - 1 for number in field_numbers] else: index_list = list(range(3)) # maximum three fields statuses = {} for index in index_list: status = 'is-disabled' not in self.q(css=CLASS_SELECTORS['url_inputs']).nth(index).attrs('class')[0] statuses[index + 1] = status return statuses def clear_field(self, index): """ Clear a video url field at index specified by `index`. """ self.q(css=CLASS_SELECTORS['url_inputs']).nth(index - 1).fill('') # Trigger an 'input' event after filling the field with an empty value. self.browser.execute_script( "$('{}:eq({})').trigger('{}')".format(CLASS_SELECTORS['url_inputs'], index, 'input')) time.sleep(DELAY) self.wait_for_ajax() def clear_fields(self): """ Clear video url fields. """ script = """ $('{selector}') .prop('disabled', false) .removeClass('is-disabled') .val('') .trigger('input'); """.format(selector=CLASS_SELECTORS['url_inputs']) self.browser.execute_script(script) time.sleep(DELAY) self.wait_for_ajax() def revert_field(self, field_name): """ Revert a field. """ _, setting = self._get_setting_entry(field_name) setting.find_element_by_class_name('setting-clear').click() def is_transcript_button_visible(self, button_name, index=0, button_text=None): """ Check if a transcript related button is visible. Arguments: button_name (str): name of button index (int): query index button_text (str or None): text to match with text on a button, if None then don't match texts Returns: bool: is button visible """ is_visible = self.q(css=BUTTON_SELECTORS[button_name]).nth(index).visible is_text_matched = True if button_text and button_text != self.q(css=BUTTON_SELECTORS[button_name]).nth(index).text[0]: is_text_matched = False return is_visible and is_text_matched def upload_transcript(self, transcript_filename): """ Upload a Transcript Arguments: transcript_filename (str): name of transcript file """ # Show the Browse Button self.browser.execute_script("$('form.file-chooser').show()") asset_file_path = self.file_path(transcript_filename) attach_css = CLASS_SELECTORS['attach_transcript'] self.wait_for_element_visibility(attach_css, "The file chooser's input field is visible.") self.q(css=attach_css).results[0].send_keys(asset_file_path) # confirm upload completion self._wait_for(lambda: not self.q(css=attach_css).visible, 'Upload Completed')