diff --git a/common/test/acceptance/edxapp_pages/lms/course_about.py b/common/test/acceptance/edxapp_pages/lms/course_about.py index db88513b2e..b7dacd4ba6 100644 --- a/common/test/acceptance/edxapp_pages/lms/course_about.py +++ b/common/test/acceptance/edxapp_pages/lms/course_about.py @@ -2,33 +2,28 @@ Course about page (with registration button) """ -from bok_choy.page_object import PageObject -from . import BASE_URL +from .course_page import CoursePage +from .register import RegisterPage -class CourseAboutPage(PageObject): +class CourseAboutPage(CoursePage): """ Course about page (with registration button) """ - name = "lms.course_about" - def url(self, course_id=None): #pylint: disable=W0221 - """ - 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 NotImplementedError("Must provide a course ID to access about page") - - return BASE_URL + "/courses/" + course_id + "/about" + URL_PATH = "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. + Navigate to the registration page. + Waits for the registration page to load, then + returns the registration page object. """ self.css_click('a.register') - self.ui.wait_for_page('lms.register') + + registration_page = RegisterPage(self.browser, self.course_id) + registration_page.wait_for_page() + return registration_page diff --git a/common/test/acceptance/edxapp_pages/lms/course_info.py b/common/test/acceptance/edxapp_pages/lms/course_info.py index 97ffe5a6f3..cdccf7343b 100644 --- a/common/test/acceptance/edxapp_pages/lms/course_info.py +++ b/common/test/acceptance/edxapp_pages/lms/course_info.py @@ -2,33 +2,27 @@ Course info page. """ -from bok_choy.page_object import PageObject -from . import BASE_URL +from .course_page import CoursePage -class CourseInfoPage(PageObject): +class CourseInfoPage(CoursePage): """ Course info. """ - name = "lms.course_info" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - 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" + URL_PATH = "info" def is_browser_on_page(self): return self.is_css_present('section.updates') + @property def num_updates(self): """ Return the number of updates on the page. """ return self.css_count('section.updates ol li') + @property def handout_links(self): """ Return a list of handout assets links. diff --git a/common/test/acceptance/edxapp_pages/lms/course_nav.py b/common/test/acceptance/edxapp_pages/lms/course_nav.py index b8d6c8b023..9fb7bcc4f6 100644 --- a/common/test/acceptance/edxapp_pages/lms/course_nav.py +++ b/common/test/acceptance/edxapp_pages/lms/course_nav.py @@ -12,14 +12,7 @@ class CourseNavPage(PageObject): Navigate sections and sequences in the courseware. """ - name = "lms.course_nav" - - def url(self, **kwargs): - """ - Since course navigation appears on multiple pages, - it doesn't have a particular URL. - """ - raise NotImplementedError + url = None def is_browser_on_page(self): return self.is_css_present('section.course-index') diff --git a/common/test/acceptance/edxapp_pages/lms/course_page.py b/common/test/acceptance/edxapp_pages/lms/course_page.py new file mode 100644 index 0000000000..40c5fd031c --- /dev/null +++ b/common/test/acceptance/edxapp_pages/lms/course_page.py @@ -0,0 +1,31 @@ +""" +Base class for pages in courseware. +""" + +from bok_choy.page_object import PageObject +from . import BASE_URL + + +class CoursePage(PageObject): + """ + Abstract base class for page objects within a course. + """ + + # Overridden by subclasses to provide the relative path within the course + # Paths should not include the leading forward slash. + URL_PATH = "" + + def __init__(self, browser, course_id): + """ + Course ID is currently of the form "edx/999/2013_Spring" + but this format could change. + """ + super(CoursePage, self).__init__(browser) + self.course_id = course_id + + @property + def url(self): + """ + Construct a URL to the page within the course. + """ + return BASE_URL + "/courses/" + self.course_id + "/" + self.URL_PATH diff --git a/common/test/acceptance/edxapp_pages/lms/dashboard.py b/common/test/acceptance/edxapp_pages/lms/dashboard.py index acf2556ac1..0f768af60d 100644 --- a/common/test/acceptance/edxapp_pages/lms/dashboard.py +++ b/common/test/acceptance/edxapp_pages/lms/dashboard.py @@ -12,19 +12,22 @@ class DashboardPage(PageObject): courses she/he has registered for. """ - name = "lms.dashboard" - - def url(self, **kwargs): - return BASE_URL + "/dashboard" + url = BASE_URL + "/dashboard" def is_browser_on_page(self): return self.is_css_present('section.my-courses') + @property 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 _get_course_name(el): + # The first component in the link text is the course number + _, course_name = el.text.split(' ', 1) + return course_name + + return self.css_map('section.info > hgroup > h3 > a', _get_course_name) def view_course(self, course_id): """ diff --git a/common/test/acceptance/edxapp_pages/lms/find_courses.py b/common/test/acceptance/edxapp_pages/lms/find_courses.py index 55fb883787..33f2c02b35 100644 --- a/common/test/acceptance/edxapp_pages/lms/find_courses.py +++ b/common/test/acceptance/edxapp_pages/lms/find_courses.py @@ -12,50 +12,15 @@ class FindCoursesPage(PageObject): Find courses page (main page of the LMS). """ - name = "lms.find_courses" - - def url(self): - return BASE_URL + url = BASE_URL def is_browser_on_page(self): return self.browser.title == "edX" + @property 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('/', r'\/') - 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/common/test/acceptance/edxapp_pages/lms/login.py b/common/test/acceptance/edxapp_pages/lms/login.py index 5012b32935..c2691e1532 100644 --- a/common/test/acceptance/edxapp_pages/lms/login.py +++ b/common/test/acceptance/edxapp_pages/lms/login.py @@ -12,10 +12,7 @@ class LoginPage(PageObject): Login page for the LMS. """ - name = "lms.login" - - def url(self): - return BASE_URL + "/login" + url = BASE_URL + "/login" def is_browser_on_page(self): return any([ diff --git a/common/test/acceptance/edxapp_pages/lms/open_response.py b/common/test/acceptance/edxapp_pages/lms/open_response.py index c61b6017bd..cc042d1199 100644 --- a/common/test/acceptance/edxapp_pages/lms/open_response.py +++ b/common/test/acceptance/edxapp_pages/lms/open_response.py @@ -11,13 +11,7 @@ class OpenResponsePage(PageObject): Open-ended response in the courseware. """ - name = "lms.open_response" - - def url(self): - """ - Open-response isn't associated with a particular URL. - """ - raise NotImplementedError + url = None def is_browser_on_page(self): return self.is_css_present('section.xmodule_CombinedOpenEndedModule') @@ -116,6 +110,19 @@ class OpenResponsePage(PageObject): return map(map_feedback, labels) + @property + def written_feedback(self): + """ + Return the written feedback from the grader (if any). + If no feedback available, returns None. + """ + feedback = self.css_text('div.written-feedback') + + if len(feedback) > 0: + return feedback[0] + else: + return None + @property def alert_message(self): """ diff --git a/common/test/acceptance/edxapp_pages/lms/progress.py b/common/test/acceptance/edxapp_pages/lms/progress.py index af496234e1..9a6d8882a1 100644 --- a/common/test/acceptance/edxapp_pages/lms/progress.py +++ b/common/test/acceptance/edxapp_pages/lms/progress.py @@ -2,19 +2,15 @@ Student progress page """ -from bok_choy.page_object import PageObject -from . import BASE_URL +from .course_page import CoursePage -class ProgressPage(PageObject): +class ProgressPage(CoursePage): """ Student progress page. """ - name = "lms.progress" - - def url(self, course_id=None): #pylint: disable=W0221 - return BASE_URL + "/courses/" + course_id + "/progress" + URL_PATH = "progress" def is_browser_on_page(self): has_course_info = self.is_css_present('section.course-info') diff --git a/common/test/acceptance/edxapp_pages/lms/register.py b/common/test/acceptance/edxapp_pages/lms/register.py index 0e722b9657..c39a5e4ee4 100644 --- a/common/test/acceptance/edxapp_pages/lms/register.py +++ b/common/test/acceptance/edxapp_pages/lms/register.py @@ -4,6 +4,7 @@ Registration page (create a new account) from bok_choy.page_object import PageObject from . import BASE_URL +from .dashboard import DashboardPage class RegisterPage(PageObject): @@ -11,18 +12,23 @@ class RegisterPage(PageObject): Registration page (create a new account) """ - name = "lms.register" - - def url(self, course_id=None): #pylint: disable=W0221 + def __init__(self, browser, course_id): """ - 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 NotImplementedError("Must provide a course ID to access about page") + super(RegisterPage, self).__init__(browser) + self._course_id = course_id - return BASE_URL + "/register?course_id=" + course_id + "&enrollment_action=enroll" + def url(self): + """ + URL for the registration page of a course. + """ + return "{base}/register?course_id={course_id}&enrollment_action={action}".format( + base=BASE_URL, + course_id=self._course_id, + action="enroll", + ) def is_browser_on_page(self): return any([ @@ -30,16 +36,15 @@ class RegisterPage(PageObject): for title in self.css_text('span.title-sub') ]) - def provide_info(self, credentials): + def provide_info(self, email, password, username, full_name): """ Fill in registration info. - - `credentials` is a `TestCredential` object. + `email`, `password`, `username`, and `full_name` are the user's credentials. """ - 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_fill('input#email', email) + self.css_fill('input#password', password) + self.css_fill('input#username', username) + self.css_fill('input#name', full_name) self.css_check('input#tos-yes') self.css_check('input#honorcode-yes') @@ -48,3 +53,8 @@ class RegisterPage(PageObject): Submit registration info to create an account. """ self.css_click('button#submit') + + # The next page is the dashboard; make sure it loads + dashboard = DashboardPage(self.browser) + dashboard.wait_for_page() + return dashboard diff --git a/common/test/acceptance/edxapp_pages/lms/tab_nav.py b/common/test/acceptance/edxapp_pages/lms/tab_nav.py index 9b4b4ccd11..e72e89d19c 100644 --- a/common/test/acceptance/edxapp_pages/lms/tab_nav.py +++ b/common/test/acceptance/edxapp_pages/lms/tab_nav.py @@ -11,14 +11,7 @@ class TabNavPage(PageObject): High-level tab navigation. """ - name = "lms.tab_nav" - - def url(self, **kwargs): - """ - Since tab navigation appears on multiple pages, - it doesn't have a particular URL. - """ - raise NotImplementedError + url = None def is_browser_on_page(self): return self.is_css_present('ol.course-tabs') @@ -40,6 +33,19 @@ class TabNavPage(PageObject): else: self.warning("No tabs found for '{0}'".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) + def _tab_css(self, tab_name): """ Return the CSS to click for `tab_name`. @@ -58,19 +64,6 @@ class TabNavPage(PageObject): Return a `Promise` that the user is on the tab `tab_name`. """ return EmptyPromise( - lambda: self._is_on_tab(tab_name), + 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/common/test/acceptance/edxapp_pages/lms/video.py b/common/test/acceptance/edxapp_pages/lms/video.py index 865664a61b..6adc2a44ee 100644 --- a/common/test/acceptance/edxapp_pages/lms/video.py +++ b/common/test/acceptance/edxapp_pages/lms/video.py @@ -5,20 +5,16 @@ Video player in the courseware. import time from bok_choy.page_object import PageObject from bok_choy.promise import EmptyPromise, fulfill_after +from bok_choy.javascript import wait_for_js, js_defined +@js_defined('window.Video') class VideoPage(PageObject): """ Video player in the courseware. """ - name = "lms.video" - - def url(self): - """ - Video players aren't associated with a particular URL. - """ - raise NotImplementedError + url = None def is_browser_on_page(self): return self.is_css_present('section.xmodule_VideoModule') @@ -53,22 +49,20 @@ class VideoPage(PageObject): """ return self.is_css_present('a.video_control') and self.is_css_present('a.video_control.play') + @wait_for_js def play(self): """ Start playing the video. """ - with fulfill_after( - EmptyPromise(lambda: self.is_playing, "Video is playing") - ): + with fulfill_after(EmptyPromise(lambda: self.is_playing, "Video is playing")): self.css_click('a.video_control.play') + @wait_for_js def pause(self): """ Pause the video. """ - with fulfill_after( - EmptyPromise(lambda: self.is_paused, "Video is paused") - ): + with fulfill_after(EmptyPromise(lambda: self.is_paused, "Video is paused")): self.css_click('a.video_control.pause') def _video_time(self): diff --git a/common/test/acceptance/edxapp_pages/studio/asset_index.py b/common/test/acceptance/edxapp_pages/studio/asset_index.py index 4e20bb12b7..805852f201 100644 --- a/common/test/acceptance/edxapp_pages/studio/asset_index.py +++ b/common/test/acceptance/edxapp_pages/studio/asset_index.py @@ -2,28 +2,15 @@ The Files and Uploads page for a course in Studio """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class AssetIndexPage(PageObject): +class AssetIndexPage(CoursePage): """ The Files and Uploads page for a course in Studio """ - name = "studio.uploads" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL to the files and uploads page for a course. - `course_id` is a string of the form "org.number.run", and it is required - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/assets/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "assets" def is_browser_on_page(self): return self.is_css_present('body.view-uploads') diff --git a/common/test/acceptance/edxapp_pages/studio/auto_auth.py b/common/test/acceptance/edxapp_pages/studio/auto_auth.py index 29df6fc632..041cbb2e50 100644 --- a/common/test/acceptance/edxapp_pages/studio/auto_auth.py +++ b/common/test/acceptance/edxapp_pages/studio/auto_auth.py @@ -14,9 +14,7 @@ class AutoAuthPage(PageObject): this url will create a user and log them in. """ - name = "studio.auto_auth" - - def url(self, username=None, email=None, password=None, staff=None, course_id=None): #pylint: disable=W0221 + def __init__(self, browser, username=None, email=None, password=None, staff=None, course_id=None): """ Auto-auth is an end-point for HTTP GET requests. By default, it will create accounts with random user credentials, @@ -29,31 +27,34 @@ class AutoAuthPage(PageObject): Note that "global staff" is NOT the same as course staff. """ - - # The base URL, used for creating a random user - url = BASE_URL + "/auto_auth" + super(AutoAuthPage, self).__init__(browser) # Create query string parameters if provided - params = {} + self._params = {} if username is not None: - params['username'] = username + self._params['username'] = username if email is not None: - params['email'] = email + self._params['email'] = email if password is not None: - params['password'] = password + self._params['password'] = password if staff is not None: - params['staff'] = "true" if staff else "false" + self._params['staff'] = "true" if staff else "false" if course_id is not None: - params['course_id'] = course_id + self._params['course_id'] = course_id - query_str = urllib.urlencode(params) + @property + def url(self): + """ + Construct the URL. + """ + url = BASE_URL + "/auto_auth" + query_str = urllib.urlencode(self._params) - # Append the query string to the base URL if query_str: url += "?" + query_str diff --git a/common/test/acceptance/edxapp_pages/studio/checklists.py b/common/test/acceptance/edxapp_pages/studio/checklists.py index 981f258a4d..3a541cc724 100644 --- a/common/test/acceptance/edxapp_pages/studio/checklists.py +++ b/common/test/acceptance/edxapp_pages/studio/checklists.py @@ -2,28 +2,15 @@ Course checklists page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class ChecklistsPage(PageObject): +class ChecklistsPage(CoursePage): """ Course Checklists page. """ - name = "studio.checklists" - - def url(self, course_id=None): # pylint: disable=W0221 - """ - URL to the checklist page in a course. - `course_id` is a string of the form "org.number.run", and it is required - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/checklists/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "checklists" def is_browser_on_page(self): return self.is_css_present('body.view-checklists') diff --git a/common/test/acceptance/edxapp_pages/studio/course_import.py b/common/test/acceptance/edxapp_pages/studio/course_import.py index 504594cd59..c4fc4c6c04 100644 --- a/common/test/acceptance/edxapp_pages/studio/course_import.py +++ b/common/test/acceptance/edxapp_pages/studio/course_import.py @@ -2,28 +2,15 @@ Course Import page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class ImportPage(PageObject): +class ImportPage(CoursePage): """ Course Import page. """ - name = "studio.import" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the import page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/import/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "import" def is_browser_on_page(self): return self.is_css_present('body.view-import') diff --git a/common/test/acceptance/edxapp_pages/studio/course_info.py b/common/test/acceptance/edxapp_pages/studio/course_info.py index 7baa4425b2..8fadb3f748 100644 --- a/common/test/acceptance/edxapp_pages/studio/course_info.py +++ b/common/test/acceptance/edxapp_pages/studio/course_info.py @@ -2,28 +2,15 @@ Course Updates page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class CourseUpdatesPage(PageObject): +class CourseUpdatesPage(CoursePage): """ Course Updates page. """ - name = "studio.updates" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the course team page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/course_info/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "course_info" def is_browser_on_page(self): return self.is_css_present('body.view-updates') diff --git a/common/test/acceptance/edxapp_pages/studio/course_page.py b/common/test/acceptance/edxapp_pages/studio/course_page.py new file mode 100644 index 0000000000..4f80984dc0 --- /dev/null +++ b/common/test/acceptance/edxapp_pages/studio/course_page.py @@ -0,0 +1,41 @@ +""" +Base class for pages specific to a course in Studio. +""" + +from bok_choy.page_object import PageObject +from . import BASE_URL + + +class CoursePage(PageObject): + """ + Abstract base class for page objects specific to a course in Studio. + """ + + # Overridden by subclasses to provide the relative path within the course + # Does not need to include the leading forward or trailing slash + URL_PATH = "" + + def __init__(self, browser, course_org, course_num, course_run): + """ + Initialize the page object for the course located at + `{course_org}.{course_num}.{course_run}` + + These identifiers will likely change in the future. + """ + super(CoursePage, self).__init__(browser) + self.course_info = { + 'course_org': course_org, + 'course_num': course_num, + 'course_run': course_run + } + + @property + def url(self): + """ + Construct a URL to the page within the course. + """ + return "/".join([ + BASE_URL, self.URL_PATH, + "{course_org}.{course_num}.{course_run}".format(**self.course_info), + "branch", "draft", "block", self.course_info['course_run'] + ]) diff --git a/common/test/acceptance/edxapp_pages/studio/edit_subsection.py b/common/test/acceptance/edxapp_pages/studio/edit_subsection.py index 32ac69236f..32f20c1a16 100644 --- a/common/test/acceptance/edxapp_pages/studio/edit_subsection.py +++ b/common/test/acceptance/edxapp_pages/studio/edit_subsection.py @@ -10,10 +10,5 @@ class SubsectionPage(PageObject): Edit Subsection page in Studio """ - name = "studio.subsection" - - def url(self): - raise NotImplementedError - def is_browser_on_page(self): return self.is_css_present('body.view-subsection') diff --git a/common/test/acceptance/edxapp_pages/studio/edit_tabs.py b/common/test/acceptance/edxapp_pages/studio/edit_tabs.py index 3339374c1c..516e7d772a 100644 --- a/common/test/acceptance/edxapp_pages/studio/edit_tabs.py +++ b/common/test/acceptance/edxapp_pages/studio/edit_tabs.py @@ -2,28 +2,15 @@ Static Pages page for a course. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class StaticPagesPage(PageObject): +class StaticPagesPage(CoursePage): """ Static Pages page for a course. """ - name = "studio.tabs" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL to the static pages UI in a course. - `course_id` is a string of the form "org.number.run", and it is required - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/tabs/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "tabs" def is_browser_on_page(self): return self.is_css_present('body.view-static-pages') diff --git a/common/test/acceptance/edxapp_pages/studio/export.py b/common/test/acceptance/edxapp_pages/studio/export.py index 3de953576d..c4e5644068 100644 --- a/common/test/acceptance/edxapp_pages/studio/export.py +++ b/common/test/acceptance/edxapp_pages/studio/export.py @@ -2,28 +2,15 @@ Course Export page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class ExportPage(PageObject): +class ExportPage(CoursePage): """ Course Export page. """ - name = "studio.export" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the export page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/export/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "export" def is_browser_on_page(self): return self.is_css_present('body.view-export') diff --git a/common/test/acceptance/edxapp_pages/studio/helpers.py b/common/test/acceptance/edxapp_pages/studio/helpers.py deleted file mode 100644 index ae64e889c8..0000000000 --- a/common/test/acceptance/edxapp_pages/studio/helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -""" -Helper functions for Studio page objects. -""" - -class InvalidCourseID(Exception): - """ - The course ID does not have the correct format. - """ - pass - - -def parse_course_id(course_id): - """ - Parse a `course_id` string of the form "org.number.run" - and return the components as a tuple. - - Raises an `InvalidCourseID` exception if the course ID is not in the right format. - """ - if course_id is None: - raise InvalidCourseID("Invalid course ID: '{0}'".format(course_id)) - - elements = course_id.split('.') - - # You need at least 3 parts to a course ID: org, number, and run - if len(elements) < 3: - raise InvalidCourseID("Invalid course ID: '{0}'".format(course_id)) - - return tuple(elements) diff --git a/common/test/acceptance/edxapp_pages/studio/howitworks.py b/common/test/acceptance/edxapp_pages/studio/howitworks.py index 0b17100030..d5ceb24de4 100644 --- a/common/test/acceptance/edxapp_pages/studio/howitworks.py +++ b/common/test/acceptance/edxapp_pages/studio/howitworks.py @@ -11,10 +11,7 @@ class HowitworksPage(PageObject): Home page for Studio when not logged in. """ - name = "studio.howitworks" - - def url(self): - return BASE_URL + "/howitworks" + url = BASE_URL + "/howitworks" def is_browser_on_page(self): return self.is_css_present('body.view-howitworks') diff --git a/common/test/acceptance/edxapp_pages/studio/index.py b/common/test/acceptance/edxapp_pages/studio/index.py index f98271fe77..8f8dcb69b2 100644 --- a/common/test/acceptance/edxapp_pages/studio/index.py +++ b/common/test/acceptance/edxapp_pages/studio/index.py @@ -11,10 +11,7 @@ class DashboardPage(PageObject): My Courses page in Studio """ - name = "studio.dashboard" - - def url(self): - return BASE_URL + "/course" + url = BASE_URL + "/course" def is_browser_on_page(self): return self.is_css_present('body.view-dashboard') diff --git a/common/test/acceptance/edxapp_pages/studio/login.py b/common/test/acceptance/edxapp_pages/studio/login.py index 946a918cd8..ed2ff278cc 100644 --- a/common/test/acceptance/edxapp_pages/studio/login.py +++ b/common/test/acceptance/edxapp_pages/studio/login.py @@ -12,10 +12,7 @@ class LoginPage(PageObject): Login page for Studio. """ - name = "studio.login" - - def url(self): - return BASE_URL + "/signin" + url = BASE_URL + "/signin" def is_browser_on_page(self): return self.is_css_present('body.view-signin') diff --git a/common/test/acceptance/edxapp_pages/studio/manage_users.py b/common/test/acceptance/edxapp_pages/studio/manage_users.py index 8c253532fc..3521a18fcc 100644 --- a/common/test/acceptance/edxapp_pages/studio/manage_users.py +++ b/common/test/acceptance/edxapp_pages/studio/manage_users.py @@ -2,28 +2,15 @@ Course Team page in Studio. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class CourseTeamPage(PageObject): +class CourseTeamPage(CoursePage): """ Course Team page in Studio. """ - name = "studio.team" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the course team page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/course_team/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "course_team" def is_browser_on_page(self): return self.is_css_present('body.view-team') diff --git a/common/test/acceptance/edxapp_pages/studio/overview.py b/common/test/acceptance/edxapp_pages/studio/overview.py index f94d232d36..6e67e7b81f 100644 --- a/common/test/acceptance/edxapp_pages/studio/overview.py +++ b/common/test/acceptance/edxapp_pages/studio/overview.py @@ -2,28 +2,15 @@ Course Outline page in Studio. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class CourseOutlinePage(PageObject): +class CourseOutlinePage(CoursePage): """ Course Outline page in Studio. """ - name = "studio.outline" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the course team page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/course/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "course" def is_browser_on_page(self): return self.is_css_present('body.view-outline') diff --git a/common/test/acceptance/edxapp_pages/studio/settings.py b/common/test/acceptance/edxapp_pages/studio/settings.py index f24c89c2e9..7134660d7b 100644 --- a/common/test/acceptance/edxapp_pages/studio/settings.py +++ b/common/test/acceptance/edxapp_pages/studio/settings.py @@ -2,28 +2,15 @@ Course Schedule and Details Settings page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class SettingsPage(PageObject): +class SettingsPage(CoursePage): """ Course Schedule and Details Settings page. """ - name = "studio.settings" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the settings page of a particular course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/settings/details/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "settings/details" def is_browser_on_page(self): return self.is_css_present('body.view-settings') diff --git a/common/test/acceptance/edxapp_pages/studio/settings_advanced.py b/common/test/acceptance/edxapp_pages/studio/settings_advanced.py index 916ec70def..4f6b8893a1 100644 --- a/common/test/acceptance/edxapp_pages/studio/settings_advanced.py +++ b/common/test/acceptance/edxapp_pages/studio/settings_advanced.py @@ -2,28 +2,15 @@ Course Advanced Settings page """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class AdvancedSettingsPage(PageObject): +class AdvancedSettingsPage(CoursePage): """ Course Advanced Settings page. """ - name = "studio.advanced" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL to the advanced setting page in a course. - `course_id` is a string of the form "org.number.run", and it is required - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/settings/advanced/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "settings/advanced" def is_browser_on_page(self): return self.is_css_present('body.advanced') diff --git a/common/test/acceptance/edxapp_pages/studio/settings_graders.py b/common/test/acceptance/edxapp_pages/studio/settings_graders.py index 24c9623d77..af6194fcc5 100644 --- a/common/test/acceptance/edxapp_pages/studio/settings_graders.py +++ b/common/test/acceptance/edxapp_pages/studio/settings_graders.py @@ -2,28 +2,15 @@ Course Grading Settings page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class GradingPage(PageObject): +class GradingPage(CoursePage): """ Course Grading Settings page. """ - name = "studio.grading" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL for the course team page of a course. - `course_id` is a string of the form "org.number.run" and is required. - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/settings/grading/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "settings/grading" def is_browser_on_page(self): return self.is_css_present('body.grading') diff --git a/common/test/acceptance/edxapp_pages/studio/signup.py b/common/test/acceptance/edxapp_pages/studio/signup.py index 8847fb8416..3e5f731597 100644 --- a/common/test/acceptance/edxapp_pages/studio/signup.py +++ b/common/test/acceptance/edxapp_pages/studio/signup.py @@ -7,10 +7,7 @@ class SignupPage(PageObject): Signup page for Studio. """ - name = "studio.signup" - - def url(self): - return BASE_URL + "/signup" + url = BASE_URL + "/signup" def is_browser_on_page(self): return self.is_css_present('body.view-signup') diff --git a/common/test/acceptance/edxapp_pages/studio/textbooks.py b/common/test/acceptance/edxapp_pages/studio/textbooks.py index 0c0dd579e8..460a83df13 100644 --- a/common/test/acceptance/edxapp_pages/studio/textbooks.py +++ b/common/test/acceptance/edxapp_pages/studio/textbooks.py @@ -2,28 +2,15 @@ Course Textbooks page. """ -from bok_choy.page_object import PageObject -from .helpers import parse_course_id -from . import BASE_URL +from .course_page import CoursePage -class TextbooksPage(PageObject): +class TextbooksPage(CoursePage): """ Course Textbooks page. """ - name = "studio.textbooks" - - def url(self, course_id=None): #pylint: disable=W0221 - """ - URL to the textbook UI in a course. - `course_id` is a string of the form "org.number.run", and it is required - """ - _, _, course_run = parse_course_id(course_id) - - return "{0}/textbooks/{1}/branch/draft/block/{2}".format( - BASE_URL, course_id, course_run - ) + URL_PATH = "textbooks" def is_browser_on_page(self): return self.is_css_present('body.view-textbooks') diff --git a/common/test/acceptance/edxapp_pages/studio/unit.py b/common/test/acceptance/edxapp_pages/studio/unit.py index 2a9294c9f7..530ebe9ef3 100644 --- a/common/test/acceptance/edxapp_pages/studio/unit.py +++ b/common/test/acceptance/edxapp_pages/studio/unit.py @@ -10,10 +10,5 @@ class UnitPage(PageObject): Unit page in Studio """ - name = "studio.unit" - - def url(self): - raise NotImplementedError - def is_browser_on_page(self): return self.is_css_present('body.view-unit') diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py deleted file mode 100644 index 6ee4fb07e7..0000000000 --- a/common/test/acceptance/fixtures/base.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Base fixtures. -""" -from bok_choy.web_app_fixture import WebAppFixture -from django.core.management import call_command - - -class DjangoCmdFixture(WebAppFixture): - """ - Install a fixture by executing a Django management command. - """ - - def __init__(self, cmd, *args, **kwargs): - """ - Configure the fixture to call `cmd` with the specified - positional and keyword arguments. - """ - self._cmd = cmd - self._args = args - self._kwargs = kwargs - - def install(self): - """ - Call the Django management command. - """ - # We do not catch exceptions here. Since management commands - # execute arbitrary Python code, any exception could be raised. - # So it makes sense to let those go all the way up to the test runner, - # where they can quickly be found and fixed. - call_command(self._cmd, *self._args, **self._kwargs) diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py index 54af80aacb..bcc998eb63 100644 --- a/common/test/acceptance/fixtures/course.py +++ b/common/test/acceptance/fixtures/course.py @@ -5,34 +5,49 @@ Fixture to create a course and course components (XBlocks). import json import datetime from textwrap import dedent +from collections import namedtuple import requests from lazy import lazy -from bok_choy.web_app_fixture import WebAppFixture, WebAppFixtureError from . import STUDIO_BASE_URL -class StudioApiFixture(WebAppFixture): +class StudioApiLoginError(Exception): + """ + Error occurred while logging in to the Studio API. + """ + pass + + +class StudioApiFixture(object): """ Base class for fixtures that use the Studio restful API. """ @lazy - def session_cookies(self): + def session(self): """ - Log in as a staff user, then return the cookies for the session (as a dict) - Raises a `WebAppFixtureError` if the login fails. + Log in as a staff user, then return a `requests` `session` object for the logged in user. + Raises a `StudioApiLoginError` if the login fails. """ + # Use auto-auth to retrieve session for a logged in user + session = requests.Session() + response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true") - # Use auto-auth to retrieve session cookies for a logged in user - response = requests.get(STUDIO_BASE_URL + "/auto_auth?staff=true") - - # Return the cookies from the request + # Return the session from the request if response.ok: - return {key: val for key, val in response.cookies.items()} + return session else: msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code) - raise WebAppFixtureError(msg) + raise StudioApiLoginError(msg) + + @lazy + def session_cookies(self): + """ + Log in as a staff user, then return the cookies for the session (as a dict) + Raises a `StudioApiLoginError` if the login fails. + """ + return {key: val for key, val in self.session.cookies.items()} @lazy def headers(self): @@ -96,6 +111,11 @@ class XBlockFixtureDesc(object): 'publish': self.publish } + # Need to handle detached categories differently, since they are not published + # This may change in the future. + if self.category in ['static_tab']: + del payload['publish'] + if parent_loc is not None: payload['parent_locator'] = parent_loc @@ -121,6 +141,19 @@ class XBlockFixtureDesc(object): ) +# Description of course updates to add to the course +# `date` is a str (e.g. "January 29, 2014) +# `content` is also a str (e.g. "Test course") +CourseUpdateDesc = namedtuple("CourseUpdateDesc", ['date', 'content']) + + +class CourseFixtureError(Exception): + """ + Error occurred while installing a course fixture. + """ + pass + + class CourseFixture(StudioApiFixture): """ Fixture for ensuring that a course exists. @@ -160,6 +193,8 @@ class CourseFixture(StudioApiFixture): if end_date is not None: self._course_details['end_date'] = end_date.isoformat() + self._updates = [] + self._handouts = [] self._children = [] def __str__(self): @@ -178,14 +213,33 @@ class CourseFixture(StudioApiFixture): self._children.extend(args) return self + def add_update(self, update): + """ + Add an update to the course. `update` should be a `CourseUpdateDesc`. + """ + self._updates.append(update) + + def add_handout(self, asset_name): + """ + Add the handout named `asset_name` to the course info page. + Note that this does not actually *create* the static asset; it only links to it. + """ + self._handouts.append(asset_name) + def install(self): """ Create the course and XBlocks within the course. This is NOT an idempotent method; if the course already exists, this will - raise a `WebAppFixtureError`. You should use unique course identifiers to avoid + raise a `CourseFixtureError`. You should use unique course identifiers to avoid conflicts between tests. """ self._create_course() + + # Remove once STUD-1248 is resolved + self._update_loc_map() + + self._install_course_updates() + self._install_course_handouts() self._configure_course() self._create_xblock_children(self._course_loc, self._children) @@ -196,33 +250,46 @@ class CourseFixture(StudioApiFixture): """ return "{org}.{number}.{run}/branch/draft/block/{run}".format(**self._course_dict) + @property + def _updates_loc(self): + """ + Return the locator string for the course updates + """ + return "{org}.{number}.{run}/branch/draft/block/updates".format(**self._course_dict) + + @property + def _handouts_loc(self): + """ + Return the locator string for the course handouts + """ + return "{org}.{number}.{run}/branch/draft/block/handouts".format(**self._course_dict) + def _create_course(self): """ Create the course described in the fixture. """ # If the course already exists, this will respond # with a 200 and an error message, which we ignore. - response = requests.post( + response = self.session.post( STUDIO_BASE_URL + '/course', data=self._encode_post_dict(self._course_dict), - headers=self.headers, - cookies=self.session_cookies + headers=self.headers ) try: err = response.json().get('ErrMsg') except ValueError: - raise WebAppFixtureError( + raise CourseFixtureError( "Could not parse response from course request as JSON: '{0}'".format( response.content)) # This will occur if the course identifier is not unique if err is not None: - raise WebAppFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) + raise CourseFixtureError("Could not create course {0}. Error message: '{1}'".format(self, err)) if not response.ok: - raise WebAppFixtureError( + raise CourseFixtureError( "Could not create course {0}. Status was {1}".format( self._course_dict, response.status_code)) @@ -233,17 +300,17 @@ class CourseFixture(StudioApiFixture): url = STUDIO_BASE_URL + '/settings/details/' + self._course_loc # First, get the current values - response = requests.get(url, headers=self.headers, cookies=self.session_cookies) + response = self.session.get(url, headers=self.headers) if not response.ok: - raise WebAppFixtureError( + raise CourseFixtureError( "Could not retrieve course details. Status was {0}".format( response.status_code)) try: details = response.json() except ValueError: - raise WebAppFixtureError( + raise CourseFixtureError( "Could not decode course details as JSON: '{0}'".format(old_details) ) @@ -251,17 +318,75 @@ class CourseFixture(StudioApiFixture): details.update(self._course_details) # POST the updated details to Studio - response = requests.post( + response = self.session.post( url, data=self._encode_post_dict(details), headers=self.headers, - cookies=self.session_cookies ) if not response.ok: - raise WebAppFixtureError( + raise CourseFixtureError( "Could not update course details to '{0}'. Status was {1}.".format( self._course_details, response.status_code)) + def _install_course_handouts(self): + """ + Add handouts to the course info page. + """ + url = STUDIO_BASE_URL + '/xblock/' + self._handouts_loc + + # Construct HTML with each of the handout links + handouts_li = [ + '
  • Example Handout
  • '.format(handout=handout) + for handout in self._handouts + ] + handouts_html = '
      {}
    '.format("".join(handouts_li)) + + # Update the course's handouts HTML + payload = json.dumps({ + 'children': None, + 'data': handouts_html, + 'id': self._handouts_loc, + 'metadata': dict() + }) + + response = self.session.post(url, data=payload, headers=self.headers) + + if not response.ok: + raise CourseFixtureError( + "Could not update course handouts. Status was {0}".format(response.status_code)) + + def _install_course_updates(self): + """ + Add updates to the course, if any are configured. + """ + url = STUDIO_BASE_URL + '/course_info_update/' + self._updates_loc + + for update in self._updates: + + # Add the update to the course + date, content = update + payload = json.dumps({'date': date, 'content': content}) + response = self.session.post(url, headers=self.headers, data=payload) + + if not response.ok: + raise CourseFixtureError( + "Could not add update to course: {0}. Status was {1}".format( + update, response.status_code)) + + def _update_loc_map(self): + """ + Force update of the location map. + """ + # We perform a GET request to force Studio to update the course location map. + # This is a (minor) bug in the Studio RESTful API: STUD-1248 + url = "{base}/course_info/{course}".format(base=STUDIO_BASE_URL, course=self._course_loc) + response = self.session.get(url, headers={'Accept': 'text/html'}) + + if not response.ok: + raise CourseFixtureError( + "Could not load Studio dashboard to trigger location map update. Status was {0}".format( + response.status_code)) + def _create_xblock_children(self, parent_loc, xblock_descriptions): """ Recursively create XBlock children. @@ -276,40 +401,40 @@ class CourseFixture(StudioApiFixture): and `xblock_desc` (an `XBlockFixtureDesc` instance). """ # Create the new XBlock - response = requests.post( + response = self.session.post( STUDIO_BASE_URL + '/xblock', data=xblock_desc.serialize(parent_loc=parent_loc), headers=self.headers, - cookies=self.session_cookies ) if not response.ok: msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code) - raise WebAppFixtureError(msg) + raise CourseFixtureError(msg) try: loc = response.json().get('locator') except ValueError: - raise WebAppFixtureError("Could not decode JSON from '{0}'".format(response.content)) + raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content)) if loc is not None: # Configure the XBlock - response = requests.post( + response = self.session.post( STUDIO_BASE_URL + '/xblock/' + loc, data=xblock_desc.serialize(), headers=self.headers, - cookies=self.session_cookies ) if response.ok: return loc else: - raise WebAppFixtureError("Could not update {0}".format(xblock_desc)) + raise CourseFixtureError( + "Could not update {0}. Status code: {1}".format( + xblock_desc, response.status_code)) else: - raise WebAppFixtureError("Could not retrieve location of {0}".format(xblock_desc)) + raise CourseFixtureError("Could not retrieve location of {0}".format(xblock_desc)) def _encode_post_dict(self, post_dict): """ diff --git a/common/test/acceptance/fixtures/xqueue.py b/common/test/acceptance/fixtures/xqueue.py index 4eacace76d..d5e74acdac 100644 --- a/common/test/acceptance/fixtures/xqueue.py +++ b/common/test/acceptance/fixtures/xqueue.py @@ -4,11 +4,10 @@ Fixture to configure XQueue response. import requests import json -from bok_choy.web_app_fixture import WebAppFixture, WebAppFixtureError from . import XQUEUE_STUB_URL -class XQueueResponseFixture(WebAppFixture): +class XQueueResponseFixture(object): """ Configure the XQueue stub's response to submissions. """ diff --git a/common/test/acceptance/tests/data/formula_problem.xml b/common/test/acceptance/tests/data/formula_problem.xml new file mode 100644 index 0000000000..6e76914286 --- /dev/null +++ b/common/test/acceptance/tests/data/formula_problem.xml @@ -0,0 +1,17 @@ + + + +

    Some edX courses ask you to enter an algebraic expression as an answer. Try entering the following algebraic expression in the box below. It’s easier than it looks.

    +

    \(A \cdot x^2 + \sqrt{y}\) +

    +

    +The entry is case sensitive. The product must be indicated with an asterisk, and the exponentiation with a caret, so you would write +"A*x^2 + sqrt(y)".

    + + + + + +
    diff --git a/common/test/acceptance/tests/data/multiple_choice.xml b/common/test/acceptance/tests/data/multiple_choice.xml new file mode 100644 index 0000000000..bdf9509d34 --- /dev/null +++ b/common/test/acceptance/tests/data/multiple_choice.xml @@ -0,0 +1,28 @@ + +

    Many edX courses have homework or exercises you need to complete. Notice the clock image to the left? That means this homework or exercise needs to be completed for you to pass the course. (This can be a bit confusing; the exercise may or may not have a due date prior to the end of the course.)

    +

    We’ve provided eight (8) examples of how a professor might ask you questions. While the multiple choice question types below are somewhat standard, explore the other question types in the sequence above, like the formula builder- try them all out.

    +

    As you go through the question types, notice how edX gives you immediate feedback on your responses - it really helps in the learning process.

    +

    What color is the open ocean on a sunny day?

    + + + +

    Which piece of furniture is built for sitting?

    + + + a table + a desk + a chair + a bookshelf + + +

    Which of the following are musical instruments?

    + + + a piano + a tree + a guitar + a window + + +

    +
    diff --git a/common/test/acceptance/tests/test_lms.py b/common/test/acceptance/tests/test_lms.py new file mode 100644 index 0000000000..5b9407c2e5 --- /dev/null +++ b/common/test/acceptance/tests/test_lms.py @@ -0,0 +1,251 @@ +""" +E2E tests for the LMS. +""" + +from bok_choy.web_app_test import WebAppTest +from bok_choy.promise import EmptyPromise, fulfill_before + +from .helpers import UniqueCourseTest, load_data_str +from ..edxapp_pages.studio.auto_auth import AutoAuthPage +from ..edxapp_pages.lms.login import LoginPage +from ..edxapp_pages.lms.find_courses import FindCoursesPage +from ..edxapp_pages.lms.course_about import CourseAboutPage +from ..edxapp_pages.lms.register import RegisterPage +from ..edxapp_pages.lms.course_info import CourseInfoPage +from ..edxapp_pages.lms.tab_nav import TabNavPage +from ..edxapp_pages.lms.course_nav import CourseNavPage +from ..edxapp_pages.lms.progress import ProgressPage +from ..edxapp_pages.lms.video import VideoPage +from ..fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc + + +class RegistrationTest(UniqueCourseTest): + """ + Test the registration process. + """ + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(RegistrationTest, self).setUp() + + self.find_courses_page = FindCoursesPage(self.browser) + self.course_about_page = CourseAboutPage(self.browser, self.course_id) + + # Create a course to register for + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ).install() + + def test_register(self): + + # Visit the main page with the list of courses + self.find_courses_page.visit() + + # Expect that the fixture course exists + course_ids = self.find_courses_page.course_id_list + self.assertIn(self.course_id, course_ids) + + # Go to the course about page and click the register button + self.course_about_page.visit() + register_page = self.course_about_page.register() + + # Fill in registration info and submit + username = "test_" + self.unique_id[0:6] + register_page.provide_info( + username + "@example.com", "test", username, "Test User" + ) + dashboard = register_page.submit() + + # We should end up at the dashboard + # Check that we're registered for the course + course_names = dashboard.available_courses + self.assertIn(self.course_info['display_name'], course_names) + + +class HighLevelTabTest(UniqueCourseTest): + """ + Tests that verify each of the high-level tabs available within a course. + """ + + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(HighLevelTabTest, self).setUp() + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.progress_page = ProgressPage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.tab_nav = TabNavPage(self.browser) + self.video = VideoPage(self.browser) + + # Install a course with sections/problems, tabs, updates, and handouts + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + course_fix.add_update( + CourseUpdateDesc(date='January 29, 2014', content='Test course update') + ) + + course_fix.add_handout('demoPDF.pdf') + + course_fix.add_children( + XBlockFixtureDesc('static_tab', 'Test Static Tab'), + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('problem', 'Test Problem 1', data=load_data_str('multiple_choice.xml')), + XBlockFixtureDesc('problem', 'Test Problem 2', data=load_data_str('formula_problem.xml')), + XBlockFixtureDesc('html', 'Test HTML'), + )), + XBlockFixtureDesc('chapter', 'Test Section 2').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection 2'), + XBlockFixtureDesc('sequential', 'Test Subsection 3'), + )).install() + + # Auto-auth register for the course + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + def test_course_info(self): + """ + Navigate to the course info page. + """ + # Navigate to the course info page from the progress page + self.progress_page.visit() + self.tab_nav.go_to_tab('Course Info') + + # Expect just one update + self.assertEqual(self.course_info_page.num_updates, 1) + + # Expect a link to the demo handout pdf + handout_links = self.course_info_page.handout_links + self.assertEqual(len(handout_links), 1) + self.assertIn('demoPDF.pdf', handout_links[0]) + + def test_progress(self): + """ + Navigate to the progress page. + """ + # Navigate to the progress page from the info page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Progress') + + # We haven't answered any problems yet, so assume scores are zero + # Only problems should have scores; so there should be 2 scores. + CHAPTER = 'Test Section' + SECTION = 'Test Subsection' + EXPECTED_SCORES = [(0, 3), (0, 1)] + + actual_scores = self.progress_page.scores(CHAPTER, SECTION) + self.assertEqual(actual_scores, EXPECTED_SCORES) + + def test_static_tab(self): + """ + Navigate to a static tab (course content) + """ + # From the course info page, navigate to the static tab + self.course_info_page.visit() + self.tab_nav.go_to_tab('Test Static Tab') + self.assertTrue(self.tab_nav.is_on_tab('Test Static Tab')) + + def test_courseware_nav(self): + """ + Navigate to a particular unit in the courseware. + """ + # Navigate to the courseware page from the info page + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') + + # Check that the courseware navigation appears correctly + EXPECTED_SECTIONS = { + 'Test Section': ['Test Subsection'], + 'Test Section 2': ['Test Subsection 2', 'Test Subsection 3'] + } + actual_sections = self.course_nav.sections + for section, subsections in EXPECTED_SECTIONS.iteritems(): + self.assertIn(section, actual_sections) + self.assertEqual(actual_sections[section], EXPECTED_SECTIONS[section]) + + # Navigate to a particular section + self.course_nav.go_to_section('Test Section', 'Test Subsection') + + # Check the sequence items + EXPECTED_ITEMS = ['Test Problem 1', 'Test Problem 2', 'Test HTML'] + + actual_items = self.course_nav.sequence_items + self.assertEqual(len(actual_items), len(EXPECTED_ITEMS)) + for expected in EXPECTED_ITEMS: + self.assertIn(expected, actual_items) + + +class VideoTest(UniqueCourseTest): + """ + Navigate to a video in the courseware and play it. + """ + def setUp(self): + """ + Initialize pages and install a course fixture. + """ + super(VideoTest, self).setUp() + + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.course_nav = CourseNavPage(self.browser) + self.tab_nav = TabNavPage(self.browser) + self.video = VideoPage(self.browser) + + # Install a course fixture with a video component + course_fix = CourseFixture( + self.course_info['org'], self.course_info['number'], + self.course_info['run'], self.course_info['display_name'] + ) + + course_fix.add_children( + XBlockFixtureDesc('chapter', 'Test Section').add_children( + XBlockFixtureDesc('sequential', 'Test Subsection').add_children( + XBlockFixtureDesc('video', 'Video') + ))).install() + + + # Auto-auth register for the course + AutoAuthPage(self.browser, course_id=self.course_id).visit() + + def test_video_player(self): + """ + Play a video in the courseware. + """ + + # Navigate to a video + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') + + # The video should start off paused + # Since the video hasn't loaded yet, it's elapsed time is 0 + self.assertFalse(self.video.is_playing) + self.assertEqual(self.video.elapsed_time, 0) + + # Play the video + self.video.play() + + # Now we should be playing + self.assertTrue(self.video.is_playing) + + # Wait for the video to load the duration + video_duration_loaded = EmptyPromise( + lambda: self.video.duration > 0, + 'video has duration', timeout=20 + ) + + with fulfill_before(video_duration_loaded): + + # Pause the video + self.video.pause() + + # Expect that the elapsed time and duration are reasonable + # Again, we can't expect the video to actually play because of + # latency through the ssh tunnel + self.assertGreaterEqual(self.video.elapsed_time, 0) + self.assertGreaterEqual(self.video.duration, self.video.elapsed_time) diff --git a/common/test/acceptance/tests/test_ora.py b/common/test/acceptance/tests/test_ora.py index 39fc3fcd17..ab5b4940f9 100644 --- a/common/test/acceptance/tests/test_ora.py +++ b/common/test/acceptance/tests/test_ora.py @@ -23,36 +23,24 @@ class OpenResponseTest(UniqueCourseTest): some helper functions used in the ORA tests. """ - page_object_classes = [ - AutoAuthPage, CourseInfoPage, TabNavPage, - CourseNavPage, OpenResponsePage, ProgressPage - ] - # Grade response (dict) to return from the XQueue stub # in response to our unique submission text. XQUEUE_GRADE_RESPONSE = None def setUp(self): """ + Install a test course with ORA problems. Always start in the subsection with open response problems. """ - # Create a unique submission - self.submission = "Test submission " + self.unique_id - - # Ensure fixtures are installed super(OpenResponseTest, self).setUp() - # Log in and navigate to the essay problems - self.ui.visit('studio.auto_auth', course_id=self.course_id) - self.ui.visit('lms.course_info', course_id=self.course_id) - self.ui['lms.tab_nav'].go_to_tab('Courseware') - - @property - def fixtures(self): - """ - Create a test course with open response problems. - Configure the XQueue stub to respond to submissions to the open-ended queue. - """ + # Create page objects + self.auth_page = AutoAuthPage(self.browser, course_id=self.course_id) + self.course_info_page = CourseInfoPage(self.browser, self.course_id) + self.tab_nav = TabNavPage(self.browser) + self.course_nav = CourseNavPage(self.browser) + self.open_response = OpenResponsePage(self.browser) + self.progress_page = ProgressPage(self.browser, self.course_id) # Configure the test course course_fix = CourseFixture( @@ -61,7 +49,6 @@ class OpenResponseTest(UniqueCourseTest): ) course_fix.add_children( - XBlockFixtureDesc('chapter', 'Test Section').add_children( XBlockFixtureDesc('sequential', 'Test Subsection').add_children( @@ -73,17 +60,21 @@ class OpenResponseTest(UniqueCourseTest): XBlockFixtureDesc('combinedopenended', 'Peer-Assessed', data=load_data_str('ora_peer_problem.xml'), metadata={'graded': True}), - ) - ) - ) + + XBlockFixtureDesc('peergrading', 'Peer Module'), + + ))).install() # Configure the XQueue stub's response for the text we will submit + # The submission text is unique so we can associate each response with a particular test case. + self.submission = "Test submission " + self.unique_id[0:4] if self.XQUEUE_GRADE_RESPONSE is not None: - xqueue_fix = XQueueResponseFixture(self.submission, self.XQUEUE_GRADE_RESPONSE) - return [course_fix, xqueue_fix] + XQueueResponseFixture(self.submission, self.XQUEUE_GRADE_RESPONSE).install() - else: - return [course_fix] + # Log in and navigate to the essay problems + self.auth_page.visit() + self.course_info_page.visit() + self.tab_nav.go_to_tab('Courseware') def submit_essay(self, expected_assessment_type, expected_prompt): """ @@ -93,21 +84,21 @@ class OpenResponseTest(UniqueCourseTest): """ # Check the assessment type and prompt - self.assertEqual(self.ui['lms.open_response'].assessment_type, expected_assessment_type) - self.assertIn(expected_prompt, self.ui['lms.open_response'].prompt) + self.assertEqual(self.open_response.assessment_type, expected_assessment_type) + self.assertIn(expected_prompt, self.open_response.prompt) # Enter a submission, which will trigger a pre-defined response from the XQueue stub. - self.ui['lms.open_response'].set_response(self.submission) + self.open_response.set_response(self.submission) # Save the response and expect some UI feedback - self.ui['lms.open_response'].save_response() + self.open_response.save_response() self.assertEqual( - self.ui['lms.open_response'].alert_message, + self.open_response.alert_message, "Answer saved, but not yet submitted." ) # Submit the response - self.ui['lms.open_response'].submit_response() + self.open_response.submit_response() def get_asynch_feedback(self, assessment_type): """ @@ -142,9 +133,9 @@ class OpenResponseTest(UniqueCourseTest): raise ValueError('Assessment type not recognized. Must be either "ai" or "peer"') def _inner_check(): - self.ui['lms.course_nav'].go_to_sequential('Self-Assessed') - self.ui['lms.course_nav'].go_to_sequential(section_name) - feedback = self.ui['lms.open_response'].rubric_feedback + self.course_nav.go_to_sequential('Self-Assessed') + self.course_nav.go_to_sequential(section_name) + feedback = self.open_response.rubric_feedback # Successful if `feedback` is a non-empty list return (bool(feedback), feedback) @@ -165,27 +156,25 @@ class SelfAssessmentTest(OpenResponseTest): And I see my score in the progress page. """ # Navigate to the self-assessment problem and submit an essay - self.ui['lms.course_nav'].go_to_sequential('Self-Assessed') + self.course_nav.go_to_sequential('Self-Assessed') self.submit_essay('self', 'Censorship in the Libraries') # Check the rubric categories self.assertEqual( - self.ui['lms.open_response'].rubric_categories, - ["Writing Applications", "Language Conventions"] + self.open_response.rubric_categories, ["Writing Applications", "Language Conventions"] ) # Fill in the self-assessment rubric - self.ui['lms.open_response'].submit_self_assessment([0, 1]) + self.open_response.submit_self_assessment([0, 1]) # Expect that we get feedback self.assertEqual( - self.ui['lms.open_response'].rubric_feedback, - ['incorrect', 'correct'] + self.open_response.rubric_feedback, ['incorrect', 'correct'] ) # Verify the progress page - self.ui.visit('lms.progress', course_id=self.course_id) - scores = self.ui['lms.progress'].scores('Test Section', 'Test Subsection') + self.progress_page.visit() + scores = self.progress_page.scores('Test Section', 'Test Subsection') # The first score is self-assessment, which we've answered, so it's 1/2 # The other scores are AI- and peer-assessment, which we haven't answered so those are 0/2 @@ -217,22 +206,16 @@ class AIAssessmentTest(OpenResponseTest): """ # Navigate to the AI-assessment problem and submit an essay - self.ui['lms.course_nav'].go_to_sequential('AI-Assessed') + self.course_nav.go_to_sequential('AI-Assessed') self.submit_essay('ai', 'Censorship in the Libraries') - # Expect UI feedback that the response was submitted - self.assertEqual( - self.ui['lms.open_response'].grader_status, - "Your response has been submitted. Please check back later for your grade." - ) - # Refresh the page to get the updated feedback # then verify that we get the feedback sent by our stub XQueue implementation self.assertEqual(self.get_asynch_feedback('ai'), ['incorrect', 'correct']) # Verify the progress page - self.ui.visit('lms.progress', course_id=self.course_id) - scores = self.ui['lms.progress'].scores('Test Section', 'Test Subsection') + self.progress_page.visit() + scores = self.progress_page.scores('Test Section', 'Test Subsection') # First score is the self-assessment score, which we haven't answered, so it's 0/2 # Second score is the AI-assessment score, which we have answered, so it's 1/2 @@ -286,23 +269,17 @@ class PeerFeedbackTest(OpenResponseTest): And I see my score in the progress page. """ # Navigate to the peer-assessment problem and submit an essay - self.ui['lms.course_nav'].go_to_sequential('Peer-Assessed') + self.course_nav.go_to_sequential('Peer-Assessed') self.submit_essay('peer', 'Censorship in the Libraries') - # Expect UI feedback that the response was submitted - self.assertEqual( - self.ui['lms.open_response'].grader_status, - "Your response has been submitted. Please check back later for your grade." - ) - # Refresh the page to get feedback from the stub XQueue grader. # We receive feedback from all three peers, each of which # provide 2 scores (one for each rubric item) self.assertEqual(self.get_asynch_feedback('peer'), ['incorrect', 'correct'] * 3) # Verify the progress page - self.ui.visit('lms.progress', course_id=self.course_id) - scores = self.ui['lms.progress'].scores('Test Section', 'Test Subsection') + self.progress_page.visit() + scores = self.progress_page.scores('Test Section', 'Test Subsection') # First score is the self-assessment score, which we haven't answered, so it's 0/2 # Second score is the AI-assessment score, which we haven't answered, so it's 0/2 diff --git a/common/test/acceptance/tests/test_studio.py b/common/test/acceptance/tests/test_studio.py index 1d233a7bb2..b4f8b64e6a 100644 --- a/common/test/acceptance/tests/test_studio.py +++ b/common/test/acceptance/tests/test_studio.py @@ -30,9 +30,9 @@ class LoggedOutTest(WebAppTest): Smoke test for pages in Studio that are visible when logged out. """ - @property - def page_object_classes(self): - return [LoginPage, HowitworksPage, SignupPage] + def setUp(self): + super(LoggedOutTest, self).setUp() + self.pages = [LoginPage(self.browser), HowitworksPage(self.browser), SignupPage(self.browser)] def test_page_existence(self): """ @@ -40,8 +40,8 @@ class LoggedOutTest(WebAppTest): Rather than fire up the browser just to check each url, do them all sequentially in this testcase. """ - for page in ['login', 'howitworks', 'signup']: - self.ui.visit('studio.{0}'.format(page)) + for page in self.pages: + page.visit() class LoggedInPagesTest(WebAppTest): @@ -49,16 +49,18 @@ class LoggedInPagesTest(WebAppTest): Tests that verify the pages in Studio that you can get to when logged in and do not have a course yet. """ - @property - def page_object_classes(self): - return [AutoAuthPage, DashboardPage] + + def setUp(self): + super(LoggedInPagesTest, self).setUp() + self.auth_page = AutoAuthPage(self.browser, staff=True) + self.dashboard_page = DashboardPage(self.browser) def test_dashboard_no_courses(self): """ Make sure that you can get to the dashboard page without a course. """ - self.ui.visit('studio.auto_auth', staff=True) - self.ui.visit('studio.dashboard') + self.auth_page.visit() + self.dashboard_page.visit() class CoursePagesTest(UniqueCourseTest): @@ -69,23 +71,29 @@ class CoursePagesTest(UniqueCourseTest): COURSE_ID_SEPARATOR = "." - @property - def page_object_classes(self): - return [ - AutoAuthPage, AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage, - StaticPagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, - SettingsPage, AdvancedSettingsPage, GradingPage, TextbooksPage - ] + def setUp(self): + """ + Install a course with no content using a fixture. + """ + super(UniqueCourseTest, self).setUp() - @property - def fixtures(self): - course_fix = CourseFixture( + CourseFixture( self.course_info['org'], self.course_info['number'], self.course_info['run'], self.course_info['display_name'] - ) - return [course_fix] + ).install() + + self.auth_page = AutoAuthPage(self.browser, staff=True) + + self.pages = [ + clz(self.browser, self.course_info['org'], self.course_info['number'], self.course_info['run']) + for clz in [ + AssetIndexPage, ChecklistsPage, ImportPage, CourseUpdatesPage, + StaticPagesPage, ExportPage, CourseTeamPage, CourseOutlinePage, SettingsPage, + AdvancedSettingsPage, GradingPage, TextbooksPage + ] + ] def test_page_existence(self): """ @@ -93,14 +101,9 @@ class CoursePagesTest(UniqueCourseTest): Rather than fire up the browser just to check each url, do them all sequentially in this testcase. """ - pages = [ - 'uploads', 'checklists', 'import', 'updates', 'tabs', 'export', - 'team', 'outline', 'settings', 'advanced', 'grading', 'textbooks' - ] - # Log in - self.ui.visit('studio.auto_auth', staff=True) + self.auth_page.visit() # Verify that each page is available - for page in pages: - self.ui.visit('studio.{0}'.format(page), course_id=self.course_id) + for page in self.pages: + page.visit() diff --git a/lms/envs/bok_choy.auth.json b/lms/envs/bok_choy.auth.json index 18c904d92a..931963c6d0 100644 --- a/lms/envs/bok_choy.auth.json +++ b/lms/envs/bok_choy.auth.json @@ -94,7 +94,7 @@ "password": "password", "peer_grading": "peer_grading", "staff_grading": "staff_grading", - "url": "http://localhost:18060/", + "url": "** OVERRIDDEN **", "username": "lms" }, "SECRET_KEY": "", diff --git a/lms/envs/bok_choy.py b/lms/envs/bok_choy.py index 28a1d7d959..851ae22dd6 100644 --- a/lms/envs/bok_choy.py +++ b/lms/envs/bok_choy.py @@ -41,6 +41,9 @@ XML_MODULESTORE['OPTIONS']['data_dir'] = (TEST_ROOT / "data").abspath() # Configure the LMS to use our stub XQueue implementation XQUEUE_INTERFACE['url'] = 'http://localhost:8040' +# Configure the LMS to use our stub ORA implementation +OPEN_ENDED_GRADING_INTERFACE['url'] = 'http://localhost:8041/' + # Enable django-pipeline and staticfiles STATIC_ROOT = (TEST_ROOT / "staticfiles").abspath() PIPELINE = True diff --git a/rakelib/bok_choy.rake b/rakelib/bok_choy.rake index 7a47d53cb7..848590b57a 100644 --- a/rakelib/bok_choy.rake +++ b/rakelib/bok_choy.rake @@ -30,7 +30,11 @@ BOK_CHOY_SERVERS = { } BOK_CHOY_STUBS = { - :xqueue => { :port => 8040, :log => File.join(BOK_CHOY_LOG_DIR, "bok_choy_xqueue.log") } + + :xqueue => { + :port => 8040, + :log => File.join(BOK_CHOY_LOG_DIR, "bok_choy_xqueue.log") + } } # For the time being, stubs are used by both the bok-choy and lettuce acceptance tests @@ -57,8 +61,8 @@ def start_servers() ) end end -end +end # Wait until we get a successful response from the servers or time out def wait_for_test_servers() @@ -162,12 +166,6 @@ namespace :'test:bok_choy' do desc "Process assets and set up database for bok-choy tests" task :setup => [:check_services, :install_prereqs, BOK_CHOY_LOG_DIR] do - # Clear any test data already in Mongo - clear_mongo() - - # Invalidate the cache - BOK_CHOY_CACHE.flush() - # Reset the database sh("#{REPO_ROOT}/scripts/reset-test-db.sh") @@ -182,20 +180,25 @@ namespace :'test:bok_choy' do :check_services, BOK_CHOY_LOG_DIR, BOK_CHOY_REPORT_DIR, :clean_reports_dir ] do |t, args| + # Clear any test data already in Mongo or MySQL and invalidate the cache + clear_mongo() + BOK_CHOY_CACHE.flush() + sh(django_admin('lms', 'bok_choy', 'flush', '--noinput')) + # Ensure the test servers are available - puts "Starting test servers...".red + puts "Starting test servers...".green start_servers() - puts "Waiting for servers to start...".red + puts "Waiting for servers to start...".green wait_for_test_servers() begin - puts "Running test suite...".red + puts "Running test suite...".green run_bok_choy(args.test_spec) rescue puts "Tests failed!".red exit 1 ensure - puts "Cleaning up databases...".red + puts "Cleaning up databases...".green cleanup() end end diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 4581b61194..06358f427e 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -21,4 +21,4 @@ -e git+https://github.com/edx/js-test-tool.git@v0.1.5#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 +-e git+https://github.com/edx/bok-choy.git@v0.1.0#egg=bok_choy