Added Selenium page objects for LMS and studio
This commit is contained in:
1
edxapp_selenium_pages/__init__.py
Normal file
1
edxapp_selenium_pages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
4
edxapp_selenium_pages/lms/__init__.py
Normal file
4
edxapp_selenium_pages/lms/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
# Get the URL of the instance under test
|
||||
BASE_URL = os.environ.get('test_url', '')
|
||||
41
edxapp_selenium_pages/lms/course_about.py
Normal file
41
edxapp_selenium_pages/lms/course_about.py
Normal file
@@ -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')
|
||||
42
edxapp_selenium_pages/lms/course_info.py
Normal file
42
edxapp_selenium_pages/lms/course_info.py
Normal file
@@ -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'])
|
||||
203
edxapp_selenium_pages/lms/course_nav.py
Normal file
203
edxapp_selenium_pages/lms/course_nav.py
Normal file
@@ -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 <span> 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
|
||||
)
|
||||
62
edxapp_selenium_pages/lms/dashboard.py
Normal file
62
edxapp_selenium_pages/lms/dashboard.py
Normal file
@@ -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
|
||||
67
edxapp_selenium_pages/lms/find_courses.py
Normal file
67
edxapp_selenium_pages/lms/find_courses.py
Normal file
@@ -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')
|
||||
61
edxapp_selenium_pages/lms/info.py
Normal file
61
edxapp_selenium_pages/lms/info.py
Normal file
@@ -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()
|
||||
45
edxapp_selenium_pages/lms/login.py
Normal file
45
edxapp_selenium_pages/lms/login.py
Normal file
@@ -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')
|
||||
241
edxapp_selenium_pages/lms/open_response.py
Normal file
241
edxapp_selenium_pages/lms/open_response.py
Normal file
@@ -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")
|
||||
111
edxapp_selenium_pages/lms/progress.py
Normal file
111
edxapp_selenium_pages/lms/progress.py
Normal file
@@ -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]
|
||||
56
edxapp_selenium_pages/lms/register.py
Normal file
56
edxapp_selenium_pages/lms/register.py
Normal file
@@ -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')
|
||||
83
edxapp_selenium_pages/lms/tab_nav.py
Normal file
83
edxapp_selenium_pages/lms/tab_nav.py
Normal file
@@ -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)
|
||||
106
edxapp_selenium_pages/lms/video.py
Normal file
106
edxapp_selenium_pages/lms/video.py
Normal file
@@ -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
|
||||
4
edxapp_selenium_pages/studio/__init__.py
Normal file
4
edxapp_selenium_pages/studio/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import os
|
||||
|
||||
# Get the URL of the instance under test
|
||||
BASE_URL = os.environ.get('test_url', '')
|
||||
26
edxapp_selenium_pages/studio/howitworks.py
Normal file
26
edxapp_selenium_pages/studio/howitworks.py
Normal file
@@ -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'
|
||||
34
edxapp_selenium_pages/studio/login.py
Normal file
34
edxapp_selenium_pages/studio/login.py
Normal file
@@ -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')
|
||||
26
edxapp_selenium_pages/studio/signup.py
Normal file
26
edxapp_selenium_pages/studio/signup.py
Normal file
@@ -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'
|
||||
@@ -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
|
||||
|
||||
29
setup.py
Normal file
29
setup.py
Normal file
@@ -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']
|
||||
)
|
||||
Reference in New Issue
Block a user