diff --git a/edxapp_selenium_pages/__init__.py b/edxapp_selenium_pages/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/edxapp_selenium_pages/__init__.py @@ -0,0 +1 @@ + diff --git a/edxapp_selenium_pages/lms/__init__.py b/edxapp_selenium_pages/lms/__init__.py new file mode 100644 index 0000000000..fe4a325a5e --- /dev/null +++ b/edxapp_selenium_pages/lms/__init__.py @@ -0,0 +1,4 @@ +import os + +# Get the URL of the instance under test +BASE_URL = os.environ.get('test_url', '') diff --git a/edxapp_selenium_pages/lms/course_about.py b/edxapp_selenium_pages/lms/course_about.py new file mode 100644 index 0000000000..bd7c8a2dd2 --- /dev/null +++ b/edxapp_selenium_pages/lms/course_about.py @@ -0,0 +1,41 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class CourseAboutPage(PageObject): + """ + Course about page (with registration button) + """ + + @property + def name(self): + return "lms.course_about" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, course_id=None): + """ + URL for the about page of a course. + Course ID is currently of the form "edx/999/2013_Spring" + but this format could change. + """ + if course_id is None: + raise NotImplemented("Must provide a course ID to access about page") + + return BASE_URL + "/courses/" + course_id + "about" + + def is_browser_on_page(self): + return self.is_css_present('section.course-info') + + def register(self): + """ + Register for the course on the page. + """ + self.css_click('a.register') + self.ui.wait_for_page('lms.register') diff --git a/edxapp_selenium_pages/lms/course_info.py b/edxapp_selenium_pages/lms/course_info.py new file mode 100644 index 0000000000..ef1fd7b4cc --- /dev/null +++ b/edxapp_selenium_pages/lms/course_info.py @@ -0,0 +1,42 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class CourseInfoPage(PageObject): + """ + Course info. + """ + + @property + def name(self): + return "lms.course_info" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, course_id=None): + """ + Go directly to the course info page for `course_id`. + (e.g. "edX/Open_DemoX/edx_demo_course") + """ + return BASE_URL + "/courses/" + course_id + "/info" + + def is_browser_on_page(self): + return self.is_css_present('section.updates') + + def num_updates(self): + """ + Return the number of updates on the page. + """ + return self.css_count('section.updates ol li') + + def handout_links(self): + """ + Return a list of handout assets links. + """ + return self.css_map('section.handouts ol li a', lambda el: el['href']) diff --git a/edxapp_selenium_pages/lms/course_nav.py b/edxapp_selenium_pages/lms/course_nav.py new file mode 100644 index 0000000000..c0ffa5f891 --- /dev/null +++ b/edxapp_selenium_pages/lms/course_nav.py @@ -0,0 +1,203 @@ +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise, fulfill_after +from ..lms import BASE_URL + + +class CourseNavPage(PageObject): + """ + Navigate sections and sequences in the courseware. + """ + + @property + def name(self): + return "lms.course_nav" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, **kwargs): + """ + Since course navigation appears on multiple pages, + it doesn't have a particular URL. + """ + raise NotImplemented + + def is_browser_on_page(self): + return self.is_css_present('section.course-index') + + @property + def sections(self): + """ + Return a dictionary representation of sections and subsections. + + Example: + + { + 'Introduction': ['Course Overview'], + 'Week 1': ['Lesson 1', 'Lesson 2', 'Homework'] + 'Final Exam': ['Final Exam'] + } + + You can use these titles in `go_to_section` to navigate to the section. + """ + # Dict to store the result + nav_dict = dict() + + section_titles = self._section_titles() + + # Get the section titles for each chapter + for sec_index in range(len(section_titles)): + + sec_title = section_titles[sec_index] + + if len(section_titles) < 1: + self.warning("Could not find subsections for '{0}'".format(sec_title)) + else: + # Add one to convert list index (starts at 0) to CSS index (starts at 1) + nav_dict[sec_title] = self._subsection_titles(sec_index + 1) + + return nav_dict + + @property + def sequence_items(self): + """ + Return a list of sequence items on the page. + Sequence items are one level below subsections in the course nav. + + Example return value: + ['Chemical Bonds Video', 'Practice Problems', 'Homework'] + """ + seq_css = 'ol#sequence-list>li>a>p' + return self.css_map(seq_css, lambda el: el.html.strip().split('\n')[0]) + + def go_to_section(self, section_title, subsection_title): + """ + Go to the section in the courseware. + Every section must have at least one subsection, so specify + both the section and subsection title. + + Example: + go_to_section("Week 1", "Lesson 1") + """ + + # For test stability, disable JQuery animations (opening / closing menus) + self.disable_jquery_animations() + + # Get the section by index + try: + sec_index = self._section_titles().index(section_title) + except ValueError: + self.warning("Could not find section '{0}'".format(section_title)) + return + + # Click the section to ensure it's open (no harm in clicking twice if it's already open) + # Add one to convert from list index to CSS index + section_css = 'nav>div.chapter:nth-of-type({0})>h3>a'.format(sec_index + 1) + self.css_click(section_css) + + # Get the subsection by index + try: + subsec_index = self._subsection_titles(sec_index + 1).index(subsection_title) + except ValueError: + msg = "Could not find subsection '{0}' in section '{1}'".format(subsection_title, section_title) + self.warning(msg) + return + + # Convert list indices (start at zero) to CSS indices (start at 1) + subsection_css = "nav>div.chapter:nth-of-type({0})>ul>li:nth-of-type({1})>a".format( + sec_index + 1, subsec_index + 1 + ) + + # Click the subsection and ensure that the page finishes reloading + with fulfill_after(self._on_section_promise(section_title, subsection_title)): + self.css_click(subsection_css) + + def go_to_sequential(self, sequential_title): + """ + Within a section/subsection, navigate to the sequential with `sequential_title`. + """ + + # Get the index of the item in the sequence + all_items = self.sequence_items + + try: + seq_index = all_items.index(sequential_title) + + except ValueError: + msg = "Could not find sequential '{0}'. Available sequentials: [{1}]".format( + sequential_title, ", ".join(all_items) + ) + self.warning(msg) + + else: + + # Click on the sequence item at the correct index + # Convert the list index (starts at 0) to a CSS index (starts at 1) + seq_css = "ol#sequence-list>li:nth-of-type({0})>a".format(seq_index + 1) + self.css_click(seq_css) + + def _section_titles(self): + """ + Return a list of all section titles on the page. + """ + chapter_css = 'nav>div.chapter>h3>a' + return self.css_map(chapter_css, lambda el: el.text.strip()) + + def _subsection_titles(self, section_index): + """ + Return a list of all subsection titles on the page + for the section at index `section_index` (starts at 1). + """ + # Retrieve the subsection title for the section + # Add one to the list index to get the CSS index, which starts at one + subsection_css = 'nav>div.chapter:nth-of-type({0})>ul>li>a>p:nth-of-type(1)'.format(section_index) + + # If the element is visible, we can get its text directly + # Otherwise, we need to get the HTML + # It *would* make sense to always get the HTML, but unfortunately + # the open tab has some child tags that we don't want. + return self.css_map( + subsection_css, + lambda el: el.text.strip().split('\n')[0] if el.visible else el.html.strip() + ) + + def _on_section_promise(self, section_title, subsection_title): + """ + Return a `Promise` that is fulfilled when the user is on + the correct section and subsection. + """ + desc = "currently at section '{0}' and subsection '{1}'".format(section_title, subsection_title) + return EmptyPromise( + lambda: self._is_on_section(section_title, subsection_title), desc + ) + + def _is_on_section(self, section_title, subsection_title): + """ + Return a boolean indicating whether the user is on the section and subsection + with the specified titles. + + This assumes that the currently expanded section is the one we're on + That's true right after we click the section/subsection, but not true in general + (the user could go to a section, then expand another tab). + """ + current_section_list = self.css_text('nav>div.chapter.is-open>h3>a') + current_subsection_list = self.css_text('nav>div.chapter.is-open li.active>a>p') + + if len(current_section_list) == 0: + self.warning("Could not find the current section") + return False + + elif len(current_subsection_list) == 0: + self.warning("Could not find current subsection") + return False + + else: + return ( + current_section_list[0].strip() == section_title and + current_subsection_list[0].strip().split('\n')[0] == subsection_title + ) diff --git a/edxapp_selenium_pages/lms/dashboard.py b/edxapp_selenium_pages/lms/dashboard.py new file mode 100644 index 0000000000..6c7f932216 --- /dev/null +++ b/edxapp_selenium_pages/lms/dashboard.py @@ -0,0 +1,62 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class DashboardPage(PageObject): + """ + Student dashboard, where the student can view + courses she/he has registered for. + """ + + @property + def name(self): + return "lms.dashboard" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, **kwargs): + return BASE_URL + "/dashboard" + + def is_browser_on_page(self): + return self.is_css_present('section.my-courses') + + def available_courses(self): + """ + Return list of the names of available courses (e.g. "999 edX Demonstration Course") + """ + return self.css_text('section.info > hgroup > h3 > a') + + def view_course(self, course_id): + """ + Go to the course with `course_id` (e.g. edx/Open_DemoX/edx_demo_course) + """ + link_css = self._link_css(course_id) + + if link_css is not None: + self.css_click(link_css) + else: + msg = "No links found for course {0}".format(course_id) + self.warning(msg) + + def _link_css(self, course_id): + + # Get the link hrefs for all courses + all_links = self.css_map('a.enter-course', lambda el: el['href']) + + # Search for the first link that matches the course id + link_index = None + for index in range(len(all_links)): + if course_id in all_links[index]: + link_index = index + break + + if link_index is not None: + return "a.enter-course:nth-of-type({0})".format(link_index + 1) + else: + return None diff --git a/edxapp_selenium_pages/lms/find_courses.py b/edxapp_selenium_pages/lms/find_courses.py new file mode 100644 index 0000000000..cdf7c0ba36 --- /dev/null +++ b/edxapp_selenium_pages/lms/find_courses.py @@ -0,0 +1,67 @@ +from bok_choy.page_object import PageObject +from bok_choy.promise import BrokenPromise +from ..lms import BASE_URL + + +class FindCoursesPage(PageObject): + """ + Find courses page (main page of the LMS). + """ + + @property + def name(self): + return "lms.find_courses" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + return BASE_URL + + def is_browser_on_page(self): + return self.browser.title == "edX" + + def course_id_list(self): + """ + Retrieve the list of available course IDs + on the page. + """ + return self.css_map('article.course', lambda el: el['id']) + + def go_to_course(self, course_id): + """ + Navigate to the course with `course_id`. + Currently the course id has the form + edx/999/2013_Spring, but this could change. + """ + + # Try clicking the link directly + try: + css = 'a[href="/courses/{0}/about"]'.format(course_id) + + # In most browsers, there are multiple links + # that match this selector, most without text + # In IE 10, only the second one works. + # In IE 9, there is only one link + if self.css_count(css) > 1: + index = 1 + else: + index = 0 + + self.css_click(css + ":nth-of-type({0})".format(index)) + + # Chrome gives an error that another element would receive the click. + # So click higher up in the DOM + except BrokenPromise: + # We need to escape forward slashes in the course_id + # to create a valid CSS selector + course_id = course_id.replace('/', '\/') + self.css_click('article.course#{0}'.format(course_id)) + + # Ensure that we end up on the next page + self.ui.wait_for_page('lms.course_about') diff --git a/edxapp_selenium_pages/lms/info.py b/edxapp_selenium_pages/lms/info.py new file mode 100644 index 0000000000..722519c700 --- /dev/null +++ b/edxapp_selenium_pages/lms/info.py @@ -0,0 +1,61 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class InfoPage(PageObject): + """ + Info pages for the main site. + These are basically static pages, so we use one page + object to represent them all. + """ + + # Dictionary mapping section names to URL paths + SECTION_PATH = { + 'about': '/about', + 'faq': '/faq', + 'press': '/press', + 'contact': '/contact', + 'terms': '/tos', + 'privacy': '/privacy', + 'honor': '/honor', + } + + # Dictionary mapping URLs to expected css selector + EXPECTED_CSS = { + '/about': 'section.vision', + '/faq': 'section.faq', + '/press': 'section.press', + '/contact': 'section.contact', + '/tos': 'section.tos', + '/privacy': 'section.privacy-policy', + '/honor': 'section.honor-code', + } + + @property + def name(self): + return "lms.info" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, section=None): + return BASE_URL + self.SECTION_PATH[section] + + def is_browser_on_page(self): + + # Find the appropriate css based on the URL + for url_path, css_sel in self.EXPECTED_CSS.iteritems(): + if self.browser.url.endswith(url_path): + return self.is_css_present(css_sel) + + # Could not find the CSS based on the URL + return False + + @classmethod + def sections(cls): + return cls.SECTION_PATH.keys() diff --git a/edxapp_selenium_pages/lms/login.py b/edxapp_selenium_pages/lms/login.py new file mode 100644 index 0000000000..20bb115853 --- /dev/null +++ b/edxapp_selenium_pages/lms/login.py @@ -0,0 +1,45 @@ +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise, fulfill_after +from ..lms import BASE_URL + + +class LoginPage(PageObject): + """ + Login page for the LMS. + """ + + @property + def name(self): + return "lms.login" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + return BASE_URL + "/login" + + def is_browser_on_page(self): + return any([ + 'log in' in title.lower() + for title in self.css_text('span.title-super') + ]) + + def login(self, email, password): + """ + Attempt to log in using `email` and `password`. + """ + # Ensure that we make it to another page + on_next_page = EmptyPromise( + lambda: "login" not in self.browser.url, + "redirected from the login page" + ) + + with fulfill_after(on_next_page): + self.css_fill('input#email', email) + self.css_fill('input#password', password) + self.css_click('button#submit') diff --git a/edxapp_selenium_pages/lms/open_response.py b/edxapp_selenium_pages/lms/open_response.py new file mode 100644 index 0000000000..27ef3d58d2 --- /dev/null +++ b/edxapp_selenium_pages/lms/open_response.py @@ -0,0 +1,241 @@ +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise, fulfill_after, fulfill_before + + +class OpenResponsePage(PageObject): + """ + Open-ended response in the courseware. + """ + + @property + def name(self): + return "lms.open_response" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + """ + Open-response isn't associated with a particular URL. + """ + raise NotImplemented + + def is_browser_on_page(self): + return self.is_css_present('section.xmodule_CombinedOpenEndedModule') + + @property + def assessment_type(self): + """ + Return the type of assessment currently active. + Options are "self", "ai", or "peer" + """ + labels = self.css_text('section#combined-open-ended-status>div.statusitem-current') + + if len(labels) < 1: + self.warning("Could not find assessment type label") + + # Provide some tolerance to UI changes + label_compare = labels[0].lower().strip() + + if 'self' in label_compare: + return 'self' + elif 'ai' in label_compare: + return 'ai' + elif 'peer' in label_compare: + return 'peer' + else: + raise ValueError("Unexpected assessment type: '{0}'".format(label)) + + @property + def prompt(self): + """ + Return an HTML string representing the essay prompt. + """ + prompt_css = "section.open-ended-child>div.prompt" + prompts = self.css_map(prompt_css, lambda el: el.html.strip()) + + if len(prompts) == 0: + self.warning("Could not find essay prompt on page.") + return "" + + elif len(prompts) > 1: + self.warning("Multiple essay prompts found on page; using the first one.") + + return prompts[0] + + @property + def has_rubric(self): + """ + Return a boolean indicating whether the rubric is available. + """ + return self.is_css_present('div.rubric') + + @property + def rubric_categories(self): + """ + Return a list of categories available in the essay rubric. + + Example: + ["Writing Applications", "Language Conventions"] + + The rubric is not always visible; if it's not available, + this will return an empty list. + """ + return self.css_text('span.rubric-category') + + @property + def rubric_feedback(self): + """ + Return a list of correct/incorrect feedback for each rubric category (e.g. from self-assessment). + Example: ['correct', 'incorrect'] + + If no feedback is available, returns an empty list. + If feedback could not be interpreted (unexpected CSS class), + the list will contain a `None` item. + """ + + # Get the green checkmark / red x labels + # We need to filter out the similar-looking CSS classes + # for the rubric items that are NOT marked correct/incorrect + feedback_css = 'div.rubric-label>label' + labels = filter( + lambda el_class: el_class != 'rubric-elements-info', + self.css_map(feedback_css, lambda el: el['class']) + ) + + # Map CSS classes on the labels to correct/incorrect + def map_feedback(css_class): + if 'choicegroup_incorrect' in css_class: + return 'incorrect' + elif 'choicegroup_correct' in css_class: + return 'correct' + else: + return None + + return map(map_feedback, labels) + + @property + def alert_message(self): + """ + Alert message displayed to the user. + """ + alerts = self.css_text("div.open-ended-alert") + + if len(alerts) < 1: + return "" + else: + return alerts[0] + + @property + def grader_status(self): + """ + Status message from the grader. + If not present, return an empty string. + """ + status_list = self.css_text('div.grader-status') + + if len(status_list) < 1: + self.warning("No grader status found") + return "" + + elif len(status_list) > 1: + self.warning("Multiple grader statuses found; returning the first one") + + return status_list[0] + + def set_response(self, response_str): + """ + Input a response to the prompt. + """ + input_css = "textarea.short-form-response" + self.css_fill(input_css, response_str) + + def save_response(self): + """ + Save the response for later submission. + """ + status_msg_shown = EmptyPromise( + lambda: 'save' in self.alert_message.lower(), + "Status message saved" + ) + + with fulfill_after(status_msg_shown): + self.css_click('input.save-button') + + def submit_response(self): + """ + Submit a response for grading. + """ + with fulfill_after(self._submitted_promise(self.assessment_type)): + with self.handle_alert(): + self.css_click('input.submit-button') + + def submit_self_assessment(self, scores): + """ + Submit a self-assessment rubric. + `scores` is a list of scores (0 to max score) for each category in the rubric. + """ + + # Warn if we have the wrong number of scores + num_categories = len(self.rubric_categories) + if len(scores) != num_categories: + msg = "Recieved {0} scores but there are {1} rubric categories".format( + len(scores), num_categories + ) + self.warning(msg) + + # Set the score for each category + for score_index in range(len(scores)): + + # Check that we have the enough radio buttons + category_css = "div.rubric>ul.rubric-list:nth-of-type({0})".format(score_index + 1) + if scores[score_index] > self.css_count(category_css + ' input.score-selection'): + msg = "Tried to select score {0} but there are only {1} options".format(score_num, len(inputs)) + self.warning(msg) + + # Check the radio button at the correct index + else: + input_css = (category_css + + ">li.rubric-list-item:nth-of-type({0}) input.score-selection".format( + scores[score_index] + 1) + ) + self.css_check(input_css) + + # Wait for the button to become enabled + button_css = 'input.submit-button' + button_enabled = EmptyPromise( + lambda: all(self.css_map(button_css, lambda el: not el['disabled'])), + "Submit button enabled" + ) + + # Submit the assessment + with fulfill_before(button_enabled): + self.css_click(button_css) + + def _submitted_promise(self, assessment_type): + """ + Return a `Promise` that the next step is visible after submitting. + This will vary based on the type of assessment. + + `assessment_type` is either 'self', 'ai', or 'peer' + """ + if assessment_type == 'self': + return EmptyPromise(lambda: self.has_rubric, "Rubric has appeared") + + elif assessment_type == 'ai': + return EmptyPromise( + lambda: self.grader_status != 'Unanswered', + "Problem status is no longer 'unanswered'" + ) + + elif assessment_type == 'peer': + return EmptyPromise(lambda: False, "Peer assessment not yet implemented") + + else: + self.warning("Unrecognized assessment type '{0}'".format(assessment_type)) + return EmptyPromise(lambda: True, "Unrecognized assessment type") diff --git a/edxapp_selenium_pages/lms/progress.py b/edxapp_selenium_pages/lms/progress.py new file mode 100644 index 0000000000..88c2e7b2ab --- /dev/null +++ b/edxapp_selenium_pages/lms/progress.py @@ -0,0 +1,111 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class ProgressPage(PageObject): + """ + Student progress page. + """ + + @property + def name(self): + return "lms.progress" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, course_id=None): + return BASE_URL + "/courses/" + course_id + "/progress" + + def is_browser_on_page(self): + has_course_info = self.is_css_present('section.course-info') + has_graph = self.is_css_present('div#grade-detail-graph') + return has_course_info and has_graph + + def scores(self, chapter, section): + """ + Return a list of (points, max_points) tuples representing the scores + for the section. + + Example: + section_scores('Week 1', 'Lesson 1', 2) --> [(2, 4), (0, 1)] + + Returns `None` if no such chapter and section can be found. + """ + + # Find the index of the section in the chapter + chapter_index = self._chapter_index(chapter) + if chapter_index is None: + return None + + section_index = self._section_index(chapter_index, section) + if section_index is None: + return None + + # Retrieve the scores for the section + return self._section_scores(chapter_index, section_index) + + def _chapter_index(self, title): + """ + Return the CSS index of the chapter with `title`. + Returns `None` if it cannot find such a chapter. + """ + chapter_css = 'ol.chapters li h2' + chapter_titles = self.css_map(chapter_css, lambda el: el.text.lower().strip()) + + try: + # CSS indices are 1-indexed, so add one to the list index + return chapter_titles.index(title.lower()) + 1 + except ValueError: + self.warning("Could not find chapter '{0}'".format(title)) + return None + + def _section_index(self, chapter_index, title): + """ + Return the CSS index of the section with `title` in the chapter at `chapter_index`. + Returns `None` if it can't find such a section. + """ + + # This is a hideous CSS selector that means: + # Get the links containing the section titles in `chapter_index`. + # The link text is the section title. + section_css = 'ol.chapters>li:nth-of-type({0}) ol.sections li h3 a'.format(chapter_index) + section_titles = self.css_map(section_css, lambda el: el.text.lower().strip()) + + # The section titles also contain "n of m possible points" on the second line + # We have to remove this to find the right title + section_titles = [title.split('\n')[0] for title in section_titles] + + # Some links are blank, so remove them + section_titles = [title for title in section_titles if title] + + try: + # CSS indices are 1-indexed, so add one to the list index + return section_titles.index(title.lower()) + 1 + except ValueError: + self.warning("Could not find section '{0}'".format(title)) + return None + + def _section_scores(self, chapter_index, section_index): + """ + Return a list of `(points, max_points)` tuples representing + the scores in the specified chapter and section. + + `chapter_index` and `section_index` start at 1. + """ + # This is CSS selector means: + # Get the scores for the chapter at `chapter_index` and the section at `section_index` + # Example text of the retrieved elements: "0/1" + score_css = "ol.chapters>li:nth-of-type({0}) ol.sections>li:nth-of-type({1}) section.scores>ol>li".format( + chapter_index, section_index + ) + + text_scores = self.css_text(score_css) + + # Convert text scores to tuples of (points, max_points) + return [tuple(map(int, score.split('/'))) for score in text_scores] diff --git a/edxapp_selenium_pages/lms/register.py b/edxapp_selenium_pages/lms/register.py new file mode 100644 index 0000000000..9d52c190eb --- /dev/null +++ b/edxapp_selenium_pages/lms/register.py @@ -0,0 +1,56 @@ +from bok_choy.page_object import PageObject +from ..lms import BASE_URL + + +class RegisterPage(PageObject): + """ + Registration page (create a new account) + """ + + @property + def name(self): + return "lms.register" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, course_id=None): + """ + URL for the registration page of a course. + Course ID is currently of the form "edx/999/2013_Spring" + but this format could change. + """ + if course_id is None: + raise NotImplemented("Must provide a course ID to access about page") + + return BASE_URL + "/register?course_id=" + course_id + "&enrollment_action=enroll" + + def is_browser_on_page(self): + return any([ + 'register' in title.lower() + for title in self.css_text('span.title-sub') + ]) + + def provide_info(self, credentials): + """ + Fill in registration info. + + `credentials` is a `TestCredential` object. + """ + self.css_fill('input#email', credentials.email) + self.css_fill('input#password', credentials.password) + self.css_fill('input#username', credentials.username) + self.css_fill('input#name', credentials.full_name) + self.css_check('input#tos-yes') + self.css_check('input#honorcode-yes') + + def submit(self): + """ + Submit registration info to create an account. + """ + self.css_click('button#submit') diff --git a/edxapp_selenium_pages/lms/tab_nav.py b/edxapp_selenium_pages/lms/tab_nav.py new file mode 100644 index 0000000000..3ec8c15141 --- /dev/null +++ b/edxapp_selenium_pages/lms/tab_nav.py @@ -0,0 +1,83 @@ +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise, fulfill_after +from ..lms import BASE_URL + + +class TabNavPage(PageObject): + """ + High-level tab navigation. + """ + + @property + def name(self): + return "lms.tab_nav" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self, **kwargs): + """ + Since tab navigation appears on multiple pages, + it doesn't have a particular URL. + """ + raise NotImplemented + + def is_browser_on_page(self): + return self.is_css_present('ol.course-tabs') + + def go_to_tab(self, tab_name): + """ + Navigate to the tab `tab_name`. + """ + if tab_name not in ['Courseware', 'Course Info', 'Discussion', 'Wiki', 'Progress']: + self.warning("'{0}' is not a valid tab name".format(tab_name)) + + # The only identifier for individual tabs is the link href + # so we find the tab with `tab_name` in its text. + tab_css = self._tab_css(tab_name) + + with fulfill_after(self._is_on_tab_promise(tab_name)): + if tab_css is not None: + self.css_click(tab_css) + else: + self.warning("No tabs found for '{0}'".format(tab_name)) + + def _tab_css(self, tab_name): + """ + Return the CSS to click for `tab_name`. + """ + all_tabs = self.css_text('ol.course-tabs li a') + + try: + tab_index = all_tabs.index(tab_name) + except ValueError: + return None + else: + return 'ol.course-tabs li:nth-of-type({0}) a'.format(tab_index + 1) + + def _is_on_tab_promise(self, tab_name): + """ + Return a `Promise` that the user is on the tab `tab_name`. + """ + return EmptyPromise( + lambda: self._is_on_tab(tab_name), + "{0} is the current tab".format(tab_name) + ) + + def _is_on_tab(self, tab_name): + """ + Return a boolean indicating whether the current tab is `tab_name`. + """ + current_tab_list = self.css_text('ol.course-tabs>li>a.active') + + if len(current_tab_list) == 0: + self.warning("Could not find current tab") + return False + + else: + return (current_tab_list[0].strip().split('\n')[0] == tab_name) diff --git a/edxapp_selenium_pages/lms/video.py b/edxapp_selenium_pages/lms/video.py new file mode 100644 index 0000000000..142da8b608 --- /dev/null +++ b/edxapp_selenium_pages/lms/video.py @@ -0,0 +1,106 @@ +import time + +from bok_choy.page_object import PageObject +from bok_choy.promise import EmptyPromise, fulfill_after +from ..lms import BASE_URL + + +class VideoPage(PageObject): + """ + Video player in the courseware. + """ + + @property + def name(self): + return "lms.video" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + """ + Video players aren't associated with a particular URL. + """ + raise NotImplemented + + def is_browser_on_page(self): + return self.is_css_present('section.xmodule_VideoModule') + + @property + def elapsed_time(self): + """ + Amount of time elapsed since the start of the video, in seconds. + """ + elapsed, _ = self._video_time() + return elapsed + + @property + def duration(self): + """ + Total duration of the video, in seconds. + """ + _, duration = self._video_time() + return duration + + @property + def is_playing(self): + """ + Return a boolean indicating whether the video is playing. + """ + return self.is_css_present('a.video_control') and self.is_css_present('a.video_control.pause') + + @property + def is_paused(self): + """ + Return a boolean indicating whether the video is paused. + """ + return self.is_css_present('a.video_control') and self.is_css_present('a.video_control.play') + + def play(self): + """ + Start playing the video. + """ + with fulfill_after( + EmptyPromise(lambda: self.is_playing, "Video is playing") + ): + self.css_click('a.video_control.play') + + def pause(self): + """ + Pause the video. + """ + with fulfill_after( + EmptyPromise(lambda: self.is_paused, "Video is paused") + ): + self.css_click('a.video_control.pause') + + def _video_time(self): + """ + Return a tuple `(elapsed_time, duration)`, each in seconds. + """ + # The full time has the form "0:32 / 3:14" + all_times = self.css_text('div.vidtime') + + if len(all_times) == 0: + self.warning('Could not find video time') + + else: + full_time = all_times[0] + + # Split the time at the " / ", to get ["0:32", "3:14"] + elapsed_str, duration_str = full_time.split(' / ') + + # Convert each string to seconds + return (self._parse_time_str(elapsed_str), self._parse_time_str(duration_str)) + + def _parse_time_str(self, time_str): + """ + Parse a string of the form 1:23 into seconds (int). + """ + time_obj = time.strptime(time_str, '%M:%S') + return time_obj.tm_min * 60 + time_obj.tm_sec diff --git a/edxapp_selenium_pages/studio/__init__.py b/edxapp_selenium_pages/studio/__init__.py new file mode 100644 index 0000000000..fe4a325a5e --- /dev/null +++ b/edxapp_selenium_pages/studio/__init__.py @@ -0,0 +1,4 @@ +import os + +# Get the URL of the instance under test +BASE_URL = os.environ.get('test_url', '') diff --git a/edxapp_selenium_pages/studio/howitworks.py b/edxapp_selenium_pages/studio/howitworks.py new file mode 100644 index 0000000000..2c3ef5649a --- /dev/null +++ b/edxapp_selenium_pages/studio/howitworks.py @@ -0,0 +1,26 @@ +from bok_choy.page_object import PageObject +from ..studio import BASE_URL + + +class HowitworksPage(PageObject): + """ + Home page for Studio when not logged in. + """ + + @property + def name(self): + return "studio.howitworks" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + return BASE_URL + "/howitworks" + + def is_browser_on_page(self): + return self.browser.title == 'Welcome | edX Studio' diff --git a/edxapp_selenium_pages/studio/login.py b/edxapp_selenium_pages/studio/login.py new file mode 100644 index 0000000000..c265235b82 --- /dev/null +++ b/edxapp_selenium_pages/studio/login.py @@ -0,0 +1,34 @@ +from bok_choy.page_object import PageObject +from ..studio import BASE_URL + + +class LoginPage(PageObject): + """ + Login page for Studio. + """ + + @property + def name(self): + return "studio.login" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + return BASE_URL + "/signin" + + def is_browser_on_page(self): + return self.browser.title == 'Sign In | edX Studio' + + def login(self, email, password): + """ + Attempt to log in using `email` and `password`. + """ + self.css_fill('input#email', email) + self.css_fill('input#password', password) + self.css_click('button#submit') diff --git a/edxapp_selenium_pages/studio/signup.py b/edxapp_selenium_pages/studio/signup.py new file mode 100644 index 0000000000..3045770bab --- /dev/null +++ b/edxapp_selenium_pages/studio/signup.py @@ -0,0 +1,26 @@ +from bok_choy.page_object import PageObject +from ..studio import BASE_URL + + +class SignupPage(PageObject): + """ + Signup page for Studio. + """ + + @property + def name(self): + return "studio.signup" + + @property + def requirejs(self): + return [] + + @property + def js_globals(self): + return [] + + def url(self): + return BASE_URL + "/signup" + + def is_browser_on_page(self): + return self.browser.title == 'Sign Up | edX Studio' diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 798c40f6bd..7962b65c6f 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -21,3 +21,4 @@ -e git+https://github.com/edx/js-test-tool.git@v0.1.4#egg=js_test_tool -e git+https://github.com/edx/django-waffle.git@823a102e48#egg=django-waffle -e git+https://github.com/edx/event-tracking.git@f0211d702d#egg=event-tracking +-e git+https://github.com/edx/bok-choy.git@bc6f1adbe439618162079f1004b2b3db3b6f8916#egg=bok_choy diff --git a/setup.py b/setup.py new file mode 100644 index 0000000000..d4722a1b9d --- /dev/null +++ b/setup.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python + +""" +Install Selenium page objects for acceptance and end-to-end tests. +""" + +from setuptools import setup + +VERSION = '0.0.1' +DESCRIPTION = "Selenium page objects for edx-platform" + +setup( + name='edx-selenium-pages', + version=VERSION, + author='edX', + url='http://github.com/edx/edx-platform', + description=DESCRIPTION, + license='AGPL', + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU Affero General Public License v3', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Topic :: Software Development :: Testing', + 'Topic :: Software Development :: Quality Assurance' + ], + packages=['edxapp_selenium_pages'] +)