Merge pull request #20223 from edx/youngstrom/deprecate-lettuce
Remove lettuce infrastructure
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import world
|
||||
from cms.djangoapps.contentstore.features.common import press_the_notification_button, type_in_codemirror
|
||||
|
||||
KEY_CSS = '.key h3.title'
|
||||
ADVANCED_MODULES_KEY = "Advanced Module List"
|
||||
|
||||
|
||||
def get_index_of(expected_key):
|
||||
for i, element in enumerate(world.css_find(KEY_CSS)):
|
||||
# Sometimes get stale reference if I hold on to the array of elements
|
||||
key = world.css_value(KEY_CSS, index=i)
|
||||
if key == expected_key:
|
||||
return i
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def change_value(step, key, new_value):
|
||||
index = get_index_of(key)
|
||||
type_in_codemirror(index, new_value)
|
||||
press_the_notification_button(step, "Save")
|
||||
world.wait_for_ajax_complete()
|
||||
@@ -1,406 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
import os
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from lettuce import step, world
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from openedx.core.lib.tests.tools import assert_in # pylint: disable=no-name-in-module
|
||||
from student import auth
|
||||
from student.models import get_user
|
||||
from student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff
|
||||
from student.tests.factories import AdminFactory
|
||||
from terrain.browser import reset_data
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the Studio homepage$')
|
||||
def i_visit_the_studio_homepage(_step):
|
||||
# To make this go to port 8001, put
|
||||
# LETTUCE_SERVER_PORT = 8001
|
||||
# in your settings.py file.
|
||||
world.visit('/')
|
||||
signin_css = 'a.action-signin'
|
||||
assert world.is_css_present(signin_css)
|
||||
|
||||
|
||||
@step('I am logged into Studio$')
|
||||
def i_am_logged_into_studio(_step):
|
||||
log_into_studio()
|
||||
|
||||
|
||||
@step('I confirm the alert$')
|
||||
def i_confirm_with_ok(_step):
|
||||
world.browser.get_alert().accept()
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" delete icon$')
|
||||
def i_press_the_category_delete_icon(_step, category):
|
||||
if category == 'section':
|
||||
css = 'a.action.delete-section-button'
|
||||
elif category == 'subsection':
|
||||
css = 'a.action.delete-subsection-button'
|
||||
else:
|
||||
assert False, u'Invalid category: %s' % category
|
||||
world.css_click(css)
|
||||
|
||||
|
||||
@step('I have opened a new course in Studio$')
|
||||
def i_have_opened_a_new_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step('I have populated a new course in Studio$')
|
||||
def i_have_populated_a_new_course(_step):
|
||||
world.clear_courses()
|
||||
course = world.CourseFactory.create()
|
||||
world.scenario_dict['COURSE'] = course
|
||||
section = world.ItemFactory.create(parent_location=course.location)
|
||||
world.ItemFactory.create(
|
||||
parent_location=section.location,
|
||||
category='sequential',
|
||||
display_name='Subsection One',
|
||||
)
|
||||
user = create_studio_user(is_staff=False)
|
||||
add_course_author(user, course)
|
||||
|
||||
log_into_studio()
|
||||
|
||||
world.css_click('a.course-link')
|
||||
world.wait_for_js_to_load()
|
||||
|
||||
|
||||
@step('(I select|s?he selects) the new course')
|
||||
def select_new_course(_step, _whom):
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" notification button$')
|
||||
def press_the_notification_button(_step, name):
|
||||
|
||||
# Because the notification uses a CSS transition,
|
||||
# Selenium will always report it as being visible.
|
||||
# This makes it very difficult to successfully click
|
||||
# the "Save" button at the UI level.
|
||||
# Instead, we use JavaScript to reliably click
|
||||
# the button.
|
||||
btn_css = u'div#page-notification button.action-%s' % name.lower()
|
||||
world.trigger_event(btn_css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(btn_css))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I change the "(.*)" field to "(.*)"$')
|
||||
def i_change_field_to_value(_step, field, value):
|
||||
field_css = '#%s' % '-'.join([s.lower() for s in field.split()])
|
||||
ele = world.css_find(field_css).first
|
||||
ele.fill(value)
|
||||
ele._element.send_keys(Keys.ENTER) # pylint: disable=protected-access
|
||||
|
||||
|
||||
@step('I reset the database')
|
||||
def reset_the_db(_step):
|
||||
"""
|
||||
When running Lettuce tests using examples (i.e. "Confirmation is
|
||||
shown on save" in course-settings.feature), the normal hooks
|
||||
aren't called between examples. reset_data should run before each
|
||||
scenario to flush the test database. When this doesn't happen we
|
||||
get errors due to trying to insert a non-unique entry. So instead,
|
||||
we delete the database manually. This has the effect of removing
|
||||
any users and courses that have been created during the test run.
|
||||
"""
|
||||
reset_data(None)
|
||||
|
||||
|
||||
@step('I see a confirmation that my changes have been saved')
|
||||
def i_see_a_confirmation(_step):
|
||||
confirmation_css = '#alert-confirmation'
|
||||
assert world.is_css_present(confirmation_css)
|
||||
|
||||
|
||||
def open_new_course():
|
||||
world.clear_courses()
|
||||
create_studio_user()
|
||||
log_into_studio()
|
||||
create_a_course()
|
||||
|
||||
|
||||
def create_studio_user(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
is_staff=False):
|
||||
studio_user = world.UserFactory(
|
||||
username=uname,
|
||||
email=email,
|
||||
password=password,
|
||||
is_staff=is_staff)
|
||||
|
||||
registration = world.RegistrationFactory(user=studio_user)
|
||||
registration.register(studio_user)
|
||||
registration.activate()
|
||||
|
||||
return studio_user
|
||||
|
||||
|
||||
def fill_in_course_info(
|
||||
name='Robot Super Course',
|
||||
org='MITx',
|
||||
num='101',
|
||||
run='2013_Spring'):
|
||||
world.css_fill('.new-course-name', name)
|
||||
world.css_fill('.new-course-org', org)
|
||||
world.css_fill('.new-course-number', num)
|
||||
world.css_fill('.new-course-run', run)
|
||||
|
||||
|
||||
def log_into_studio(
|
||||
uname='robot',
|
||||
email='robot+studio@edx.org',
|
||||
password='test',
|
||||
name='Robot Studio'):
|
||||
|
||||
world.log_in(username=uname, password=password, email=email, name=name)
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
assert_in(uname, world.css_text('span.account-username', timeout=10))
|
||||
|
||||
|
||||
def add_course_author(user, course):
|
||||
"""
|
||||
Add the user to the instructor group of the course
|
||||
so they will have the permissions to see it in studio
|
||||
"""
|
||||
global_admin = AdminFactory()
|
||||
for role in (CourseStaffRole, CourseInstructorRole):
|
||||
auth.add_users(global_admin, role(course.id), user)
|
||||
|
||||
|
||||
def create_a_course():
|
||||
course = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course')
|
||||
world.scenario_dict['COURSE'] = course
|
||||
|
||||
user = world.scenario_dict.get("USER")
|
||||
if not user:
|
||||
user = get_user('robot+studio@edx.org')
|
||||
|
||||
add_course_author(user, course)
|
||||
|
||||
# Navigate to the studio dashboard
|
||||
world.visit('/')
|
||||
course_link_css = 'a.course-link'
|
||||
world.css_click(course_link_css)
|
||||
course_title_css = 'span.course-title'
|
||||
assert world.is_css_present(course_title_css)
|
||||
|
||||
|
||||
def add_section():
|
||||
world.css_click('.outline .button-new')
|
||||
assert world.is_css_present('.outline-section .xblock-field-value')
|
||||
|
||||
|
||||
def set_date_and_time(date_css, desired_date, time_css, desired_time, key=None):
|
||||
set_element_value(date_css, desired_date, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
set_element_value(time_css, desired_time, key)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def set_element_value(element_css, element_value, key=None):
|
||||
element = world.css_find(element_css).first
|
||||
element.fill(element_value)
|
||||
# hit TAB or provided key to trigger save content
|
||||
if key is not None:
|
||||
element._element.send_keys(getattr(Keys, key)) # pylint: disable=protected-access
|
||||
else:
|
||||
element._element.send_keys(Keys.TAB) # pylint: disable=protected-access
|
||||
|
||||
|
||||
@step('I have enabled the (.*) advanced module$')
|
||||
def i_enabled_the_advanced_module(step, module):
|
||||
step.given('I have opened a new course section in Studio')
|
||||
world.css_click('.nav-course-settings')
|
||||
world.css_click('.nav-course-settings-advanced a')
|
||||
type_in_codemirror(0, '["%s"]' % module)
|
||||
press_the_notification_button(step, 'Save')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_unit_from_course_outline():
|
||||
"""
|
||||
Expands the section and clicks on the New Unit link.
|
||||
The end result is the page where the user is editing the new unit.
|
||||
"""
|
||||
css_selectors = [
|
||||
'.outline-subsection .expand-collapse', '.outline-subsection .button-new'
|
||||
]
|
||||
for selector in css_selectors:
|
||||
world.css_click(selector)
|
||||
|
||||
world.wait_for_mathjax()
|
||||
world.wait_for_loading()
|
||||
|
||||
assert world.is_css_present('ul.new-component-type')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_loading():
|
||||
"""
|
||||
Waits for the loading indicator to be hidden.
|
||||
"""
|
||||
world.wait_for(lambda _driver: len(world.browser.find_by_css('div.ui-loading.is-hidden')) > 0)
|
||||
|
||||
|
||||
@step('I have clicked the new unit button$')
|
||||
@step(u'I am in Studio editing a new unit$')
|
||||
def edit_new_unit(step):
|
||||
step.given('I have populated a new course in Studio')
|
||||
create_unit_from_course_outline()
|
||||
|
||||
|
||||
@step('the save notification button is disabled')
|
||||
def save_button_disabled(_step):
|
||||
button_css = '.action-save'
|
||||
disabled = 'is-disabled'
|
||||
assert world.css_has_class(button_css, disabled)
|
||||
|
||||
|
||||
@step('the "([^"]*)" button is disabled')
|
||||
def button_disabled(_step, value):
|
||||
button_css = 'input[value="%s"]' % value
|
||||
assert world.css_has_class(button_css, 'is-disabled')
|
||||
|
||||
|
||||
def _do_studio_prompt_action(intent, action):
|
||||
"""
|
||||
Wait for a studio prompt to appear and press the specified action button
|
||||
See common/js/components/views/feedback_prompt.js for implementation
|
||||
"""
|
||||
assert intent in [
|
||||
'warning',
|
||||
'error',
|
||||
'confirmation',
|
||||
'announcement',
|
||||
'step-required',
|
||||
'help',
|
||||
'mini',
|
||||
]
|
||||
assert action in ['primary', 'secondary']
|
||||
|
||||
world.wait_for_present('div.wrapper-prompt.is-shown#prompt-{}'.format(intent))
|
||||
|
||||
action_css = u'li.nav-item > button.action-{}'.format(action)
|
||||
world.trigger_event(action_css, event='focus')
|
||||
world.browser.execute_script("$('{}').click()".format(action_css))
|
||||
|
||||
world.wait_for_ajax_complete()
|
||||
world.wait_for_present('div.wrapper-prompt.is-hiding#prompt-{}'.format(intent))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def confirm_studio_prompt():
|
||||
_do_studio_prompt_action('warning', 'primary')
|
||||
|
||||
|
||||
@step('I confirm the prompt')
|
||||
def confirm_the_prompt(_step):
|
||||
confirm_studio_prompt()
|
||||
|
||||
|
||||
@step(u'I am shown a prompt$')
|
||||
def i_am_shown_a_notification(_step):
|
||||
assert world.is_css_present('.wrapper-prompt')
|
||||
|
||||
|
||||
def type_in_codemirror(index, text, find_prefix="$"):
|
||||
script = """
|
||||
var cm = {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror;
|
||||
cm.getInputField().focus();
|
||||
cm.setValue(arguments[0]);
|
||||
cm.getInputField().blur();""".format(index=index, find_prefix=find_prefix)
|
||||
world.browser.driver.execute_script(script, str(text))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def get_codemirror_value(index=0, find_prefix="$"):
|
||||
return world.browser.driver.execute_script(
|
||||
u"""
|
||||
return {find_prefix}('div.CodeMirror:eq({index})').get(0).CodeMirror.getValue();
|
||||
""".format(index=index, find_prefix=find_prefix)
|
||||
)
|
||||
|
||||
|
||||
def attach_file(filename, sub_path):
|
||||
path = os.path.join(TEST_ROOT, sub_path, filename)
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
assert os.path.exists(path)
|
||||
world.browser.attach_file('file', os.path.abspath(path))
|
||||
|
||||
|
||||
def upload_file(filename, sub_path=''):
|
||||
# The file upload dialog is a faux modal, a div that takes over the display
|
||||
attach_file(filename, sub_path)
|
||||
modal_css = 'div.wrapper-modal-window-assetupload'
|
||||
button_css = u'{} .action-upload'.format(modal_css)
|
||||
world.css_click(button_css)
|
||||
|
||||
# Clicking the Upload button triggers an AJAX POST.
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
# The modal stays up with a "File uploaded succeeded" confirmation message, then goes away.
|
||||
# It should take under 2 seconds, so wait up to 10.
|
||||
# Note that is_css_not_present will return as soon as the element is gone.
|
||||
assert world.is_css_not_present(modal_css, wait_time=10)
|
||||
|
||||
|
||||
@step(u'"([^"]*)" logs in$')
|
||||
def other_user_login(step, name):
|
||||
step.given('I log out')
|
||||
world.visit('/')
|
||||
|
||||
signin_css = 'a.action-signin'
|
||||
world.is_css_present(signin_css)
|
||||
world.css_click(signin_css)
|
||||
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill(name + '@edx.org')
|
||||
login_form.find_by_name('password').fill("test")
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
assert world.is_css_present('.new-course-button')
|
||||
world.scenario_dict['USER'] = get_user(name + '@edx.org')
|
||||
|
||||
|
||||
@step(u'the user "([^"]*)" exists( as a course (admin|staff member|is_staff))?$')
|
||||
def create_other_user(_step, name, has_extra_perms, role_name):
|
||||
email = name + '@edx.org'
|
||||
user = create_studio_user(uname=name, password="test", email=email)
|
||||
if has_extra_perms:
|
||||
if role_name == "is_staff":
|
||||
GlobalStaff().add_users(user)
|
||||
else:
|
||||
if role_name == "admin":
|
||||
# admins get staff privileges, as well
|
||||
roles = (CourseStaffRole, CourseInstructorRole)
|
||||
else:
|
||||
roles = (CourseStaffRole,)
|
||||
course_key = world.scenario_dict["COURSE"].id
|
||||
global_admin = AdminFactory()
|
||||
for role in roles:
|
||||
auth.add_users(global_admin, role(course_key), user)
|
||||
|
||||
|
||||
@step('I log out')
|
||||
def log_out(_step):
|
||||
world.visit('logout')
|
||||
@@ -1,181 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
# Lettuce formats proposed definitions for unimplemented steps with the
|
||||
# argument name "step" instead of "_step" and pylint does not like that.
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import step, world
|
||||
from openedx.core.lib.tests.tools import assert_equal, assert_in, assert_true # pylint: disable=no-name-in-module
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
|
||||
|
||||
@step(u'I add this type of single step component:$')
|
||||
def add_a_single_step_component(step):
|
||||
for step_hash in step.hashes:
|
||||
component = step_hash['Component']
|
||||
assert_in(component, ['Discussion', 'Video'])
|
||||
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='{}'.format(component.lower()),
|
||||
)
|
||||
|
||||
|
||||
@step(u'I see this type of single step component:$')
|
||||
def see_a_single_step_component(step):
|
||||
for step_hash in step.hashes:
|
||||
component = step_hash['Component']
|
||||
assert_in(component, ['Discussion', 'Video'])
|
||||
component_css = '.xmodule_{}Module'.format(component)
|
||||
assert_true(world.is_css_present(component_css),
|
||||
u"{} couldn't be found".format(component))
|
||||
|
||||
|
||||
@step(u'I add this type of( Advanced)? (HTML|Problem) component:$')
|
||||
def add_a_multi_step_component(step, is_advanced, category):
|
||||
for step_hash in step.hashes:
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='{}'.format(category.lower()),
|
||||
component_type=step_hash['Component'],
|
||||
is_advanced=bool(is_advanced),
|
||||
)
|
||||
|
||||
|
||||
@step(u'I see (HTML|Problem) components in this order:')
|
||||
def see_a_multi_step_component(step, category):
|
||||
|
||||
# Wait for all components to finish rendering
|
||||
if category == 'HTML':
|
||||
selector = 'li.studio-xblock-wrapper div.xblock-student_view'
|
||||
else:
|
||||
selector = 'li.studio-xblock-wrapper div.xblock-author_view'
|
||||
world.wait_for(lambda _: len(world.css_find(selector)) == len(step.hashes))
|
||||
|
||||
for idx, step_hash in enumerate(step.hashes):
|
||||
if category == 'HTML':
|
||||
html_matcher = {
|
||||
'Text': '\n \n',
|
||||
'Announcement': '<h3 class="hd hd-2">Announcement Date</h3>',
|
||||
'Zooming Image Tool': '<h3 class="hd hd-2">Zooming Image Tool</h3>',
|
||||
'E-text Written in LaTeX': '<h3 class="hd hd-2">Example: E-text page</h3>',
|
||||
'Raw HTML': '<p>This template is similar to the Text template. The only difference is',
|
||||
}
|
||||
actual_html = world.css_html(selector, index=idx)
|
||||
assert_in(html_matcher[step_hash['Component']].strip(), actual_html.strip())
|
||||
else:
|
||||
actual_text = world.css_text(selector, index=idx)
|
||||
assert_in(step_hash['Component'], actual_text)
|
||||
|
||||
|
||||
@step(u'I see a "([^"]*)" Problem component$')
|
||||
def see_a_problem_component(step, category):
|
||||
component_css = '.xmodule_CapaModule'
|
||||
assert_true(world.is_css_present(component_css),
|
||||
'No problem was added to the unit.')
|
||||
|
||||
problem_css = '.studio-xblock-wrapper .xblock-student_view'
|
||||
# This view presents the given problem component in uppercase. Assert that the text matches
|
||||
# the component selected
|
||||
assert_true(world.css_contains_text(problem_css, category))
|
||||
|
||||
|
||||
@step(u'I add a "([^"]*)" "([^"]*)" component$')
|
||||
def add_component_category(step, component, category):
|
||||
assert category in ('single step', 'HTML', 'Problem', 'Advanced Problem')
|
||||
given_string = u'I add this type of {} component:'.format(category)
|
||||
step.given('{}\n{}\n{}'.format(given_string, '|Component|', '|{}|'.format(component)))
|
||||
|
||||
|
||||
@step(u'I delete all components$')
|
||||
def delete_all_components(step):
|
||||
count = len(world.css_find('.reorderable-container .studio-xblock-wrapper'))
|
||||
step.given('I delete "' + str(count) + '" component')
|
||||
|
||||
|
||||
@step(u'I delete "([^"]*)" component$')
|
||||
def delete_components(step, number):
|
||||
world.wait_for_xmodule()
|
||||
delete_btn_css = '.delete-button'
|
||||
prompt_css = '#prompt-warning'
|
||||
btn_css = u'{} .action-primary'.format(prompt_css)
|
||||
saving_mini_css = '#page-notification .wrapper-notification-mini'
|
||||
for _ in range(int(number)):
|
||||
world.css_click(delete_btn_css)
|
||||
assert_true(
|
||||
world.is_css_present('{}.is-shown'.format(prompt_css)),
|
||||
msg='Waiting for the confirmation prompt to be shown')
|
||||
|
||||
# Pressing the button via css was not working reliably for the last component
|
||||
# when run in Chrome.
|
||||
if world.browser.driver_name == 'Chrome':
|
||||
world.browser.execute_script("$('{}').click()".format(btn_css))
|
||||
else:
|
||||
world.css_click(btn_css)
|
||||
|
||||
# Wait for the saving notification to pop up then disappear
|
||||
if world.is_css_present('{}.is-shown'.format(saving_mini_css)):
|
||||
world.css_find('{}.is-hiding'.format(saving_mini_css))
|
||||
|
||||
|
||||
@step(u'I see no components')
|
||||
def see_no_components(steps):
|
||||
assert world.is_css_not_present('li.studio-xblock-wrapper')
|
||||
|
||||
|
||||
@step(u'I delete a component')
|
||||
def delete_one_component(step):
|
||||
world.css_click('.delete-button')
|
||||
|
||||
|
||||
@step(u'I edit and save a component')
|
||||
def edit_and_save_component(step):
|
||||
world.css_click('.edit-button')
|
||||
world.css_click('.save-button')
|
||||
|
||||
|
||||
@step(u'I duplicate the (first|second|third) component$')
|
||||
def duplicated_component(step, ordinal):
|
||||
ord_map = {
|
||||
"first": 0,
|
||||
"second": 1,
|
||||
"third": 2,
|
||||
}
|
||||
index = ord_map[ordinal]
|
||||
duplicate_btn_css = '.duplicate-button'
|
||||
world.css_click(duplicate_btn_css, int(index))
|
||||
|
||||
|
||||
@step(u'I see a Problem component with display name "([^"]*)" in position "([^"]*)"$')
|
||||
def see_component_in_position(step, display_name, index):
|
||||
component_css = '.xmodule_CapaModule'
|
||||
|
||||
def find_problem(_driver):
|
||||
return world.css_text(component_css, int(index)).startswith(display_name)
|
||||
|
||||
world.wait_for(find_problem, timeout_msg='Did not find the duplicated problem')
|
||||
|
||||
|
||||
@step(u'I see the display name is "([^"]*)"')
|
||||
def check_component_display_name(step, display_name):
|
||||
# The display name for the unit uses the same structure, must differentiate by level-element.
|
||||
label = world.css_html(".level-element>header>div>div>span.xblock-display-name")
|
||||
assert_equal(display_name, label)
|
||||
|
||||
|
||||
@step(u'I change the display name to "([^"]*)"')
|
||||
def change_display_name(step, display_name):
|
||||
world.edit_component_and_select_settings()
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, display_name)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step(u'I unset the display name')
|
||||
def unset_display_name(step):
|
||||
world.edit_component_and_select_settings()
|
||||
world.revert_setting_entry(DISPLAY_NAME)
|
||||
world.save_component()
|
||||
@@ -1,270 +0,0 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
|
||||
from lettuce import world
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from common import type_in_codemirror
|
||||
from openedx.core.lib.tests.tools import assert_equal, assert_in # pylint: disable=no-name-in-module
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_component_instance(step, category, component_type=None, is_advanced=False, advanced_component=None):
|
||||
"""
|
||||
Create a new component in a Unit.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
category: component type (discussion, html, problem, video, advanced)
|
||||
component_type: for components with multiple templates, the link text in the menu
|
||||
is_advanced: for problems, is the desired component under the advanced menu?
|
||||
advanced_component: for advanced components, the related value of policy key 'advanced_modules'
|
||||
"""
|
||||
assert_in(category, ['advanced', 'problem', 'html', 'video', 'discussion'])
|
||||
|
||||
component_button_css = 'span.large-{}-icon'.format(category.lower())
|
||||
if category == 'problem':
|
||||
module_css = 'div.xmodule_CapaModule'
|
||||
elif category == 'advanced':
|
||||
module_css = 'div.xmodule_{}Module'.format(advanced_component.title())
|
||||
elif category == 'discussion':
|
||||
module_css = 'div.xblock-author_view-{}'.format(category.lower())
|
||||
else:
|
||||
module_css = 'div.xmodule_{}Module'.format(category.title())
|
||||
|
||||
# Count how many of that module is on the page. Later we will
|
||||
# assert that one more was added.
|
||||
# We need to use world.browser.find_by_css instead of world.css_find
|
||||
# because it's ok if there are currently zero of them.
|
||||
module_count_before = len(world.browser.find_by_css(module_css))
|
||||
|
||||
# Disable the jquery animation for the transition to the menus.
|
||||
world.disable_jquery_animations()
|
||||
world.css_click(component_button_css)
|
||||
|
||||
if category in ('problem', 'html', 'advanced'):
|
||||
world.wait_for_invisible(component_button_css)
|
||||
click_component_from_menu(category, component_type, is_advanced)
|
||||
|
||||
expected_count = module_count_before + 1
|
||||
world.wait_for(
|
||||
lambda _: len(world.css_find(module_css)) == expected_count,
|
||||
timeout=20
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_new_component_button(step, component_button_css):
|
||||
step.given('I have clicked the new unit button')
|
||||
|
||||
world.css_click(component_button_css)
|
||||
|
||||
|
||||
def _click_advanced():
|
||||
css = 'ul.problem-type-tabs a[href="#tab2"]'
|
||||
world.css_click(css)
|
||||
|
||||
# Wait for the advanced tab items to be displayed
|
||||
tab2_css = 'div.ui-tabs-panel#tab2'
|
||||
world.wait_for_visible(tab2_css)
|
||||
|
||||
|
||||
def _find_matching_button(category, component_type):
|
||||
"""
|
||||
Find the button with the specified text. There should be one and only one.
|
||||
"""
|
||||
|
||||
# The tab shows buttons for the given category
|
||||
buttons = world.css_find(u'div.new-component-{} button'.format(category))
|
||||
|
||||
# Find the button whose text matches what you're looking for
|
||||
matched_buttons = [btn for btn in buttons if btn.text == component_type]
|
||||
|
||||
# There should be one and only one
|
||||
assert_equal(len(matched_buttons), 1)
|
||||
return matched_buttons[0]
|
||||
|
||||
|
||||
def click_component_from_menu(category, component_type, is_advanced):
|
||||
"""
|
||||
Creates a component for a category with more
|
||||
than one template, i.e. HTML and Problem.
|
||||
For some problem types, it is necessary to click to
|
||||
the Advanced tab.
|
||||
The component_type is the link text, e.g. "Blank Common Problem"
|
||||
"""
|
||||
if is_advanced:
|
||||
# Sometimes this click does not work if you go too fast.
|
||||
world.retry_on_exception(
|
||||
_click_advanced,
|
||||
ignored_exceptions=AssertionError,
|
||||
)
|
||||
|
||||
# Retry this in case the list is empty because you tried too fast.
|
||||
link = world.retry_on_exception(
|
||||
lambda: _find_matching_button(category, component_type),
|
||||
ignored_exceptions=AssertionError
|
||||
)
|
||||
|
||||
# Wait for the link to be clickable. If you go too fast it is not.
|
||||
world.retry_on_exception(lambda: link.click())
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component_and_select_settings():
|
||||
world.edit_component()
|
||||
world.ensure_settings_visible()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def ensure_settings_visible():
|
||||
# Select the 'settings' tab if there is one (it isn't displayed if it is the only option)
|
||||
settings_button = world.browser.find_by_css('.settings-button')
|
||||
if settings_button:
|
||||
world.css_click('.settings-button')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def edit_component(index=0):
|
||||
# Verify that the "loading" indication has been hidden.
|
||||
world.wait_for_loading()
|
||||
# Verify that the "edit" button is present.
|
||||
world.wait_for(lambda _driver: world.css_visible('.edit-button'))
|
||||
world.css_click('.edit-button', index)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def select_editor_tab(tab_name):
|
||||
editor_tabs = world.browser.find_by_css('.editor-tabs a')
|
||||
expected_tab_text = tab_name.strip().upper()
|
||||
matching_tabs = [tab for tab in editor_tabs if tab.text.upper() == expected_tab_text]
|
||||
assert len(matching_tabs) == 1
|
||||
tab = matching_tabs[0]
|
||||
tab.click()
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
def enter_xml_in_advanced_problem(_step, text):
|
||||
"""
|
||||
Edits an advanced problem (assumes only on page),
|
||||
types the provided XML, and saves the component.
|
||||
"""
|
||||
world.edit_component()
|
||||
type_in_codemirror(0, text)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_setting_entry(setting, display_name, value, explicitly_set):
|
||||
"""
|
||||
Verify the capa module fields are set as expected in the
|
||||
Advanced Settings editor.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
setting: the WebDriverElement object found in the browser
|
||||
display_name: the string expected as the label
|
||||
html: the expected field value
|
||||
explicitly_set: True if the value is expected to have been explicitly set
|
||||
for the problem, rather than derived from the defaults. This is verified
|
||||
by the existence of a "Clear" button next to the field value.
|
||||
"""
|
||||
label_element = setting.find_by_css('.setting-label')[0]
|
||||
assert_equal(display_name, label_element.html.strip())
|
||||
label_for = label_element['for']
|
||||
|
||||
# Check if the web object is a list type
|
||||
# If so, we use a slightly different mechanism for determining its value
|
||||
if setting.has_class('metadata-list-enum') or setting.has_class('metadata-dict') or setting.has_class('metadata-video-translations'):
|
||||
list_value = ', '.join(ele.value for ele in setting.find_by_css('.list-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
elif setting.has_class('metadata-videolist-enum'):
|
||||
list_value = ', '.join(ele.find_by_css('input')[0].value for ele in setting.find_by_css('.videolist-settings-item'))
|
||||
assert_equal(value, list_value)
|
||||
else:
|
||||
assert_equal(value, setting.find_by_id(label_for).value)
|
||||
|
||||
# VideoList doesn't have clear button
|
||||
if not setting.has_class('metadata-videolist-enum'):
|
||||
settingClearButton = setting.find_by_css('.setting-clear')[0]
|
||||
assert_equal(explicitly_set, settingClearButton.has_class('active'))
|
||||
assert_equal(not explicitly_set, settingClearButton.has_class('inactive'))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def verify_all_setting_entries(expected_entries):
|
||||
settings = world.browser.find_by_css('.wrapper-comp-setting')
|
||||
assert_equal(len(expected_entries), len(settings))
|
||||
for (counter, setting) in enumerate(settings):
|
||||
world.verify_setting_entry(
|
||||
setting, expected_entries[counter][0],
|
||||
expected_entries[counter][1], expected_entries[counter][2]
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_component():
|
||||
world.css_click("a.action-save,a.save-button")
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_component_and_reopen(step):
|
||||
save_component()
|
||||
# We have a known issue that modifications are still shown within the edit window after cancel (though)
|
||||
# they are not persisted. Refresh the browser to make sure the changes WERE persisted after Save.
|
||||
reload_the_page(step)
|
||||
edit_component_and_select_settings()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def cancel_component(step):
|
||||
world.css_click("a.action-cancel")
|
||||
# We have a known issue that modifications are still shown within the edit window after cancel (though)
|
||||
# they are not persisted. Refresh the browser to make sure the changes were not persisted.
|
||||
reload_the_page(step)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def revert_setting_entry(label):
|
||||
get_setting_entry(label).find_by_css('.setting-clear')[0].click()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry(label):
|
||||
def get_setting():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for setting in settings:
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return setting
|
||||
return None
|
||||
return world.retry_on_exception(get_setting)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def get_setting_entry_index(label):
|
||||
def get_index():
|
||||
settings = world.css_find('.wrapper-comp-setting')
|
||||
for index, setting in enumerate(settings):
|
||||
if setting.find_by_css('.setting-label')[0].value == label:
|
||||
return index
|
||||
return None
|
||||
return world.retry_on_exception(get_index)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def set_field_value(index, value):
|
||||
"""
|
||||
Set the field to the specified value.
|
||||
|
||||
Note: we cannot use css_fill here because the value is not set
|
||||
until after you move away from that field.
|
||||
Instead we will find the element, set its value, then hit the Tab key
|
||||
to get to the next field.
|
||||
"""
|
||||
elem = world.css_find('div.wrapper-comp-setting input')[index]
|
||||
elem.value = value
|
||||
elem.type(Keys.TAB)
|
||||
@@ -1,170 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from django.conf import settings
|
||||
from lettuce import step, world
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
|
||||
from cms.djangoapps.contentstore.features.common import type_in_codemirror
|
||||
|
||||
TEST_ROOT = settings.COMMON_TEST_DATA_ROOT
|
||||
|
||||
COURSE_START_DATE_CSS = "#course-start-date"
|
||||
COURSE_END_DATE_CSS = "#course-end-date"
|
||||
ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date"
|
||||
ENROLLMENT_END_DATE_CSS = "#course-enrollment-end-date"
|
||||
|
||||
COURSE_START_TIME_CSS = "#course-start-time"
|
||||
COURSE_END_TIME_CSS = "#course-end-time"
|
||||
ENROLLMENT_START_TIME_CSS = "#course-enrollment-start-time"
|
||||
ENROLLMENT_END_TIME_CSS = "#course-enrollment-end-time"
|
||||
|
||||
DUMMY_TIME = "15:30"
|
||||
DEFAULT_TIME = "00:00"
|
||||
|
||||
|
||||
############### ACTIONS ####################
|
||||
@step('I select Schedule and Details$')
|
||||
def test_i_select_schedule_and_details(_step):
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-schedule a'
|
||||
world.css_click(link_css)
|
||||
world.wait_for_requirejs(
|
||||
["jquery", "js/models/course",
|
||||
"js/models/settings/course_details", "js/views/settings/main"])
|
||||
|
||||
|
||||
@step('I have set course dates$')
|
||||
def test_i_have_set_course_dates(step):
|
||||
step.given('I have opened a new course in Studio')
|
||||
step.given('I select Schedule and Details')
|
||||
step.given('And I set course dates')
|
||||
|
||||
|
||||
@step('And I set course dates$')
|
||||
def test_and_i_set_course_dates(_step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '12/1/2013')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('And I clear all the dates except start$')
|
||||
def test_and_i_clear_all_the_dates_except_start(_step):
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('Then I see cleared dates$')
|
||||
def test_then_i_see_cleared_dates(_step):
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '')
|
||||
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, '')
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, '')
|
||||
|
||||
# Verify course start date (required) and time still there
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I clear the course start date$')
|
||||
def test_i_clear_the_course_start_date(_step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '')
|
||||
|
||||
|
||||
@step('I receive a warning about course start date$')
|
||||
def test_i_receive_a_warning_about_course_start_date(_step):
|
||||
assert world.css_has_text('.message-error', 'The course must have an assigned start date.')
|
||||
assert 'error' in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class') # pylint: disable=protected-access
|
||||
assert 'error' in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class') # pylint: disable=protected-access
|
||||
|
||||
|
||||
@step('the previously set start date is shown$')
|
||||
def test_the_previously_set_start_date_is_shown(_step):
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('Given I have tried to clear the course start$')
|
||||
def test_i_have_tried_to_clear_the_course_start(step):
|
||||
step.given("I have set course dates")
|
||||
step.given("I clear the course start date")
|
||||
step.given("I receive a warning about course start date")
|
||||
|
||||
|
||||
@step('I have entered a new course start date$')
|
||||
def test_i_have_entered_a_new_course_start_date(_step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
|
||||
|
||||
@step('The warning about course start date goes away$')
|
||||
def test_the_warning_about_course_start_date_goes_away(_step):
|
||||
assert world.is_css_not_present('.message-error')
|
||||
assert 'error' not in world.css_find(COURSE_START_DATE_CSS).first._element.get_attribute('class') # pylint: disable=protected-access
|
||||
assert 'error' not in world.css_find(COURSE_START_TIME_CSS).first._element.get_attribute('class') # pylint: disable=protected-access
|
||||
|
||||
|
||||
@step('my new course start date is shown$')
|
||||
def new_course_start_date_is_shown(_step):
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/22/2013')
|
||||
# Time should have stayed from before attempt to clear date.
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
|
||||
|
||||
@step('I change fields$')
|
||||
def test_i_change_fields(_step):
|
||||
set_date_or_time(COURSE_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(COURSE_END_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_START_DATE_CSS, '7/7/7777')
|
||||
set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777')
|
||||
|
||||
|
||||
@step('I change the course overview')
|
||||
def test_change_course_overview(_step):
|
||||
type_in_codemirror(0, "<h1>Overview</h1>")
|
||||
|
||||
|
||||
############### HELPER METHODS ####################
|
||||
def set_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Sets date or time field.
|
||||
"""
|
||||
world.css_fill(css, date_or_time)
|
||||
e = world.css_find(css).first
|
||||
# hit Enter to apply the changes
|
||||
e._element.send_keys(Keys.ENTER) # pylint: disable=protected-access
|
||||
|
||||
|
||||
def verify_date_or_time(css, date_or_time):
|
||||
"""
|
||||
Verifies date or time field.
|
||||
"""
|
||||
# We need to wait for JavaScript to fill in the field, so we use
|
||||
# css_has_value(), which first checks that the field is not blank
|
||||
assert world.css_has_value(css, date_or_time)
|
||||
|
||||
|
||||
@step('I do not see the changes')
|
||||
@step('I see the set dates')
|
||||
def i_see_the_set_dates(_step):
|
||||
"""
|
||||
Ensure that each field has the value set in `test_and_i_set_course_dates`.
|
||||
"""
|
||||
verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013')
|
||||
verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013')
|
||||
verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013')
|
||||
verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013')
|
||||
|
||||
verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME)
|
||||
# Unset times get set to 12 AM once the corresponding date has been set.
|
||||
verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME)
|
||||
verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME)
|
||||
@@ -1,27 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
import os
|
||||
|
||||
from django.conf import settings
|
||||
from lettuce import step, world
|
||||
|
||||
|
||||
def import_file(filename):
|
||||
world.browser.execute_script("$('input.file-input').css('display', 'block')")
|
||||
path = os.path.join(settings.COMMON_TEST_DATA_ROOT, "imports", filename)
|
||||
world.browser.attach_file('course-data', os.path.abspath(path))
|
||||
world.css_click('input.submit-button')
|
||||
# Go to course outline
|
||||
world.click_course_content()
|
||||
outline_css = 'li.nav-course-courseware-outline a'
|
||||
world.css_click(outline_css)
|
||||
|
||||
|
||||
@step('I go to the import page$')
|
||||
def go_to_import(step):
|
||||
menu_css = 'li.nav-course-tools'
|
||||
import_css = 'li.nav-course-tools-import a'
|
||||
world.css_click(menu_css)
|
||||
world.css_click(import_css)
|
||||
@@ -1,128 +0,0 @@
|
||||
@shard_2
|
||||
Feature: CMS.HTML Editor
|
||||
As a course author, I want to be able to create HTML blocks.
|
||||
|
||||
Scenario: User can view metadata
|
||||
Given I have created a Blank HTML Page
|
||||
And I edit and select Settings
|
||||
Then I see the HTML component settings
|
||||
|
||||
# Safari doesn't save the name properly
|
||||
@skip_safari
|
||||
Scenario: User can modify display name
|
||||
Given I have created a Blank HTML Page
|
||||
And I edit and select Settings
|
||||
Then I can modify the display name
|
||||
And my display name change is persisted on save
|
||||
|
||||
Scenario: TinyMCE link plugin sets urls correctly
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I add a link with static link "/static/image.jpg" via the Link Plugin Icon
|
||||
Then the href link is rewritten to the asset link "image.jpg"
|
||||
And the link is shown as "/static/image.jpg" in the Link Plugin
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve style tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<p class='title'>pages</p><style><!-- .title { color: red; } --></style>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<p class="title">pages</p>
|
||||
<style><!--
|
||||
.title { color: red; }
|
||||
--></style>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve span tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<span>Test</span>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<span>Test</span>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE and CodeMirror preserve math tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<math><msup><mi>x</mi><mn>2</mn></msup></math>" in the code editor and press OK
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<math><msup><mi>x</mi><mn>2</mn></msup></math>
|
||||
"""
|
||||
|
||||
Scenario: TinyMCE toolbar buttons are as expected
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
Then the expected toolbar buttons are displayed
|
||||
|
||||
Scenario: Static links are converted when switching between code editor and WYSIWYG views
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And type "<img src="/static/image.jpg">" in the code editor and press OK
|
||||
Then the src link is rewritten to the asset link "image.jpg"
|
||||
And the code editor displays "<p><img src="/static/image.jpg" /></p>"
|
||||
|
||||
Scenario: Code format toolbar button wraps text with code tags
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I set the text to "display as code" and I select the text
|
||||
And I select the code toolbar button
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<p><code>display as code</code></p>
|
||||
"""
|
||||
|
||||
Scenario: Raw HTML component does not change text
|
||||
Given I have created a raw HTML component
|
||||
When I edit the page
|
||||
And type "<li>zzzz<ol>" into the Raw Editor
|
||||
And I save the page
|
||||
Then the page text contains:
|
||||
"""
|
||||
<li>zzzz<ol>
|
||||
"""
|
||||
And I edit the page
|
||||
Then the Raw Editor contains exactly:
|
||||
"""
|
||||
<li>zzzz<ol>
|
||||
"""
|
||||
|
||||
Scenario: Font selection dropdown contains Default font and tinyMCE builtin fonts
|
||||
Given I have created a Blank HTML Page
|
||||
When I edit the page
|
||||
And I click font selection dropdown
|
||||
Then I should see a list of available fonts
|
||||
And "Default" option sets the expected font family
|
||||
And all standard tinyMCE fonts should be available
|
||||
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Visual Editor to Raw
|
||||
# Given I have created a Blank HTML Page
|
||||
# When I edit the component and select the Raw Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "fancy html" into the Raw Editor
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# fancy html
|
||||
# """
|
||||
|
||||
# Skipping in master due to brittleness JZ 05/22/2014
|
||||
# Scenario: Can switch from Raw Editor to Visual
|
||||
# Given I have created a raw HTML component
|
||||
# And I edit the component and select the Visual Editor
|
||||
# And I save the page
|
||||
# When I edit the page
|
||||
# And type "less fancy html" in the code editor and press OK
|
||||
# And I save the page
|
||||
# Then the page text contains:
|
||||
# """
|
||||
# less fancy html
|
||||
# """
|
||||
@@ -1,306 +0,0 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from lettuce import step, world
|
||||
|
||||
from common import get_codemirror_value, type_in_codemirror
|
||||
from openedx.core.lib.tests.tools import assert_equal, assert_in # pylint: disable=no-name-in-module
|
||||
|
||||
CODEMIRROR_SELECTOR_PREFIX = "$('iframe').contents().find"
|
||||
|
||||
|
||||
@step('I have created a Blank HTML Page$')
|
||||
def i_created_blank_html_page(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='Text'
|
||||
)
|
||||
|
||||
|
||||
@step('I have created a raw HTML component')
|
||||
def i_created_raw_html(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='Raw HTML'
|
||||
)
|
||||
|
||||
|
||||
@step('I see the HTML component settings$')
|
||||
def i_see_only_the_html_display_name(_step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
['Display Name', "Text", False],
|
||||
['Editor', "Visual", False]
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@step('I have created an E-text Written in LaTeX$')
|
||||
def i_created_etext_in_latex(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given('I have enabled latex compiler')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='html',
|
||||
component_type='E-text Written in LaTeX'
|
||||
)
|
||||
|
||||
|
||||
@step('I edit the page$')
|
||||
def i_click_on_edit_icon(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step('I add a link with static link "(.*)" via the Link Plugin Icon$')
|
||||
def i_click_on_link_plugin_icon(_step, path):
|
||||
def fill_in_link_fields():
|
||||
world.css_fill('.mce-textbox', path, 0)
|
||||
world.css_fill('.mce-textbox', 'picture', 1)
|
||||
|
||||
use_plugin('.mce-i-link', fill_in_link_fields)
|
||||
|
||||
|
||||
@step('the link is shown as "(.*)" in the Link Plugin$')
|
||||
def check_link_in_link_plugin(_step, path):
|
||||
# Ensure caret position is within the link just created.
|
||||
script = """
|
||||
var editor = tinyMCE.activeEditor;
|
||||
editor.selection.select(editor.dom.select('a')[0]);"""
|
||||
world.browser.driver.execute_script(script)
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
use_plugin(
|
||||
'.mce-i-link',
|
||||
lambda: assert_equal(path, world.css_find('.mce-textbox')[0].value)
|
||||
)
|
||||
|
||||
|
||||
@step('type "(.*)" in the code editor and press OK$')
|
||||
def type_in_codemirror_plugin(_step, text):
|
||||
# Verify that raw code editor is not visible.
|
||||
assert world.css_has_class('.CodeMirror', 'is-inactive')
|
||||
# Verify that TinyMCE editor is present
|
||||
assert world.is_css_present('.tiny-mce')
|
||||
use_code_editor(
|
||||
lambda: type_in_codemirror(0, text, CODEMIRROR_SELECTOR_PREFIX)
|
||||
)
|
||||
|
||||
|
||||
@step('and the code editor displays "(.*)"$')
|
||||
def verify_code_editor_text(_step, text):
|
||||
use_code_editor(
|
||||
lambda: assert_equal(text, get_codemirror_value(0, CODEMIRROR_SELECTOR_PREFIX))
|
||||
)
|
||||
|
||||
|
||||
@step('I save the page$')
|
||||
def i_click_on_save(_step):
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step('the page text contains:')
|
||||
def check_page_text(step):
|
||||
assert_in(step.multiline, world.css_find('.xmodule_HtmlModule').html)
|
||||
|
||||
|
||||
@step('the Raw Editor contains exactly:')
|
||||
def check_raw_editor_text(step):
|
||||
assert_equal(step.multiline, get_codemirror_value(0))
|
||||
|
||||
|
||||
@step('the src link is rewritten to the asset link "(.*)"$')
|
||||
def image_static_link_is_rewritten(_step, path):
|
||||
# Find the TinyMCE iframe within the main window
|
||||
with world.browser.get_iframe('mce_0_ifr') as tinymce:
|
||||
image = tinymce.find_by_tag('img').first
|
||||
assert_in(unicode(world.scenario_dict['COURSE'].id.make_asset_key('asset', path)), image['src'])
|
||||
|
||||
|
||||
@step('the href link is rewritten to the asset link "(.*)"$')
|
||||
def link_static_link_is_rewritten(_step, path):
|
||||
# Find the TinyMCE iframe within the main window
|
||||
with world.browser.get_iframe('mce_0_ifr') as tinymce:
|
||||
link = tinymce.find_by_tag('a').first
|
||||
assert_in(unicode(world.scenario_dict['COURSE'].id.make_asset_key('asset', path)), link['href'])
|
||||
|
||||
|
||||
@step('the expected toolbar buttons are displayed$')
|
||||
def check_toolbar_buttons(_step):
|
||||
dropdowns = world.css_find('.mce-listbox')
|
||||
assert_equal(2, len(dropdowns))
|
||||
|
||||
# Format dropdown
|
||||
assert_equal('Paragraph', dropdowns[0].text)
|
||||
# Font dropdown
|
||||
assert_equal('Font Family', dropdowns[1].text)
|
||||
|
||||
buttons = world.css_find('.mce-ico')
|
||||
|
||||
# Note that the code editor icon is not present because we are now showing text instead of an icon.
|
||||
# However, other test points user the code editor, so we have already verified its presence.
|
||||
expected_buttons = [
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'forecolor',
|
||||
# This is our custom "code style" button, which uses an image instead of a class.
|
||||
'none',
|
||||
'alignleft',
|
||||
'aligncenter',
|
||||
'alignright',
|
||||
'alignjustify',
|
||||
'bullist',
|
||||
'numlist',
|
||||
'outdent',
|
||||
'indent',
|
||||
'blockquote',
|
||||
'link',
|
||||
'unlink',
|
||||
'image'
|
||||
]
|
||||
|
||||
assert_equal(len(expected_buttons), len(buttons))
|
||||
|
||||
for index, button in enumerate(expected_buttons):
|
||||
class_names = buttons[index]._element.get_attribute('class') # pylint: disable=protected-access
|
||||
assert_equal("mce-ico mce-i-" + button, class_names)
|
||||
|
||||
|
||||
@step('I set the text to "(.*)" and I select the text$')
|
||||
def set_text_and_select(_step, text):
|
||||
script = """
|
||||
var editor = tinyMCE.activeEditor;
|
||||
editor.setContent(arguments[0]);
|
||||
editor.selection.select(editor.dom.select('p')[0]);"""
|
||||
world.browser.driver.execute_script(script, str(text))
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I select the code toolbar button$')
|
||||
def select_code_button(_step):
|
||||
# This is our custom "code style" button. It uses an image instead of a class.
|
||||
world.css_click(".mce-i-none")
|
||||
|
||||
|
||||
@step('type "(.*)" into the Raw Editor$')
|
||||
def type_in_raw_editor(_step, text):
|
||||
# Verify that CodeMirror editor is not hidden
|
||||
assert not world.css_has_class('.CodeMirror', 'is-inactive')
|
||||
# Verify that TinyMCE Editor is not present
|
||||
assert world.is_css_not_present('.tiny-mce')
|
||||
type_in_codemirror(0, text)
|
||||
|
||||
|
||||
@step('I edit the component and select the (Raw|Visual) Editor$')
|
||||
def select_editor(_step, editor):
|
||||
world.edit_component_and_select_settings()
|
||||
world.browser.select('Editor', editor)
|
||||
|
||||
|
||||
@step('I click font selection dropdown')
|
||||
def click_font_dropdown(_step):
|
||||
dropdowns = [drop for drop in world.css_find('.mce-listbox') if drop.text == 'Font Family']
|
||||
assert_equal(len(dropdowns), 1)
|
||||
dropdowns[0].click()
|
||||
|
||||
|
||||
@step('I should see a list of available fonts')
|
||||
def font_selector_dropdown_is_shown(_step):
|
||||
font_panel = get_fonts_list_panel(world)
|
||||
expected_fonts = list(CUSTOM_FONTS.keys()) + list(TINYMCE_FONTS.keys())
|
||||
actual_fonts = [font.strip() for font in font_panel.text.split('\n')]
|
||||
assert_equal(actual_fonts, expected_fonts)
|
||||
|
||||
|
||||
@step('"Default" option sets the expected font family')
|
||||
def default_options_sets_expected_font_family(step): # pylint: disable=unused-argument, redefined-outer-name
|
||||
fonts = get_available_fonts(get_fonts_list_panel(world))
|
||||
fonts_found = fonts.get("Default", None)
|
||||
expected_font_family = CUSTOM_FONTS.get('Default')
|
||||
for expected_font in expected_font_family:
|
||||
assert_in(expected_font, fonts_found)
|
||||
|
||||
|
||||
@step('all standard tinyMCE fonts should be available')
|
||||
def check_standard_tinyMCE_fonts(_step):
|
||||
fonts = get_available_fonts(get_fonts_list_panel(world))
|
||||
for label, expected_fonts in TINYMCE_FONTS.items():
|
||||
for expected_font in expected_fonts:
|
||||
assert_in(expected_font, fonts.get(label, None))
|
||||
|
||||
TINYMCE_FONTS = OrderedDict([
|
||||
("Andale Mono", ['andale mono', 'times']),
|
||||
("Arial", ['arial', 'helvetica', 'sans-serif']),
|
||||
("Arial Black", ['arial black', 'avant garde']),
|
||||
("Book Antiqua", ['book antiqua', 'palatino']),
|
||||
("Comic Sans MS", ['comic sans ms', 'sans-serif']),
|
||||
("Courier New", ['courier new', 'courier']),
|
||||
("Georgia", ['georgia', 'palatino']),
|
||||
("Helvetica", ['helvetica']),
|
||||
("Impact", ['impact', 'chicago']),
|
||||
("Symbol", ['symbol']),
|
||||
("Tahoma", ['tahoma', 'arial', 'helvetica', 'sans-serif']),
|
||||
("Terminal", ['terminal', 'monaco']),
|
||||
("Times New Roman", ['times new roman', 'times']),
|
||||
("Trebuchet MS", ['trebuchet ms', 'geneva']),
|
||||
("Verdana", ['verdana', 'geneva']),
|
||||
# tinyMCE does not set font-family on dropdown span for these two fonts
|
||||
("Webdings", [""]), # webdings
|
||||
("Wingdings", [""]), # wingdings, 'zapf dingbats'
|
||||
])
|
||||
|
||||
CUSTOM_FONTS = OrderedDict([
|
||||
('Default', ['Open Sans', 'Verdana', 'Arial', 'Helvetica', 'sans-serif']),
|
||||
])
|
||||
|
||||
|
||||
def use_plugin(button_class, action):
|
||||
# Click on plugin button
|
||||
world.css_click(button_class)
|
||||
perform_action_in_plugin(action)
|
||||
|
||||
|
||||
def use_code_editor(action):
|
||||
# Click on plugin button
|
||||
buttons = world.css_find('div.mce-widget>button')
|
||||
|
||||
code_editor = [button for button in buttons if button.text == 'HTML']
|
||||
assert_equal(1, len(code_editor))
|
||||
code_editor[0].click()
|
||||
|
||||
perform_action_in_plugin(action)
|
||||
|
||||
|
||||
def perform_action_in_plugin(action):
|
||||
# Wait for the plugin window to open.
|
||||
world.wait_for_visible('.mce-window')
|
||||
|
||||
# Trigger the action
|
||||
action()
|
||||
|
||||
# Click OK
|
||||
world.css_click('.mce-primary')
|
||||
|
||||
|
||||
def get_fonts_list_panel(world):
|
||||
menus = world.css_find('.mce-menu')
|
||||
return menus[0]
|
||||
|
||||
|
||||
def get_available_fonts(font_panel):
|
||||
font_spans = font_panel.find_by_css('.mce-text')
|
||||
return {font_span.text: get_font_family(font_span) for font_span in font_spans}
|
||||
|
||||
|
||||
def get_font_family(font_span):
|
||||
# get_attribute('style').replace('font-family: ', '').replace(';', '') is equivalent to
|
||||
# value_of_css_property('font-family'). However, for reason unknown value_of_css_property fails tests in CI
|
||||
# while works as expected in local development environment
|
||||
return font_span._element.get_attribute('style').replace('font-family: ', '').replace(';', '') # pylint: disable=protected-access
|
||||
@@ -1,66 +0,0 @@
|
||||
@shard_1
|
||||
Feature: CMS.Problem Editor
|
||||
As a course author, I want to be able to create problems and edit their settings.
|
||||
|
||||
Scenario: User can revert display name to unset
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can revert the display name to unset
|
||||
And my display name is unset on save
|
||||
|
||||
Scenario: User can specify html in display name and it will be escaped
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can specify html in the display name and save
|
||||
And the problem display name is "<script>alert('test')</script>"
|
||||
|
||||
# IE will not click the revert button properly
|
||||
@skip_internetexplorer
|
||||
Scenario: User can select values in a Select
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can select Per Student for Randomization
|
||||
And my change to randomization is persisted
|
||||
And I can revert to the default value for randomization
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: User can modify float input values
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And my change to weight is persisted
|
||||
And I can revert to the default value of unset for weight
|
||||
|
||||
Scenario: User cannot type letters in float number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the weight to "abc", it remains unset
|
||||
|
||||
# Safari will input it as 234.
|
||||
@skip_safari
|
||||
Scenario: User cannot type decimal values integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "2.34", it will persist as a valid integer
|
||||
|
||||
# Safari will input it incorrectly
|
||||
@skip_safari
|
||||
Scenario: User cannot type out of range values in an integer number field
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then if I set the max attempts to "-3", it will persist as a valid integer
|
||||
|
||||
# Safari will input it as 35.
|
||||
@skip_safari
|
||||
Scenario: Settings changes are not saved on Cancel
|
||||
Given I have created a Blank Common Problem
|
||||
When I edit and select Settings
|
||||
Then I can set the weight to "3.5"
|
||||
And I can modify the display name
|
||||
Then If I press Cancel my changes are not persisted
|
||||
|
||||
Scenario: Cheat sheet visible on toggle
|
||||
Given I have created a Blank Common Problem
|
||||
And I can edit the problem
|
||||
Then I can see cheatsheet
|
||||
@@ -1,391 +0,0 @@
|
||||
# disable missing docstring
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
|
||||
import json
|
||||
|
||||
from lettuce import step, world
|
||||
from openedx.core.lib.tests.tools import assert_equal, assert_true # pylint: disable=no-name-in-module
|
||||
|
||||
from cms.djangoapps.contentstore.features.advanced_settings import ADVANCED_MODULES_KEY, change_value
|
||||
from cms.djangoapps.contentstore.features.common import open_new_course, type_in_codemirror
|
||||
from cms.djangoapps.contentstore.features.course_import import import_file
|
||||
|
||||
DISPLAY_NAME = "Display Name"
|
||||
MAXIMUM_ATTEMPTS = "Maximum Attempts"
|
||||
PROBLEM_WEIGHT = "Problem Weight"
|
||||
RANDOMIZATION = 'Randomization'
|
||||
SHOW_ANSWER = "Show Answer"
|
||||
SHOW_ANSWER_AFTER_SOME_NUMBER_OF_ATTEMPTS = 'Show Answer: Number of Attempts'
|
||||
SHOW_RESET_BUTTON = "Show Reset Button"
|
||||
TIMER_BETWEEN_ATTEMPTS = "Timer Between Attempts"
|
||||
MATLAB_API_KEY = "Matlab API key"
|
||||
|
||||
|
||||
@step('I have created a Blank Common Problem$')
|
||||
def i_created_blank_common_problem(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given("I have created another Blank Common Problem")
|
||||
|
||||
|
||||
@step('I have created a unit with advanced module "(.*)"$')
|
||||
def i_created_unit_with_advanced_module(step, advanced_module):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
|
||||
url = world.browser.url
|
||||
step.given("I select the Advanced Settings")
|
||||
change_value(step, ADVANCED_MODULES_KEY, '["{}"]'.format(advanced_module))
|
||||
world.visit(url)
|
||||
world.wait_for_xmodule()
|
||||
|
||||
|
||||
@step('I have created an advanced component "(.*)" of type "(.*)"')
|
||||
def i_create_new_advanced_component(step, component_type, advanced_component):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='advanced',
|
||||
component_type=component_type,
|
||||
advanced_component=advanced_component
|
||||
)
|
||||
|
||||
|
||||
@step('I have created another Blank Common Problem$')
|
||||
def i_create_new_common_problem(step):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type='Blank Common Problem'
|
||||
)
|
||||
|
||||
|
||||
@step('when I mouseover on "(.*)"')
|
||||
def i_mouseover_on_html_component(_step, element_class):
|
||||
action_css = '.{}'.format(element_class)
|
||||
world.trigger_event(action_css, event='mouseover')
|
||||
|
||||
|
||||
@step(u'I can see Reply to Annotation link$')
|
||||
def i_see_reply_to_annotation_link(_step):
|
||||
css_selector = 'a.annotatable-reply'
|
||||
world.wait_for_visible(css_selector)
|
||||
|
||||
|
||||
@step(u'I see that page has scrolled "(.*)" when I click on "(.*)" link$')
|
||||
def i_see_annotation_problem_page_scrolls(_step, scroll_direction, link_css):
|
||||
scroll_js = "$(window).scrollTop();"
|
||||
scroll_height_before = world.browser.evaluate_script(scroll_js)
|
||||
world.css_click("a.{}".format(link_css))
|
||||
scroll_height_after = world.browser.evaluate_script(scroll_js)
|
||||
if scroll_direction == "up":
|
||||
assert scroll_height_after < scroll_height_before
|
||||
elif scroll_direction == "down":
|
||||
assert scroll_height_after > scroll_height_before
|
||||
|
||||
|
||||
@step('I have created an advanced problem of type "(.*)"$')
|
||||
def i_create_new_advanced_problem(step, component_type):
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type=component_type,
|
||||
is_advanced=True
|
||||
)
|
||||
|
||||
|
||||
@step('I edit and select Settings$')
|
||||
def i_edit_and_select_settings(_step):
|
||||
world.edit_component_and_select_settings()
|
||||
|
||||
|
||||
@step('I see the advanced settings and their expected values$')
|
||||
def i_see_advanced_settings_with_values(_step):
|
||||
world.verify_all_setting_entries(
|
||||
[
|
||||
[DISPLAY_NAME, "Blank Common Problem", True],
|
||||
[MATLAB_API_KEY, "", False],
|
||||
[MAXIMUM_ATTEMPTS, "", False],
|
||||
[PROBLEM_WEIGHT, "", False],
|
||||
[RANDOMIZATION, "Never", False],
|
||||
[SHOW_ANSWER, "Finished", False],
|
||||
[SHOW_ANSWER_AFTER_SOME_NUMBER_OF_ATTEMPTS, '0', False],
|
||||
[SHOW_RESET_BUTTON, "False", False],
|
||||
[TIMER_BETWEEN_ATTEMPTS, "0", False],
|
||||
])
|
||||
|
||||
|
||||
@step('I can modify the display name')
|
||||
def i_can_modify_the_display_name(_step):
|
||||
# Verifying that the display name can be a string containing a floating point value
|
||||
# (to confirm that we don't throw an error because it is of the wrong type).
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, '3.4')
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@step('my display name change is persisted on save')
|
||||
def my_display_name_change_is_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_display_name()
|
||||
|
||||
|
||||
@step('the problem display name is "(.*)"$')
|
||||
def verify_problem_display_name(_step, name):
|
||||
"""
|
||||
name is uppercased because the heading styles are uppercase in css
|
||||
"""
|
||||
assert_equal(name, world.browser.find_by_css('.problem-header').text)
|
||||
|
||||
|
||||
@step('I can specify special characters in the display name')
|
||||
def i_can_modify_the_display_name_with_special_chars(_step):
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, "updated ' \" &")
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@step('I can specify html in the display name and save')
|
||||
def i_can_modify_the_display_name_with_html(_step):
|
||||
"""
|
||||
If alert appear on save then UnexpectedAlertPresentException
|
||||
will occur and test will fail.
|
||||
"""
|
||||
index = world.get_setting_entry_index(DISPLAY_NAME)
|
||||
world.set_field_value(index, "<script>alert('test')</script>")
|
||||
verify_modified_display_name_with_html()
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step('my special characters and persisted on save')
|
||||
def special_chars_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_display_name_with_special_chars()
|
||||
|
||||
|
||||
@step('I can revert the display name to unset')
|
||||
def can_revert_display_name_to_unset(_step):
|
||||
world.revert_setting_entry(DISPLAY_NAME)
|
||||
verify_unset_display_name()
|
||||
|
||||
|
||||
@step('my display name is unset on save')
|
||||
def my_display_name_is_persisted_on_save(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_unset_display_name()
|
||||
|
||||
|
||||
@step('I can select Per Student for Randomization')
|
||||
def i_can_select_per_student_for_randomization(_step):
|
||||
world.browser.select(RANDOMIZATION, "Per Student")
|
||||
verify_modified_randomization()
|
||||
|
||||
|
||||
@step('my change to randomization is persisted')
|
||||
def my_change_to_randomization_is_persisted(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_randomization()
|
||||
|
||||
|
||||
@step('I can revert to the default value for randomization')
|
||||
def i_can_revert_to_default_for_randomization(step):
|
||||
world.revert_setting_entry(RANDOMIZATION)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False)
|
||||
|
||||
|
||||
@step('I can set the weight to "(.*)"?')
|
||||
def i_can_set_weight(_step, weight):
|
||||
set_weight(weight)
|
||||
verify_modified_weight()
|
||||
|
||||
|
||||
@step('my change to weight is persisted')
|
||||
def my_change_to_weight_is_persisted(step):
|
||||
world.save_component_and_reopen(step)
|
||||
verify_modified_weight()
|
||||
|
||||
|
||||
@step('I can revert to the default value of unset for weight')
|
||||
def i_can_revert_to_default_for_unset_weight(step):
|
||||
world.revert_setting_entry(PROBLEM_WEIGHT)
|
||||
world.save_component_and_reopen(step)
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the weight to "(.*)", it remains unset')
|
||||
def set_the_weight_to_abc(step, bad_weight):
|
||||
set_weight(bad_weight)
|
||||
# We show the clear button immediately on type, hence the "True" here.
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", True)
|
||||
world.save_component_and_reopen(step)
|
||||
# But no change was actually ever sent to the model, so on reopen, explicitly_set is False
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "", False)
|
||||
|
||||
|
||||
@step('if I set the max attempts to "(.*)", it will persist as a valid integer$')
|
||||
def set_the_max_attempts(step, max_attempts_set):
|
||||
# on firefox with selenium, the behavior is different.
|
||||
# eg 2.34 displays as 2.34 and is persisted as 2
|
||||
index = world.get_setting_entry_index(MAXIMUM_ATTEMPTS)
|
||||
world.set_field_value(index, max_attempts_set)
|
||||
world.save_component_and_reopen(step)
|
||||
value = world.css_value('input.setting-input', index=index)
|
||||
assert value != "", "max attempts is blank"
|
||||
assert int(value) >= 0
|
||||
|
||||
|
||||
@step('Edit High Level Source is not visible')
|
||||
def edit_high_level_source_not_visible(step):
|
||||
verify_high_level_source_links(step, False)
|
||||
|
||||
|
||||
@step('Edit High Level Source is visible')
|
||||
def edit_high_level_source_links_visible(step):
|
||||
verify_high_level_source_links(step, True)
|
||||
|
||||
|
||||
@step('If I press Cancel my changes are not persisted')
|
||||
def cancel_does_not_save_changes(step):
|
||||
world.cancel_component(step)
|
||||
step.given("I edit and select Settings")
|
||||
step.given("I see the advanced settings and their expected values")
|
||||
|
||||
|
||||
@step('I have enabled latex compiler')
|
||||
def enable_latex_compiler(step):
|
||||
url = world.browser.url
|
||||
step.given("I select the Advanced Settings")
|
||||
change_value(step, 'Enable LaTeX Compiler', 'true')
|
||||
world.visit(url)
|
||||
world.wait_for_xmodule()
|
||||
|
||||
|
||||
@step('I have created a LaTeX Problem')
|
||||
def create_latex_problem(step):
|
||||
step.given('I am in Studio editing a new unit')
|
||||
step.given('I have enabled latex compiler')
|
||||
world.create_component_instance(
|
||||
step=step,
|
||||
category='problem',
|
||||
component_type='Problem Written in LaTeX',
|
||||
is_advanced=True
|
||||
)
|
||||
|
||||
|
||||
@step('I edit and compile the High Level Source')
|
||||
def edit_latex_source(_step):
|
||||
open_high_level_source()
|
||||
type_in_codemirror(1, "hi")
|
||||
world.css_click('.hls-compile')
|
||||
|
||||
|
||||
@step('my change to the High Level Source is persisted')
|
||||
def high_level_source_persisted(_step):
|
||||
def verify_text(_driver):
|
||||
css_sel = '.problem div>span'
|
||||
return world.css_text(css_sel) == 'hi'
|
||||
|
||||
world.wait_for(verify_text, timeout=10)
|
||||
|
||||
|
||||
@step('I view the High Level Source I see my changes')
|
||||
def high_level_source_in_editor(_step):
|
||||
open_high_level_source()
|
||||
assert_equal('hi', world.css_value('.source-edit-box'))
|
||||
|
||||
|
||||
@step(u'I have an empty course')
|
||||
def i_have_empty_course(_step):
|
||||
open_new_course()
|
||||
|
||||
|
||||
@step(u'I import the file "([^"]*)"$')
|
||||
def i_import_the_file(_step, filename):
|
||||
import_file(filename)
|
||||
|
||||
|
||||
@step(u'I go to the vertical "([^"]*)"$')
|
||||
def i_go_to_vertical(_step, vertical):
|
||||
world.css_click("span:contains('{0}')".format(vertical))
|
||||
|
||||
|
||||
@step(u'I go to the unit "([^"]*)"$')
|
||||
def i_go_to_unit(_step, unit):
|
||||
loc = "window.location = $(\"span:contains('{0}')\").closest('a').attr('href')".format(unit)
|
||||
world.browser.execute_script(loc)
|
||||
|
||||
|
||||
@step(u'I see a message that says "([^"]*)"$')
|
||||
def i_can_see_message(_step, msg):
|
||||
msg = json.dumps(msg) # escape quotes
|
||||
world.css_has_text("h2.title", msg)
|
||||
|
||||
|
||||
@step(u'I can edit the problem$')
|
||||
def i_can_edit_problem(_step):
|
||||
world.edit_component()
|
||||
|
||||
|
||||
@step(u'I edit first blank advanced problem for annotation response$')
|
||||
def i_edit_blank_problem_for_annotation_response(_step):
|
||||
world.edit_component(1)
|
||||
text = """
|
||||
<problem>
|
||||
<annotationresponse>
|
||||
<annotationinput><text>Text of annotation</text></annotationinput>
|
||||
</annotationresponse>
|
||||
</problem>"""
|
||||
type_in_codemirror(0, text)
|
||||
world.save_component()
|
||||
|
||||
|
||||
@step(u'I can see cheatsheet$')
|
||||
def verify_cheat_sheet_displaying(_step):
|
||||
world.css_click(".cheatsheet-toggle")
|
||||
css_selector = '.simple-editor-cheatsheet'
|
||||
world.wait_for_visible(css_selector)
|
||||
|
||||
|
||||
def verify_high_level_source_links(step, visible):
|
||||
if visible:
|
||||
assert_true(world.is_css_present('.launch-latex-compiler'),
|
||||
msg="Expected to find the latex button but it is not present.")
|
||||
else:
|
||||
assert_true(world.is_css_not_present('.launch-latex-compiler'),
|
||||
msg="Expected not to find the latex button but it is present.")
|
||||
|
||||
world.cancel_component(step)
|
||||
|
||||
|
||||
def verify_modified_weight():
|
||||
world.verify_setting_entry(world.get_setting_entry(PROBLEM_WEIGHT), PROBLEM_WEIGHT, "3.5", True)
|
||||
|
||||
|
||||
def verify_modified_randomization():
|
||||
world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Per Student", True)
|
||||
|
||||
|
||||
def verify_modified_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '3.4', True)
|
||||
|
||||
|
||||
def verify_modified_display_name_with_special_chars():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, "updated ' \" &", True)
|
||||
|
||||
|
||||
def verify_modified_display_name_with_html():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME),
|
||||
DISPLAY_NAME, "<script>alert('test')</script>", True)
|
||||
|
||||
|
||||
def verify_unset_display_name():
|
||||
world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False)
|
||||
|
||||
|
||||
def set_weight(weight):
|
||||
index = world.get_setting_entry_index(PROBLEM_WEIGHT)
|
||||
world.set_field_value(index, weight)
|
||||
|
||||
|
||||
def open_high_level_source():
|
||||
world.edit_component()
|
||||
world.css_click('.launch-latex-compiler > a')
|
||||
@@ -1,71 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
|
||||
from lettuce import step, world
|
||||
|
||||
|
||||
@step('I fill in the registration form$')
|
||||
def i_fill_in_the_registration_form(_step):
|
||||
def fill_in_reg_form():
|
||||
register_form = world.css_find('form#register_form')
|
||||
register_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
register_form.find_by_name('password').fill('test')
|
||||
register_form.find_by_name('username').fill('robot-studio')
|
||||
register_form.find_by_name('name').fill('Robot Studio')
|
||||
register_form.find_by_name('terms_of_service').click()
|
||||
world.retry_on_exception(fill_in_reg_form)
|
||||
|
||||
|
||||
@step('I press the Create My Account button on the registration form$')
|
||||
def i_press_the_button_on_the_registration_form(_step):
|
||||
submit_css = 'form#register_form button#submit'
|
||||
world.css_click(submit_css)
|
||||
|
||||
|
||||
@step('I should see an email verification prompt')
|
||||
def i_should_see_an_email_verification_prompt(_step):
|
||||
world.css_has_text('h1.page-header', u'Studio Home')
|
||||
world.css_has_text('div.msg h3.title', u'We need to verify your email address')
|
||||
|
||||
|
||||
@step(u'I fill in and submit the signin form$')
|
||||
def i_fill_in_the_signin_form(_step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('test')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
|
||||
@step(u'I should( not)? see a login error message$')
|
||||
def i_should_see_a_login_error(_step, should_not_see):
|
||||
if should_not_see:
|
||||
# the login error may be absent or invisible. Check absence first,
|
||||
# because css_visible will throw an exception if the element is not present
|
||||
if world.is_css_present('div#login_error'):
|
||||
assert not world.css_visible('div#login_error')
|
||||
else:
|
||||
assert world.css_visible('div#login_error')
|
||||
|
||||
|
||||
@step(u'I fill in and submit the signin form incorrectly$')
|
||||
def i_goof_in_the_signin_form(_step):
|
||||
def fill_login_form():
|
||||
login_form = world.browser.find_by_css('form#login_form')
|
||||
login_form.find_by_name('email').fill('robot+studio@edx.org')
|
||||
login_form.find_by_name('password').fill('oops')
|
||||
login_form.find_by_name('submit').click()
|
||||
world.retry_on_exception(fill_login_form)
|
||||
|
||||
|
||||
@step(u'I edit the password field$')
|
||||
def i_edit_the_password_field(_step):
|
||||
password_css = 'form#login_form input#password'
|
||||
world.css_fill(password_css, 'test')
|
||||
|
||||
|
||||
@step(u'I submit the signin form$')
|
||||
def i_submit_the_signin_form(_step):
|
||||
submit_css = 'form#login_form button#submit'
|
||||
world.css_click(submit_css)
|
||||
@@ -1,149 +0,0 @@
|
||||
"""
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .test import *
|
||||
|
||||
# You need to start the server in debug mode,
|
||||
# otherwise the browser will not render the pages correctly
|
||||
DEBUG = True
|
||||
|
||||
# Output Django logs to a file
|
||||
import logging
|
||||
logging.basicConfig(filename=TEST_ROOT / "log" / "cms_acceptance.log", level=logging.ERROR)
|
||||
|
||||
# set root logger level
|
||||
logging.getLogger().setLevel(logging.ERROR)
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def seed():
|
||||
return os.getppid()
|
||||
|
||||
# Silence noisy logs
|
||||
LOG_OVERRIDES = [
|
||||
('track.middleware', logging.CRITICAL),
|
||||
('codejail.safe_exec', logging.ERROR),
|
||||
('edx.courseware', logging.ERROR),
|
||||
('edxmako.shortcuts', logging.ERROR),
|
||||
('audit', logging.ERROR),
|
||||
('contentstore.views.import_export', logging.CRITICAL),
|
||||
('xmodule.x_module', logging.CRITICAL),
|
||||
]
|
||||
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
|
||||
update_module_store_settings(
|
||||
MODULESTORE,
|
||||
doc_store_settings={
|
||||
'db': 'acceptance_xmodule',
|
||||
'collection': 'acceptance_modulestore_%s' % seed(),
|
||||
},
|
||||
module_store_options={
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
},
|
||||
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
|
||||
)
|
||||
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'DOC_STORE_CONFIG': {
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xcontent_%s' % seed(),
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Set this up so that 'paver cms --settings=acceptance' and running the
|
||||
# harvest command both use the same (test) database
|
||||
# which they can flush without messing up your dev db
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_edx.db",
|
||||
'OPTIONS': {
|
||||
'timeout': 30,
|
||||
},
|
||||
'ATOMIC_REQUESTS': True,
|
||||
'TEST': {
|
||||
'NAME': TEST_ROOT / "db" / "test_edx.db",
|
||||
},
|
||||
},
|
||||
'student_module_history': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_student_module_history.db",
|
||||
'OPTIONS': {
|
||||
'timeout': 30,
|
||||
},
|
||||
'TEST': {
|
||||
'NAME': TEST_ROOT / "db" / "test_student_module_history.db",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
FEATURES['RESTRICT_AUTOMATIC_AUTH'] = False
|
||||
|
||||
# Forums are disabled in test.py to speed up unit tests, but we do not have
|
||||
# per-test control for lettuce acceptance tests.
|
||||
# If you are writing an acceptance test that needs the discussion service enabled,
|
||||
# do not write it in lettuce, but instead write it using bok-choy.
|
||||
# DO NOT CHANGE THIS SETTING HERE.
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
|
||||
# HACK
|
||||
# Setting this flag to false causes imports to not load correctly in the lettuce python files
|
||||
# We do not yet understand why this occurs. Setting this to true is a stopgap measure
|
||||
USE_I18N = True
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
# django.contrib.staticfiles used to be loaded by lettuce, now we must add it ourselves
|
||||
# django.contrib.staticfiles is not added to lms as there is a ^/static$ route built in to the app
|
||||
INSTALLED_APPS.append('lettuce.django')
|
||||
LETTUCE_APPS = ('contentstore',)
|
||||
LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome')
|
||||
|
||||
# Where to run: local, saucelabs, or grid
|
||||
LETTUCE_SELENIUM_CLIENT = os.environ.get('LETTUCE_SELENIUM_CLIENT', 'local')
|
||||
|
||||
SELENIUM_GRID = {
|
||||
'URL': 'http://127.0.0.1:4444/wd/hub',
|
||||
'BROWSER': LETTUCE_BROWSER,
|
||||
}
|
||||
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
from .private import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
YOUTUBE_HOSTNAME = os.environ.get('BOK_CHOY_HOSTNAME', '127.0.0.1')
|
||||
YOUTUBE['API'] = "http://{0}:{1}/get_youtube_api/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['METADATA_URL'] = "http://{0}:{1}/test_youtube/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['TEXT_API']['url'] = "{0}:{1}/test_transcripts_youtube/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['TEST_TIMEOUT'] = 1500
|
||||
|
||||
# Generate a random UUID so that different runs of acceptance tests don't break each other
|
||||
import uuid
|
||||
SECRET_KEY = uuid.uuid4().hex
|
||||
|
||||
############################### PIPELINE #######################################
|
||||
|
||||
PIPELINE_ENABLED = False
|
||||
REQUIRE_DEBUG = True
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
import os
|
||||
|
||||
os.environ['EDXAPP_TEST_MONGO_HOST'] = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'edx.devstack.mongo')
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
from .acceptance import *
|
||||
|
||||
update_module_store_settings(
|
||||
MODULESTORE,
|
||||
doc_store_settings={
|
||||
'db': 'acceptance_xmodule',
|
||||
'host': MONGO_HOST,
|
||||
'port': MONGO_PORT_NUM,
|
||||
'collection': 'acceptance_modulestore_%s' % seed(),
|
||||
},
|
||||
module_store_options={
|
||||
'default_class': 'xmodule.raw_module.RawDescriptor',
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
},
|
||||
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
|
||||
)
|
||||
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'DOC_STORE_CONFIG': {
|
||||
'host': MONGO_HOST,
|
||||
'port': MONGO_PORT_NUM,
|
||||
'db': 'acceptance_xcontent_%s' % seed(),
|
||||
},
|
||||
# allow for additional options that can be keyed on a name, e.g. 'trashcan'
|
||||
'ADDITIONAL_OPTIONS': {
|
||||
'trashcan': {
|
||||
'bucket': 'trash_fs'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Where to run: local, saucelabs, or grid
|
||||
LETTUCE_SELENIUM_CLIENT = os.environ.get('LETTUCE_SELENIUM_CLIENT', 'grid')
|
||||
SELENIUM_HOST = 'edx.devstack.{}'.format(LETTUCE_BROWSER)
|
||||
SELENIUM_PORT = os.environ.get('SELENIUM_PORT', '4444')
|
||||
|
||||
SELENIUM_GRID = {
|
||||
'URL': 'http://{}:{}/wd/hub'.format(SELENIUM_HOST, SELENIUM_PORT),
|
||||
'BROWSER': LETTUCE_BROWSER,
|
||||
}
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
LETTUCE_HOST = os.environ['BOK_CHOY_HOSTNAME']
|
||||
YOUTUBE['API'] = "http://{}:{}/get_youtube_api/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
YOUTUBE['METADATA_URL'] = "http://{}:{}/test_youtube/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
YOUTUBE['TEXT_API']['url'] = "{}:{}/test_transcripts_youtube/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
@@ -185,7 +185,6 @@ CLEAR_REQUEST_CACHE_ON_TASK_COMPLETION = False
|
||||
|
||||
# These ports are carefully chosen so that if the browser needs to
|
||||
# access them, they will be available through the SauceLabs SSH tunnel
|
||||
LETTUCE_SERVER_PORT = 8003
|
||||
XQUEUE_PORT = 8040
|
||||
YOUTUBE_PORT = 8031
|
||||
LTI_PORT = 8765
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# Use this as your lettuce terrain file so that the common steps
|
||||
# across all lms apps can be put in terrain/common
|
||||
# See https://groups.google.com/forum/?fromgroups=#!msg/lettuce-users/5VyU9B4HcX8/USgbGIJdS5QJ
|
||||
|
||||
import lettuce
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
from .browser import * # pylint: disable=wildcard-import
|
||||
from .factories import absorb_factories
|
||||
from .steps import * # pylint: disable=wildcard-import
|
||||
from .setup_prereqs import * # pylint: disable=wildcard-import
|
||||
|
||||
# Delay absorption of factories until the next access,
|
||||
# after Django apps have finished initializing
|
||||
setattr(lettuce, 'world', SimpleLazyObject(absorb_factories))
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
"""
|
||||
Browser set up for acceptance tests.
|
||||
"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from base64 import encodestring
|
||||
from json import dumps
|
||||
from logging import getLogger
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
from lettuce import after, before, world
|
||||
from selenium.common.exceptions import WebDriverException
|
||||
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
|
||||
from splinter.browser import Browser
|
||||
|
||||
from xmodule.contentstore.django import _CONTENTSTORE
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
LOGGER.info("Loading the lettuce acceptance testing terrain file...")
|
||||
|
||||
MAX_VALID_BROWSER_ATTEMPTS = 20
|
||||
GLOBAL_SCRIPT_TIMEOUT = 60
|
||||
|
||||
|
||||
def get_saucelabs_username_and_key():
|
||||
"""
|
||||
Returns the Sauce Labs username and access ID as set by environment variables
|
||||
"""
|
||||
return {"username": settings.SAUCE.get('USERNAME'), "access-key": settings.SAUCE.get('ACCESS_ID')}
|
||||
|
||||
|
||||
def set_saucelabs_job_status(jobid, passed=True):
|
||||
"""
|
||||
Sets the job status on sauce labs
|
||||
"""
|
||||
config = get_saucelabs_username_and_key()
|
||||
url = 'http://saucelabs.com/rest/v1/{}/jobs/{}'.format(config['username'], world.jobid)
|
||||
body_content = dumps({"passed": passed})
|
||||
base64string = encodestring('{}:{}'.format(config['username'], config['access-key']))[:-1]
|
||||
headers = {"Authorization": "Basic {}".format(base64string)}
|
||||
result = requests.put(url, data=body_content, headers=headers)
|
||||
return result.status_code == 200
|
||||
|
||||
|
||||
def make_saucelabs_desired_capabilities():
|
||||
"""
|
||||
Returns a DesiredCapabilities object corresponding to the environment sauce parameters
|
||||
"""
|
||||
desired_capabilities = settings.SAUCE.get('BROWSER', DesiredCapabilities.CHROME)
|
||||
desired_capabilities['platform'] = settings.SAUCE.get('PLATFORM')
|
||||
desired_capabilities['version'] = settings.SAUCE.get('VERSION')
|
||||
desired_capabilities['device-type'] = settings.SAUCE.get('DEVICE')
|
||||
desired_capabilities['name'] = settings.SAUCE.get('SESSION')
|
||||
desired_capabilities['build'] = settings.SAUCE.get('BUILD')
|
||||
desired_capabilities['video-upload-on-pass'] = False
|
||||
desired_capabilities['sauce-advisor'] = False
|
||||
desired_capabilities['capture-html'] = True
|
||||
desired_capabilities['record-screenshots'] = True
|
||||
desired_capabilities['selenium-version'] = "2.34.0"
|
||||
desired_capabilities['max-duration'] = 3600
|
||||
desired_capabilities['public'] = 'public restricted'
|
||||
return desired_capabilities
|
||||
|
||||
|
||||
@before.harvest
|
||||
def initial_setup(server):
|
||||
"""
|
||||
Launch the browser once before executing the tests.
|
||||
"""
|
||||
world.absorb(settings.LETTUCE_SELENIUM_CLIENT, 'LETTUCE_SELENIUM_CLIENT')
|
||||
|
||||
if world.LETTUCE_SELENIUM_CLIENT == 'local':
|
||||
browser_driver = getattr(settings, 'LETTUCE_BROWSER', 'chrome')
|
||||
|
||||
if browser_driver == 'chrome':
|
||||
desired_capabilities = DesiredCapabilities.CHROME
|
||||
desired_capabilities['loggingPrefs'] = {
|
||||
'browser': 'ALL',
|
||||
}
|
||||
else:
|
||||
desired_capabilities = {}
|
||||
|
||||
# There is an issue with ChromeDriver2 r195627 on Ubuntu
|
||||
# in which we sometimes get an invalid browser session.
|
||||
# This is a work-around to ensure that we get a valid session.
|
||||
success = False
|
||||
num_attempts = 0
|
||||
while (not success) and num_attempts < MAX_VALID_BROWSER_ATTEMPTS:
|
||||
|
||||
# Load the browser and try to visit the main page
|
||||
# If the browser couldn't be reached or
|
||||
# the browser session is invalid, this will
|
||||
# raise a WebDriverException
|
||||
try:
|
||||
if browser_driver == 'firefox':
|
||||
# Lettuce initializes differently for firefox, and sending
|
||||
# desired_capabilities will not work. So initialize without
|
||||
# sending desired_capabilities.
|
||||
world.browser = Browser(browser_driver)
|
||||
else:
|
||||
world.browser = Browser(browser_driver, desired_capabilities=desired_capabilities)
|
||||
world.browser.driver.set_script_timeout(GLOBAL_SCRIPT_TIMEOUT)
|
||||
world.visit('/')
|
||||
|
||||
except WebDriverException:
|
||||
LOGGER.warn("Error acquiring %s browser, retrying", browser_driver, exc_info=True)
|
||||
if hasattr(world, 'browser'):
|
||||
world.browser.quit()
|
||||
num_attempts += 1
|
||||
|
||||
else:
|
||||
success = True
|
||||
|
||||
# If we were unable to get a valid session within the limit of attempts,
|
||||
# then we cannot run the tests.
|
||||
if not success:
|
||||
raise IOError("Could not acquire valid {driver} browser session.".format(driver=browser_driver))
|
||||
|
||||
world.absorb(0, 'IMPLICIT_WAIT')
|
||||
world.browser.driver.set_window_size(1280, 1024)
|
||||
|
||||
elif world.LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
config = get_saucelabs_username_and_key()
|
||||
world.browser = Browser(
|
||||
'remote',
|
||||
url="http://{}:{}@ondemand.saucelabs.com:80/wd/hub".format(config['username'], config['access-key']),
|
||||
**make_saucelabs_desired_capabilities()
|
||||
)
|
||||
world.absorb(30, 'IMPLICIT_WAIT')
|
||||
world.browser.set_script_timeout(GLOBAL_SCRIPT_TIMEOUT)
|
||||
|
||||
elif world.LETTUCE_SELENIUM_CLIENT == 'grid':
|
||||
world.browser = Browser(
|
||||
'remote',
|
||||
url=settings.SELENIUM_GRID.get('URL'),
|
||||
browser=settings.SELENIUM_GRID.get('BROWSER'),
|
||||
)
|
||||
world.absorb(30, 'IMPLICIT_WAIT')
|
||||
world.browser.driver.set_script_timeout(GLOBAL_SCRIPT_TIMEOUT)
|
||||
|
||||
else:
|
||||
raise Exception("Unknown selenium client '{}'".format(world.LETTUCE_SELENIUM_CLIENT))
|
||||
|
||||
world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT)
|
||||
world.absorb(world.browser.driver.session_id, 'jobid')
|
||||
|
||||
|
||||
@before.each_scenario
|
||||
def reset_data(scenario):
|
||||
"""
|
||||
Clean out the django test database defined in the
|
||||
envs/acceptance.py file: edx-platform/db/test_edx.db
|
||||
"""
|
||||
LOGGER.debug("Flushing the test database...")
|
||||
call_command('flush', interactive=False, verbosity=0)
|
||||
world.absorb({}, 'scenario_dict')
|
||||
|
||||
|
||||
@before.each_scenario
|
||||
def configure_screenshots(scenario):
|
||||
"""
|
||||
Before each scenario, turn off automatic screenshots.
|
||||
|
||||
Args: str, scenario. Name of current scenario.
|
||||
"""
|
||||
world.auto_capture_screenshots = False
|
||||
|
||||
|
||||
@after.each_scenario
|
||||
def clear_data(scenario):
|
||||
world.spew('scenario_dict')
|
||||
|
||||
|
||||
@after.each_scenario
|
||||
def reset_databases(scenario):
|
||||
"""
|
||||
After each scenario, all databases are cleared/dropped. Contentstore data are stored in unique databases
|
||||
whereas modulestore data is in unique collection names. This data is created implicitly during the scenarios.
|
||||
If no data is created during the test, these lines equivilently do nothing.
|
||||
"""
|
||||
import xmodule.modulestore.django
|
||||
xmodule.modulestore.django.modulestore()._drop_database() # pylint: disable=protected-access
|
||||
xmodule.modulestore.django.clear_existing_modulestores()
|
||||
_CONTENTSTORE.clear()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def capture_screenshot(image_name):
|
||||
"""
|
||||
Capture a screenshot outputting it to a defined directory.
|
||||
This function expects only the name of the file. It will generate
|
||||
the full path of the output screenshot.
|
||||
|
||||
If the name contains spaces, they ill be converted to underscores.
|
||||
"""
|
||||
output_dir = '{}/log/auto_screenshots'.format(settings.TEST_ROOT)
|
||||
image_name = '{}/{}.png'.format(output_dir, image_name.replace(' ', '_'))
|
||||
try:
|
||||
world.browser.driver.save_screenshot(image_name)
|
||||
except WebDriverException:
|
||||
LOGGER.error("Could not capture a screenshot '{}'".format(image_name))
|
||||
|
||||
|
||||
@after.each_scenario
|
||||
def screenshot_on_error(scenario):
|
||||
"""
|
||||
Save a screenshot to help with debugging.
|
||||
"""
|
||||
if scenario.failed:
|
||||
try:
|
||||
output_dir = '{}/log'.format(settings.TEST_ROOT)
|
||||
image_name = '{}/{}.png'.format(output_dir, scenario.name.replace(' ', '_'))
|
||||
world.browser.driver.save_screenshot(image_name)
|
||||
except WebDriverException:
|
||||
LOGGER.error('Could not capture a screenshot')
|
||||
|
||||
|
||||
@after.each_scenario
|
||||
def capture_console_log(scenario):
|
||||
"""
|
||||
Save the console log to help with debugging.
|
||||
"""
|
||||
if scenario.failed:
|
||||
log = world.browser.driver.get_log('browser')
|
||||
try:
|
||||
output_dir = '{}/log'.format(settings.TEST_ROOT)
|
||||
file_name = '{}/{}.log'.format(output_dir, scenario.name.replace(' ', '_'))
|
||||
|
||||
with open(file_name, 'w') as output_file:
|
||||
for line in log:
|
||||
output_file.write("{}{}".format(dumps(line), '\n'))
|
||||
|
||||
except WebDriverException:
|
||||
LOGGER.error('Could not capture the console log')
|
||||
|
||||
|
||||
def capture_screenshot_for_step(step, when):
|
||||
"""
|
||||
Useful method for debugging acceptance tests that are run in Vagrant.
|
||||
This method runs automatically before and after each step of an acceptance
|
||||
test scenario. The variable:
|
||||
|
||||
world.auto_capture_screenshots
|
||||
|
||||
either enables or disabled the taking of screenshots. To change the
|
||||
variable there is a convenient step defined:
|
||||
|
||||
I (enable|disable) auto screenshots
|
||||
|
||||
If you just want to capture a single screenshot at a desired point in code,
|
||||
you should use the method:
|
||||
|
||||
world.capture_screenshot("image_name")
|
||||
"""
|
||||
if world.auto_capture_screenshots:
|
||||
scenario_num = step.scenario.feature.scenarios.index(step.scenario) + 1
|
||||
step_num = step.scenario.steps.index(step) + 1
|
||||
step_func_name = step.defined_at.function.func_name
|
||||
image_name = "{prefix:03d}__{num:03d}__{name}__{postfix}".format(
|
||||
prefix=scenario_num,
|
||||
num=step_num,
|
||||
name=step_func_name,
|
||||
postfix=when
|
||||
)
|
||||
world.capture_screenshot(image_name)
|
||||
|
||||
|
||||
@before.each_step
|
||||
def before_each_step(step):
|
||||
capture_screenshot_for_step(step, '1_before')
|
||||
|
||||
|
||||
@after.each_step
|
||||
def after_each_step(step):
|
||||
capture_screenshot_for_step(step, '2_after')
|
||||
|
||||
|
||||
@after.harvest
|
||||
def saucelabs_status(total):
|
||||
"""
|
||||
Collect data for saucelabs.
|
||||
"""
|
||||
if world.LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
set_saucelabs_job_status(world.jobid, total.scenarios_ran == total.scenarios_passed)
|
||||
@@ -1,77 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
import urllib
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from lettuce import world
|
||||
|
||||
from xmodule.contentstore.django import _CONTENTSTORE
|
||||
|
||||
|
||||
@world.absorb
|
||||
def create_user(uname, password):
|
||||
|
||||
# If the user already exists, don't try to create it again
|
||||
if len(get_user_model().objects.filter(username=uname)) > 0:
|
||||
return
|
||||
|
||||
portal_user = world.UserFactory.build(username=uname, email=uname + '@edx.org')
|
||||
portal_user.set_password(password)
|
||||
portal_user.save()
|
||||
|
||||
registration = world.RegistrationFactory(user=portal_user)
|
||||
registration.register(portal_user)
|
||||
registration.activate()
|
||||
|
||||
world.UserProfileFactory(user=portal_user)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def log_in(username='robot', password='test', email='robot@edx.org', name="Robot"):
|
||||
"""
|
||||
Use the auto_auth feature to programmatically log the user in
|
||||
"""
|
||||
url = '/auto_auth'
|
||||
params = {'username': username, 'password': password, 'email': email, 'full_name': name}
|
||||
url += "?" + urllib.urlencode(params)
|
||||
world.visit(url)
|
||||
|
||||
# Save the user info in the world scenario_dict for use in the tests
|
||||
user = get_user_model().objects.get(username=username)
|
||||
world.scenario_dict['USER'] = user
|
||||
|
||||
|
||||
@world.absorb
|
||||
def register_by_course_key(course_key, username='robot', password='test', is_staff=False):
|
||||
create_user(username, password)
|
||||
user = get_user_model().objects.get(username=username)
|
||||
# Note: this flag makes the user global staff - that is, an edX employee - not a course staff.
|
||||
# See courseware.tests.factories for StaffFactory and InstructorFactory.
|
||||
if is_staff:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
apps.get_model('student', 'CourseEnrollment').enroll(user, course_key)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def enroll_user(user, course_key):
|
||||
# Activate user
|
||||
registration = world.RegistrationFactory(user=user)
|
||||
registration.register(user)
|
||||
registration.activate()
|
||||
# Enroll them in the course
|
||||
apps.get_model('student', 'CourseEnrollment').enroll(user, course_key)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def clear_courses():
|
||||
# Flush and initialize the module store
|
||||
# Note that if your test module gets in some weird state
|
||||
# (though it shouldn't), do this manually
|
||||
# from the bash shell to drop it:
|
||||
# $ mongo test_xmodule --eval "db.dropDatabase()"
|
||||
from xmodule.modulestore.django import clear_existing_modulestores, modulestore
|
||||
modulestore()._drop_database() # pylint: disable=protected-access
|
||||
_CONTENTSTORE.clear()
|
||||
clear_existing_modulestores()
|
||||
@@ -1,30 +0,0 @@
|
||||
"""
|
||||
Factories are defined in other modules and absorbed here into the
|
||||
lettuce world so that they can be used by both unit tests
|
||||
and integration / BDD tests.
|
||||
"""
|
||||
from lettuce import world
|
||||
|
||||
|
||||
def absorb_factories():
|
||||
"""
|
||||
Absorb the factories and return the resulting ``world`` object.
|
||||
"""
|
||||
import course_modes.tests.factories as cmf
|
||||
import student.tests.factories as sf
|
||||
import xmodule.modulestore.tests.factories as xf
|
||||
|
||||
# Unlock XBlock factories, because we're randomizing the collection
|
||||
# name above to prevent collisions
|
||||
xf.XMODULE_FACTORY_LOCK.enable()
|
||||
|
||||
world.absorb(sf.UserFactory)
|
||||
world.absorb(sf.UserProfileFactory)
|
||||
world.absorb(sf.RegistrationFactory)
|
||||
world.absorb(sf.GroupFactory)
|
||||
world.absorb(sf.CourseEnrollmentAllowedFactory)
|
||||
world.absorb(cmf.CourseModeFactory)
|
||||
world.absorb(xf.CourseFactory)
|
||||
world.absorb(xf.ItemFactory)
|
||||
|
||||
return world
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Set up the prequisites for acceptance tests.
|
||||
|
||||
This includes initialization and teardown for stub and video HTTP services
|
||||
and checking for external URLs that need to be accessible and responding.
|
||||
|
||||
"""
|
||||
import re
|
||||
from logging import getLogger
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from lettuce import after, before, world
|
||||
from selenium.common.exceptions import NoAlertPresentException
|
||||
|
||||
from terrain.stubs.lti import StubLtiService
|
||||
from terrain.stubs.video_source import VideoSourceHttpService
|
||||
from terrain.stubs.xqueue import StubXQueueService
|
||||
from terrain.stubs.youtube import StubYouTubeService
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
SERVICES = {
|
||||
"youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService},
|
||||
"xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService},
|
||||
"lti": {"port": settings.LTI_PORT, "class": StubLtiService},
|
||||
}
|
||||
|
||||
YOUTUBE_API_URLS = {
|
||||
'main': 'https://www.youtube.com/',
|
||||
'player': 'https://www.youtube.com/iframe_api',
|
||||
# For transcripts, you need to check an actual video, so we will
|
||||
# just specify our default video and see if that one is available.
|
||||
'transcript': 'http://video.google.com/timedtext?lang=en&v=OEoXaMPEzfM',
|
||||
}
|
||||
|
||||
|
||||
@before.all # pylint: disable=no-member
|
||||
def start_video_server():
|
||||
"""
|
||||
Serve the HTML5 Video Sources from a local port
|
||||
"""
|
||||
video_source_dir = '{}/data/video'.format(settings.TEST_ROOT)
|
||||
video_server = VideoSourceHttpService(port_num=settings.VIDEO_SOURCE_PORT)
|
||||
video_server.config['root_dir'] = video_source_dir
|
||||
world.video_source = video_server
|
||||
|
||||
|
||||
@after.all # pylint: disable=no-member
|
||||
def stop_video_server(_total):
|
||||
"""
|
||||
Stop the HTML5 Video Source server after all tests have executed
|
||||
"""
|
||||
video_server = getattr(world, 'video_source', None)
|
||||
if video_server:
|
||||
video_server.shutdown()
|
||||
|
||||
|
||||
@before.all # pylint: disable=no-member
|
||||
def start_stub_servers():
|
||||
"""
|
||||
Start all stub servers
|
||||
"""
|
||||
|
||||
for stub in SERVICES.keys():
|
||||
start_stub(stub)
|
||||
|
||||
|
||||
@before.each_scenario # pylint: disable=no-member
|
||||
def skip_youtube_if_not_available(scenario):
|
||||
"""
|
||||
|
||||
Scenario tags must be named with this convention:
|
||||
@requires_stub_bar, where 'bar' is the name of the stub service to start
|
||||
|
||||
if 'bar' is 'youtube'
|
||||
if 'youtube' is not available Then
|
||||
DON'T start youtube stub server
|
||||
ALSO DON'T start any other stub server BECAUSE we will SKIP this Scenario so no need to start any stub
|
||||
else
|
||||
start the stub server
|
||||
|
||||
"""
|
||||
tag_re = re.compile('requires_stub_(?P<server>[^_]+)')
|
||||
for tag in scenario.tags:
|
||||
requires = tag_re.match(tag)
|
||||
|
||||
if requires:
|
||||
if requires.group('server') == 'youtube':
|
||||
if not is_youtube_available(YOUTUBE_API_URLS):
|
||||
# A hackish way to skip a test in lettuce as there is no proper way to skip a test conditionally
|
||||
scenario.steps = []
|
||||
return
|
||||
|
||||
return
|
||||
|
||||
|
||||
def start_stub(name):
|
||||
"""
|
||||
Start the required stub service running on a local port.
|
||||
Since these services can be reconfigured on the fly,
|
||||
we start them on a scenario basis when needed and
|
||||
stop them at the end of the scenario.
|
||||
"""
|
||||
service = SERVICES.get(name, None)
|
||||
if service:
|
||||
fake_server = service['class'](port_num=service['port'])
|
||||
setattr(world, name, fake_server)
|
||||
|
||||
|
||||
def is_youtube_available(urls):
|
||||
"""
|
||||
Check if the required youtube urls are available.
|
||||
If they are not, then skip the scenario.
|
||||
"""
|
||||
for name, url in urls.iteritems():
|
||||
try:
|
||||
response = requests.get(url, allow_redirects=False)
|
||||
except requests.exceptions.ConnectionError:
|
||||
LOGGER.warning("Connection Error. YouTube {0} service not available. Skipping this test.".format(name))
|
||||
return False
|
||||
|
||||
status = response.status_code
|
||||
if status >= 300:
|
||||
LOGGER.warning(
|
||||
"YouTube {0} service not available. Status code: {1}. Skipping this test.".format(name, status))
|
||||
|
||||
# No need to check all the URLs
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@after.all # pylint: disable=no-member
|
||||
def stop_stubs(_scenario):
|
||||
"""
|
||||
Shut down any stub services.
|
||||
"""
|
||||
# close browser to ensure no open connections to the stub servers
|
||||
world.browser.quit()
|
||||
for name in SERVICES.keys():
|
||||
stub_server = getattr(world, name, None)
|
||||
if stub_server is not None:
|
||||
stub_server.shutdown()
|
||||
|
||||
|
||||
@after.each_scenario # pylint: disable=no-member
|
||||
def clear_alerts(_scenario):
|
||||
"""
|
||||
Clear any alerts that might still exist, so that
|
||||
the next scenario will not fail due to their existence.
|
||||
|
||||
Note that the splinter documentation indicates that
|
||||
get_alert should return None if no alert is present,
|
||||
however that is not the case. Instead a
|
||||
NoAlertPresentException is raised.
|
||||
"""
|
||||
try:
|
||||
with world.browser.get_alert() as alert:
|
||||
alert.dismiss()
|
||||
except NoAlertPresentException:
|
||||
pass
|
||||
@@ -1,244 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
# Disable the "wildcard import" warning so we can bring in all methods from
|
||||
# course helpers and ui helpers
|
||||
# pylint: disable=wildcard-import
|
||||
|
||||
# Disable the "Unused import %s from wildcard import" warning
|
||||
# pylint: disable=unused-wildcard-import
|
||||
|
||||
# Disable the "unused argument" warning because lettuce uses "step"
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
# django_url is assigned late in the process of loading lettuce,
|
||||
from logging import getLogger
|
||||
|
||||
# so we import this as a module, and then read django_url from
|
||||
# it to get the correct value
|
||||
import lettuce.django
|
||||
from lettuce import step, world
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from openedx.core.lib.tests.tools import assert_equals # pylint: disable=no-name-in-module
|
||||
|
||||
from .course_helpers import *
|
||||
from .ui_helpers import *
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@step(r'I wait (?:for )?"(\d+\.?\d*)" seconds?$')
|
||||
def wait_for_seconds(step, seconds):
|
||||
world.wait(seconds)
|
||||
|
||||
|
||||
@step('I reload the page$')
|
||||
def reload_the_page(step):
|
||||
world.wait_for_ajax_complete()
|
||||
world.browser.reload()
|
||||
world.wait_for_js_to_load()
|
||||
|
||||
|
||||
@step('I press the browser back button$')
|
||||
def browser_back(step):
|
||||
world.browser.driver.back()
|
||||
|
||||
|
||||
@step('I (?:visit|access|open) the homepage$')
|
||||
def i_visit_the_homepage(step):
|
||||
world.visit('/')
|
||||
assert world.is_css_present('header.global')
|
||||
|
||||
|
||||
@step(u'I (?:visit|access|open) the dashboard$')
|
||||
def i_visit_the_dashboard(step):
|
||||
world.visit('/dashboard')
|
||||
assert world.is_css_present('.dashboard')
|
||||
|
||||
|
||||
@step('I should be on the dashboard page$')
|
||||
def i_should_be_on_the_dashboard(step):
|
||||
assert world.is_css_present('.dashboard')
|
||||
assert 'Dashboard' in world.browser.title
|
||||
|
||||
|
||||
@step(u'I (?:visit|access|open) the courses page$')
|
||||
def i_am_on_the_courses_page(step):
|
||||
world.visit('/courses')
|
||||
assert world.is_css_present('div.courses')
|
||||
|
||||
|
||||
@step(u'I press the "([^"]*)" button$')
|
||||
def and_i_press_the_button(step, value):
|
||||
button_css = 'input[value="%s"]' % value
|
||||
world.css_click(button_css)
|
||||
|
||||
|
||||
@step(u'I click the link with the text "([^"]*)"$')
|
||||
def click_the_link_with_the_text_group1(step, linktext):
|
||||
world.click_link(linktext)
|
||||
|
||||
|
||||
@step('I should see that the path is "([^"]*)"$')
|
||||
def i_should_see_that_the_path_is(step, path):
|
||||
if 'COURSE' in world.scenario_dict:
|
||||
path = path.format(world.scenario_dict['COURSE'].id)
|
||||
assert world.url_equals(path), (
|
||||
"path should be {!r} but is {!r}".format(path, world.browser.url)
|
||||
)
|
||||
|
||||
|
||||
@step(u'the page title should be "([^"]*)"$')
|
||||
def the_page_title_should_be(step, title):
|
||||
assert_equals(world.browser.title, title)
|
||||
|
||||
|
||||
@step(u'the page title should contain "([^"]*)"$')
|
||||
def the_page_title_should_contain(step, title):
|
||||
assert title in world.browser.title
|
||||
|
||||
|
||||
@step('I log in$')
|
||||
def i_log_in(step):
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am a logged in user$')
|
||||
def i_am_logged_in_user(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step('I am not logged in$')
|
||||
def i_am_not_logged_in(step):
|
||||
world.visit('logout')
|
||||
|
||||
|
||||
@step('I am staff for course "([^"]*)"$')
|
||||
def i_am_staff_for_course_by_id(step, course_id):
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
world.register_by_course_key(course_key, True)
|
||||
|
||||
|
||||
@step(r'click (?:the|a) link (?:called|with the text) "([^"]*)"$')
|
||||
def click_the_link_called(step, text):
|
||||
world.click_link(text)
|
||||
|
||||
|
||||
@step(r'should see that the url is "([^"]*)"$')
|
||||
def should_have_the_url(step, url):
|
||||
assert_equals(world.browser.url, url)
|
||||
|
||||
|
||||
@step(r'should see (?:the|a) link (?:called|with the text) "([^"]*)"$')
|
||||
def should_see_a_link_called(step, text):
|
||||
assert len(world.browser.find_link_by_text(text)) > 0
|
||||
|
||||
|
||||
@step(r'should see (?:the|a) link with the id "([^"]*)" called "([^"]*)"$')
|
||||
def should_have_link_with_id_and_text(step, link_id, text):
|
||||
link = world.browser.find_by_id(link_id)
|
||||
assert len(link) > 0
|
||||
assert_equals(link.text, text)
|
||||
|
||||
|
||||
@step(r'should see a link to "([^"]*)" with the text "([^"]*)"$')
|
||||
def should_have_link_with_path_and_text(step, path, text):
|
||||
link = world.browser.find_link_by_text(text)
|
||||
assert len(link) > 0
|
||||
assert_equals(link.first["href"], lettuce.django.django_url(path))
|
||||
|
||||
|
||||
@step(r'should( not)? see "(.*)" (?:somewhere|anywhere) (?:in|on) (?:the|this) page')
|
||||
def should_see_in_the_page(step, doesnt_appear, text):
|
||||
if world.LETTUCE_SELENIUM_CLIENT == 'saucelabs':
|
||||
multiplier = 2
|
||||
else:
|
||||
multiplier = 1
|
||||
if doesnt_appear:
|
||||
assert world.browser.is_text_not_present(text, wait_time=5 * multiplier)
|
||||
else:
|
||||
assert world.browser.is_text_present(text, wait_time=5 * multiplier)
|
||||
|
||||
|
||||
@step('I am logged in$')
|
||||
def i_am_logged_in(step):
|
||||
world.create_user('robot', 'test')
|
||||
world.log_in(username='robot', password='test')
|
||||
world.browser.visit(lettuce.django.django_url('/'))
|
||||
dash_css = '.dashboard'
|
||||
assert world.is_css_present(dash_css)
|
||||
|
||||
|
||||
@step(u'I am an edX user$')
|
||||
def i_am_an_edx_user(step):
|
||||
world.create_user('robot', 'test')
|
||||
|
||||
|
||||
@step(u'User "([^"]*)" is an edX user$')
|
||||
def registered_edx_user(step, uname):
|
||||
world.create_user(uname, 'test')
|
||||
|
||||
|
||||
@step(u'All dialogs should be closed$')
|
||||
def dialogs_are_closed(step):
|
||||
assert world.dialogs_closed()
|
||||
|
||||
|
||||
@step(u'visit the url "([^"]*)"')
|
||||
def visit_url(step, url):
|
||||
if 'COURSE' in world.scenario_dict:
|
||||
url = url.format(world.scenario_dict['COURSE'].id)
|
||||
world.browser.visit(lettuce.django.django_url(url))
|
||||
|
||||
|
||||
@step(u'wait for AJAX to (?:finish|complete)')
|
||||
def wait_ajax(_step):
|
||||
wait_for_ajax_complete()
|
||||
|
||||
|
||||
@step('I will confirm all alerts')
|
||||
def i_confirm_all_alerts(step):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.confirm = function(){return true;} ; window.alert = function(){return;}')
|
||||
|
||||
|
||||
@step('I will cancel all alerts')
|
||||
def i_cancel_all_alerts(step):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.confirm = function(){return false;} ; window.alert = function(){return;}')
|
||||
|
||||
|
||||
@step('I will answer all prompts with "([^"]*)"')
|
||||
def i_answer_prompts_with(step, prompt):
|
||||
"""
|
||||
Please note: This method must be called RIGHT BEFORE an expected alert
|
||||
Window variables are page local and thus all changes are removed upon navigating to a new page
|
||||
In addition, this method changes the functionality of ONLY future alerts
|
||||
"""
|
||||
world.browser.execute_script('window.prompt = function(){return %s;}') % prompt
|
||||
|
||||
|
||||
@step('I run ipdb')
|
||||
def run_ipdb(_step):
|
||||
"""Run ipdb as step for easy debugging"""
|
||||
import ipdb
|
||||
ipdb.set_trace()
|
||||
assert True
|
||||
|
||||
|
||||
@step(u'(I am viewing|s?he views) the course team settings$')
|
||||
def view_course_team_settings(_step, whom):
|
||||
""" navigates to course team settings page """
|
||||
world.click_course_settings()
|
||||
link_css = 'li.nav-course-settings-team a'
|
||||
world.css_click(link_css)
|
||||
@@ -1,681 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
import json
|
||||
import platform
|
||||
import re
|
||||
import time
|
||||
from textwrap import dedent
|
||||
from urllib import quote_plus
|
||||
|
||||
# django_url is assigned late in the process of loading lettuce,
|
||||
# so we import this as a module, and then read django_url from
|
||||
# it to get the correct value
|
||||
import lettuce.django
|
||||
from lettuce import world
|
||||
from selenium.common.exceptions import (
|
||||
InvalidElementStateException,
|
||||
StaleElementReferenceException,
|
||||
TimeoutException,
|
||||
WebDriverException
|
||||
)
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
from openedx.core.lib.tests.tools import assert_true
|
||||
|
||||
GLOBAL_WAIT_FOR_TIMEOUT = 60
|
||||
|
||||
REQUIREJS_WAIT = {
|
||||
# Settings - Schedule & Details
|
||||
re.compile(r'^Schedule & Details Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course",
|
||||
"js/models/settings/course_details", "js/views/settings/main"],
|
||||
|
||||
# Settings - Advanced Settings
|
||||
re.compile(r'^Advanced Settings \|'): [
|
||||
"jquery", "js/base", "js/models/course", "js/models/settings/advanced",
|
||||
"js/views/settings/advanced", "codemirror"],
|
||||
|
||||
# Content - Outline
|
||||
# Note that calling your org, course number, or display name, 'course' will mess this up
|
||||
re.compile(r'^Course Outline \|'): [
|
||||
"js/base", "js/models/course", "js/models/location", "js/models/section"],
|
||||
|
||||
# Dashboard
|
||||
re.compile(r'^Studio Home \|'): [
|
||||
"gettext", "js/base",
|
||||
"jquery.ui", "cms/js/main", "underscore"],
|
||||
|
||||
# Pages
|
||||
re.compile(r'^Pages \|'): [
|
||||
'js/models/explicit_url', 'js/views/tabs', 'cms/js/main', 'xblock/cms.runtime.v1'
|
||||
],
|
||||
}
|
||||
|
||||
TRUTHY_WAIT = {
|
||||
# Pages
|
||||
re.compile(r'^Pages \|'): [
|
||||
'XBlock'
|
||||
],
|
||||
# Unit page
|
||||
re.compile(r'Unit \|'): [
|
||||
"jQuery", "XBlock", "ContainerFactory"
|
||||
],
|
||||
|
||||
}
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait(seconds):
|
||||
time.sleep(float(seconds))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_js_to_load():
|
||||
for test, req in REQUIREJS_WAIT.items():
|
||||
if test.search(world.browser.title):
|
||||
world.wait_for_requirejs(req)
|
||||
break
|
||||
|
||||
for test, req in TRUTHY_WAIT.items():
|
||||
if test.search(world.browser.title):
|
||||
for var in req:
|
||||
world.wait_for_js_variable_truthy(var)
|
||||
|
||||
|
||||
# Selenium's `execute_async_script` function pauses Selenium's execution
|
||||
# until the browser calls a specific Javascript callback; in effect,
|
||||
# Selenium goes to sleep until the JS callback function wakes it back up again.
|
||||
# This callback is passed as the last argument to the script. Any arguments
|
||||
# passed to this callback get returned from the `execute_async_script`
|
||||
# function, which allows the JS to communicate information back to Python.
|
||||
# Ref: https://selenium.googlecode.com/svn/trunk/docs/api/dotnet/html/M_OpenQA_Selenium_IJavaScriptExecutor_ExecuteAsyncScript.htm
|
||||
@world.absorb
|
||||
def wait_for_js_variable_truthy(variable):
|
||||
"""
|
||||
Using Selenium's `execute_async_script` function, poll the Javascript
|
||||
environment until the given variable is defined and truthy. This process
|
||||
guards against page reloads, and seamlessly retries on the next page.
|
||||
"""
|
||||
javascript = """
|
||||
var callback = arguments[arguments.length - 1];
|
||||
var unloadHandler = function() {{
|
||||
callback("unload");
|
||||
}}
|
||||
addEventListener("beforeunload", unloadHandler);
|
||||
addEventListener("unload", unloadHandler);
|
||||
var intervalID = setInterval(function() {{
|
||||
try {{
|
||||
if({variable}) {{
|
||||
clearInterval(intervalID);
|
||||
removeEventListener("beforeunload", unloadHandler);
|
||||
removeEventListener("unload", unloadHandler);
|
||||
callback(true);
|
||||
}}
|
||||
}} catch (e) {{}}
|
||||
}}, 10);
|
||||
""".format(variable=variable)
|
||||
for _ in range(5): # 5 attempts max
|
||||
try:
|
||||
result = world.browser.driver.execute_async_script(dedent(javascript))
|
||||
except WebDriverException as wde:
|
||||
if "document unloaded while waiting for result" in wde.msg:
|
||||
result = "unload"
|
||||
else:
|
||||
raise
|
||||
if result == "unload":
|
||||
# we ran this on the wrong page. Wait a bit, and try again, when the
|
||||
# browser has loaded the next page.
|
||||
world.wait(1)
|
||||
continue
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_xmodule():
|
||||
"Wait until the XModule Javascript has loaded on the page."
|
||||
world.wait_for_js_variable_truthy("XModule")
|
||||
world.wait_for_js_variable_truthy("XBlock")
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_mathjax():
|
||||
"Wait until MathJax is loaded and set up on the page."
|
||||
world.wait_for_js_variable_truthy("MathJax")
|
||||
|
||||
|
||||
class RequireJSError(Exception):
|
||||
"""
|
||||
An error related to waiting for require.js. If require.js is unable to load
|
||||
a dependency in the `wait_for_requirejs` function, Python will throw
|
||||
this exception to make sure that the failure doesn't pass silently.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
def load_requrejs_modules(dependencies, callback="callback(true);"):
|
||||
javascript = """
|
||||
var callback = arguments[arguments.length - 1];
|
||||
if(window.require) {{
|
||||
requirejs.onError = callback;
|
||||
var unloadHandler = function() {{
|
||||
callback("unload");
|
||||
}}
|
||||
addEventListener("beforeunload", unloadHandler);
|
||||
addEventListener("unload", unloadHandler);
|
||||
require({deps}, function($) {{
|
||||
var modules = arguments;
|
||||
setTimeout(function() {{
|
||||
removeEventListener("beforeunload", unloadHandler);
|
||||
removeEventListener("unload", unloadHandler);
|
||||
{callback}
|
||||
}}, 50);
|
||||
}});
|
||||
}} else {{
|
||||
callback(false);
|
||||
}}
|
||||
""".format(deps=json.dumps(dependencies), callback=callback)
|
||||
for _ in range(5): # 5 attempts max
|
||||
try:
|
||||
result = world.browser.driver.execute_async_script(dedent(javascript))
|
||||
except WebDriverException as wde:
|
||||
if "document unloaded while waiting for result" in wde.msg:
|
||||
result = "unload"
|
||||
else:
|
||||
raise
|
||||
if result == "unload":
|
||||
# we ran this on the wrong page. Wait a bit, and try again, when the
|
||||
# browser has loaded the next page.
|
||||
world.wait(1)
|
||||
continue
|
||||
elif result not in (None, True, False):
|
||||
# We got a require.js error
|
||||
# Sometimes requireJS will throw an error with requireType=require
|
||||
# This doesn't seem to cause problems on the page, so we ignore it
|
||||
if result['requireType'] == 'require':
|
||||
world.wait(1)
|
||||
continue
|
||||
|
||||
# Otherwise, fail and report the error
|
||||
else:
|
||||
msg = "Error loading dependencies: type={0} modules={1}".format(
|
||||
result['requireType'], result['requireModules'])
|
||||
err = RequireJSError(msg)
|
||||
err.error = result
|
||||
raise err
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
def wait_for_xmodules_to_load():
|
||||
"""
|
||||
If requirejs is loaded on the page, this function will pause
|
||||
Selenium until require is finished loading all xmodules.
|
||||
If requirejs is not loaded on the page, this function will return
|
||||
immediately.
|
||||
"""
|
||||
callback = """
|
||||
if (modules[0] && modules[0].done) {{
|
||||
modules[0].done(function () {{callback(true)}});
|
||||
}}
|
||||
"""
|
||||
return load_requrejs_modules(["xmodule"], callback)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_requirejs(dependencies=None):
|
||||
"""
|
||||
If requirejs is loaded on the page, this function will pause
|
||||
Selenium until require is finished loading the given dependencies.
|
||||
If requirejs is not loaded on the page, this function will return
|
||||
immediately.
|
||||
|
||||
:param dependencies: a list of strings that identify resources that
|
||||
we should wait for requirejs to load. By default, requirejs will only
|
||||
wait for jquery.
|
||||
"""
|
||||
if not dependencies:
|
||||
dependencies = ["jquery"]
|
||||
# stick jquery at the front
|
||||
if dependencies[0] != "jquery":
|
||||
dependencies.insert(0, "jquery")
|
||||
|
||||
result = load_requrejs_modules(dependencies)
|
||||
if result and "xmodule" in dependencies:
|
||||
result = wait_for_xmodules_to_load()
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_ajax_complete():
|
||||
"""
|
||||
Wait until all jQuery AJAX calls have completed. "Complete" means that
|
||||
either the server has sent a response (regardless of whether the response
|
||||
indicates success or failure), or that the AJAX call timed out waiting for
|
||||
a response. For more information about the `jQuery.active` counter that
|
||||
keeps track of this information, go here:
|
||||
http://stackoverflow.com/questions/3148225/jquery-active-function#3148506
|
||||
"""
|
||||
javascript = """
|
||||
var callback = arguments[arguments.length - 1];
|
||||
if(!window.jQuery) {callback(false);}
|
||||
var intervalID = setInterval(function() {
|
||||
if(jQuery.active == 0) {
|
||||
clearInterval(intervalID);
|
||||
callback(true);
|
||||
}
|
||||
}, 100);
|
||||
"""
|
||||
# Sometimes the ajax when it returns will make the browser reload
|
||||
# the DOM, and throw a WebDriverException with the message:
|
||||
# 'javascript error: document unloaded while waiting for result'
|
||||
for _ in range(5): # 5 attempts max
|
||||
try:
|
||||
result = world.browser.driver.execute_async_script(dedent(javascript))
|
||||
except WebDriverException as wde:
|
||||
if "document unloaded while waiting for result" in wde.msg:
|
||||
# Wait a bit, and try again, when the browser has reloaded the page.
|
||||
world.wait(1)
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
return result
|
||||
|
||||
|
||||
@world.absorb
|
||||
def visit(url):
|
||||
world.browser.visit(lettuce.django.django_url(url))
|
||||
wait_for_js_to_load()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def url_equals(url):
|
||||
return world.browser.url == lettuce.django.django_url(url)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_css_present(css_selector, wait_time=30):
|
||||
return world.browser.is_element_present_by_css(css_selector, wait_time=wait_time)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_css_not_present(css_selector, wait_time=5):
|
||||
world.browser.driver.implicitly_wait(1)
|
||||
try:
|
||||
return world.browser.is_element_not_present_by_css(css_selector, wait_time=wait_time)
|
||||
except:
|
||||
raise
|
||||
finally:
|
||||
world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_text(css_selector, text, index=0, strip=False):
|
||||
"""
|
||||
Return a boolean indicating whether the element with `css_selector`
|
||||
has `text`.
|
||||
|
||||
If `strip` is True, strip whitespace at beginning/end of both
|
||||
strings before comparing.
|
||||
|
||||
If there are multiple elements matching the css selector,
|
||||
use `index` to indicate which one.
|
||||
"""
|
||||
# If we're expecting a non-empty string, give the page
|
||||
# a chance to fill in text fields.
|
||||
if text:
|
||||
wait_for(lambda _: css_text(css_selector, index=index))
|
||||
|
||||
actual_text = css_text(css_selector, index=index)
|
||||
|
||||
if strip:
|
||||
actual_text = actual_text.strip()
|
||||
text = text.strip()
|
||||
|
||||
return actual_text == text
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_contains_text(css_selector, partial_text, index=0):
|
||||
"""
|
||||
Return a boolean indicating whether the element with `css_selector`
|
||||
contains `partial_text`.
|
||||
|
||||
If there are multiple elements matching the css selector,
|
||||
use `index` to indicate which one.
|
||||
"""
|
||||
# If we're expecting a non-empty string, give the page
|
||||
# a chance to fill in text fields.
|
||||
if partial_text:
|
||||
wait_for(lambda _: css_html(css_selector, index=index), timeout=8)
|
||||
|
||||
actual_text = css_html(css_selector, index=index)
|
||||
|
||||
return partial_text in actual_text
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_value(css_selector, value, index=0):
|
||||
"""
|
||||
Return a boolean indicating whether the element with
|
||||
`css_selector` has the specified `value`.
|
||||
|
||||
If there are multiple elements matching the css selector,
|
||||
use `index` to indicate which one.
|
||||
"""
|
||||
# If we're expecting a non-empty string, give the page
|
||||
# a chance to fill in values
|
||||
if value:
|
||||
wait_for(lambda _: css_value(css_selector, index=index))
|
||||
|
||||
return css_value(css_selector, index=index) == value
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for(func, timeout=5, timeout_msg=None):
|
||||
"""
|
||||
Calls the method provided with the driver as an argument until the
|
||||
return value is not False.
|
||||
Throws an error if the WebDriverWait timeout clock expires.
|
||||
Otherwise this method will return None.
|
||||
"""
|
||||
msg = timeout_msg or "Timed out after {} seconds.".format(timeout)
|
||||
try:
|
||||
WebDriverWait(
|
||||
driver=world.browser.driver,
|
||||
timeout=timeout,
|
||||
ignored_exceptions=(StaleElementReferenceException)
|
||||
).until(func)
|
||||
except TimeoutException:
|
||||
raise TimeoutException(msg)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_present(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Wait for the element to be present in the DOM.
|
||||
"""
|
||||
wait_for(
|
||||
func=lambda _: EC.presence_of_element_located((By.CSS_SELECTOR, css_selector,)),
|
||||
timeout=timeout,
|
||||
timeout_msg="Timed out waiting for {} to be present.".format(css_selector)
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_visible(css_selector, index=0, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Wait for the element to be visible in the DOM.
|
||||
"""
|
||||
wait_for(
|
||||
func=lambda _: css_visible(css_selector, index),
|
||||
timeout=timeout,
|
||||
timeout_msg="Timed out waiting for {} to be visible.".format(css_selector)
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_invisible(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Wait for the element to be either invisible or not present on the DOM.
|
||||
"""
|
||||
wait_for(
|
||||
func=lambda _: EC.invisibility_of_element_located((By.CSS_SELECTOR, css_selector,)),
|
||||
timeout=timeout,
|
||||
timeout_msg="Timed out waiting for {} to be invisible.".format(css_selector)
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def wait_for_clickable(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Wait for the element to be present and clickable.
|
||||
"""
|
||||
wait_for(
|
||||
func=lambda _: EC.element_to_be_clickable((By.CSS_SELECTOR, css_selector,)),
|
||||
timeout=timeout,
|
||||
timeout_msg="Timed out waiting for {} to be clickable.".format(css_selector)
|
||||
)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_find(css, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Wait for the element(s) as defined by css locator
|
||||
to be present.
|
||||
|
||||
This method will return a WebDriverElement.
|
||||
"""
|
||||
wait_for_present(css_selector=css, timeout=wait_time)
|
||||
return world.browser.find_by_css(css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_click(css_selector, index=0, wait_time=GLOBAL_WAIT_FOR_TIMEOUT, dismiss_alert=False):
|
||||
"""
|
||||
Perform a click on a CSS selector, first waiting for the element
|
||||
to be present and clickable.
|
||||
|
||||
This method will return True if the click worked.
|
||||
|
||||
If `dismiss_alert` is true, dismiss any alerts that appear.
|
||||
"""
|
||||
wait_for_clickable(css_selector, timeout=wait_time)
|
||||
wait_for_visible(css_selector, index=index, timeout=wait_time)
|
||||
assert_true(
|
||||
css_visible(css_selector, index=index),
|
||||
msg="Element {}[{}] is present but not visible".format(css_selector, index)
|
||||
)
|
||||
|
||||
retry_on_exception(lambda: css_find(css_selector)[index].click())
|
||||
|
||||
# Dismiss any alerts that occur.
|
||||
# We need to do this before calling `wait_for_js_to_load()`
|
||||
# to avoid getting an unexpected alert exception
|
||||
if dismiss_alert:
|
||||
world.browser.get_alert().accept()
|
||||
|
||||
wait_for_js_to_load()
|
||||
return True
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_check(css_selector, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
"""
|
||||
Checks a check box based on a CSS selector, first waiting for the element
|
||||
to be present and clickable. This is just a wrapper for calling "click"
|
||||
because that's how selenium interacts with check boxes and radio buttons.
|
||||
|
||||
Then for synchronization purposes, wait for the element to be checked.
|
||||
This method will return True if the check worked.
|
||||
"""
|
||||
css_click(css_selector=css_selector, wait_time=wait_time)
|
||||
wait_for(lambda _: css_find(css_selector).selected)
|
||||
return True
|
||||
|
||||
|
||||
@world.absorb
|
||||
def select_option(name, value, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
'''
|
||||
A method to select an option
|
||||
Then for synchronization purposes, wait for the option to be selected.
|
||||
This method will return True if the selection worked.
|
||||
'''
|
||||
select_css = "select[name='{}']".format(name)
|
||||
option_css = "option[value='{}']".format(value)
|
||||
|
||||
css_selector = "{} {}".format(select_css, option_css)
|
||||
css_click(css_selector=css_selector, wait_time=wait_time)
|
||||
wait_for(lambda _: css_has_value(select_css, value))
|
||||
return True
|
||||
|
||||
|
||||
@world.absorb
|
||||
def id_click(elem_id):
|
||||
"""
|
||||
Perform a click on an element as specified by its id
|
||||
"""
|
||||
css_click('#{}'.format(elem_id))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_fill(css_selector, text, index=0):
|
||||
"""
|
||||
Set the value of the element to the specified text.
|
||||
Note that this will replace the current value completely.
|
||||
Then for synchronization purposes, wait for the value on the page.
|
||||
"""
|
||||
wait_for_visible(css_selector, index=index)
|
||||
retry_on_exception(lambda: css_find(css_selector)[index].fill(text))
|
||||
wait_for(lambda _: css_has_value(css_selector, text, index=index))
|
||||
return True
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_link(partial_text, index=0):
|
||||
retry_on_exception(lambda: world.browser.find_link_by_partial_text(partial_text)[index].click())
|
||||
wait_for_js_to_load()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_button(data_attr, index=0):
|
||||
xpath = '//button[text()="{button_text}"]'.format(
|
||||
button_text=data_attr
|
||||
)
|
||||
world.browser.find_by_xpath(xpath)[index].click()
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_link_by_text(text, index=0):
|
||||
retry_on_exception(lambda: world.browser.find_link_by_text(text)[index].click())
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_text(css_selector, index=0, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
|
||||
# Wait for the css selector to appear
|
||||
if is_css_present(css_selector):
|
||||
return retry_on_exception(lambda: css_find(css_selector, wait_time=timeout)[index].text)
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_value(css_selector, index=0):
|
||||
# Wait for the css selector to appear
|
||||
if is_css_present(css_selector):
|
||||
return retry_on_exception(lambda: css_find(css_selector)[index].value)
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_html(css_selector, index=0):
|
||||
"""
|
||||
Returns the HTML of a css_selector
|
||||
"""
|
||||
assert is_css_present(css_selector)
|
||||
return retry_on_exception(lambda: css_find(css_selector)[index].html)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_has_class(css_selector, class_name, index=0):
|
||||
return retry_on_exception(lambda: css_find(css_selector)[index].has_class(class_name))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def css_visible(css_selector, index=0):
|
||||
assert is_css_present(css_selector)
|
||||
return retry_on_exception(lambda: css_find(css_selector)[index].visible)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def dialogs_closed():
|
||||
def are_dialogs_closed(_driver):
|
||||
'''
|
||||
Return True when no modal dialogs are visible
|
||||
'''
|
||||
return not css_visible('.modal')
|
||||
wait_for(are_dialogs_closed)
|
||||
return not css_visible('.modal')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def save_the_html(path='/tmp'):
|
||||
url = world.browser.url
|
||||
html = world.browser.html.encode('ascii', 'ignore')
|
||||
filename = "{path}/{name}.html".format(path=path, name=quote_plus(url))
|
||||
with open(filename, "w") as f:
|
||||
f.write(html)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_course_content():
|
||||
world.wait_for_js_to_load()
|
||||
course_content_css = 'li.nav-course-courseware'
|
||||
css_click(course_content_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_course_settings():
|
||||
world.wait_for_js_to_load()
|
||||
course_settings_css = 'li.nav-course-settings'
|
||||
css_click(course_settings_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def click_tools():
|
||||
world.wait_for_js_to_load()
|
||||
tools_css = 'li.nav-course-tools'
|
||||
css_click(tools_css)
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_mac():
|
||||
return platform.mac_ver()[0] != ''
|
||||
|
||||
|
||||
@world.absorb
|
||||
def is_firefox():
|
||||
return world.browser.driver_name == 'Firefox'
|
||||
|
||||
|
||||
@world.absorb
|
||||
def trigger_event(css_selector, event='change', index=0):
|
||||
world.browser.execute_script("$('{}:eq({})').trigger('{}')".format(css_selector, index, event))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def retry_on_exception(func, max_attempts=5, ignored_exceptions=(StaleElementReferenceException, InvalidElementStateException)):
|
||||
"""
|
||||
Retry the interaction, ignoring the passed exceptions.
|
||||
By default ignore StaleElementReferenceException, which happens often in our application
|
||||
when the DOM is being manipulated by client side JS.
|
||||
Note that ignored_exceptions is passed directly to the except block, and as such can be
|
||||
either a single exception or multiple exceptions as a parenthesized tuple.
|
||||
"""
|
||||
attempt = 0
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
return func()
|
||||
except ignored_exceptions:
|
||||
world.wait(1)
|
||||
attempt += 1
|
||||
|
||||
assert_true(attempt < max_attempts, 'Ran out of attempts to execute {}'.format(func))
|
||||
|
||||
|
||||
@world.absorb
|
||||
def disable_jquery_animations():
|
||||
"""
|
||||
Disable JQuery animations on the page. Any state changes
|
||||
will occur immediately to the final state.
|
||||
"""
|
||||
|
||||
# Ensure that jquery is loaded
|
||||
world.wait_for_js_to_load()
|
||||
|
||||
# Disable jQuery animations
|
||||
world.browser.execute_script("jQuery.fx.off = true;")
|
||||
@@ -1,8 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Bok choy acceptance tests for problems in the LMS
|
||||
|
||||
See also old lettuce tests in lms/djangoapps/courseware/features/problems.feature
|
||||
"""
|
||||
from textwrap import dedent
|
||||
import time
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""
|
||||
Bok choy acceptance and a11y tests for problem types in the LMS
|
||||
|
||||
See also lettuce tests in lms/djangoapps/courseware/features/problems.feature
|
||||
"""
|
||||
import random
|
||||
import textwrap
|
||||
|
||||
Binary file not shown.
Binary file not shown.
114
docs/testing.rst
114
docs/testing.rst
@@ -68,13 +68,7 @@ UI Acceptance Tests
|
||||
- We use `Bok Choy`_ to write end-user acceptance tests directly in Python,
|
||||
using the framework to maximize reliability and maintainability.
|
||||
|
||||
- We used to use `lettuce`_ to write BDD-style tests but it's now deprecated
|
||||
in favor of Bok Choy for new tests. Most of these tests simulate user
|
||||
interactions through the browser using `splinter`_.
|
||||
|
||||
.. _Bok Choy: https://bok-choy.readthedocs.org/en/latest/tutorial.html
|
||||
.. _lettuce: http://lettuce.it/
|
||||
.. _splinter: http://splinter.cobrateam.info/
|
||||
|
||||
|
||||
Internationalization
|
||||
@@ -101,8 +95,6 @@ Test Locations
|
||||
|
||||
- Set up and helper methods, and stubs for external services:
|
||||
``common/djangoapps/terrain``
|
||||
- Lettuce Tests: located in ``features`` subpackage within a Django
|
||||
app. For example: ``lms/djangoapps/courseware/features``
|
||||
- Bok Choy Acceptance Tests: located under ``common/test/acceptance/tests``
|
||||
- Bok Choy Accessibility Tests: located under ``common/test/acceptance/tests`` and tagged with ``@attr("a11y")``
|
||||
- Bok Choy PageObjects: located under ``common/test/acceptance/pages``
|
||||
@@ -431,8 +423,7 @@ Object and Promise design patterns.
|
||||
These prerequisites are all automatically installed and available in
|
||||
`Devstack`_, the supported development enviornment for the Open edX platform.
|
||||
|
||||
* Chromedriver and Chrome (see `Running Lettuce Acceptance Tests`_ below for
|
||||
the latest tested versions)
|
||||
* Chromedriver and Chrome
|
||||
|
||||
* Mongo
|
||||
|
||||
@@ -591,65 +582,6 @@ You must run BOTH `--testsonly` and `--fasttest`.
|
||||
Control-C. *Warning*: Only hit Control-C one time so the pytest framework can
|
||||
properly clean up.
|
||||
|
||||
Running Lettuce Acceptance Tests
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Although it's deprecated now `lettuce`_ acceptance tests still exists in the
|
||||
code base. Most of our tests use `Splinter`_ to simulate UI browser
|
||||
interactions. Splinter, in turn, uses `Selenium`_ to control the Chrome
|
||||
browser.
|
||||
|
||||
**Prerequisite**: You must have `ChromeDriver`_ installed to run the tests in
|
||||
Chrome. The tests are confirmed to run with Chrome (not Chromium) version
|
||||
34.0.1847.116 with ChromeDriver version 2.6.232917.
|
||||
|
||||
.. _ChromeDriver: https://code.google.com/p/selenium/wiki/ChromeDriver
|
||||
|
||||
To run all the acceptance tests, run this command::
|
||||
|
||||
paver test_acceptance
|
||||
|
||||
To run only for lms or cms, run one of these commands::
|
||||
|
||||
paver test_acceptance -s lms
|
||||
paver test_acceptance -s cms
|
||||
|
||||
For example, this command tests only a specific feature::
|
||||
|
||||
paver test_acceptance -s lms --extra_args="lms/djangoapps/courseware/features/problems.feature"
|
||||
|
||||
A command like this tests only a specific scenario::
|
||||
|
||||
paver test_acceptance -s lms --extra_args="lms/djangoapps/courseware/features/problems.feature -s 3"
|
||||
|
||||
To start the debugger on failure, pass the ``--pdb`` option to the paver command like this::
|
||||
|
||||
paver test_acceptance -s lms --pdb --extra_args="lms/djangoapps/courseware/features/problems.feature"
|
||||
|
||||
To run tests faster by not collecting static files or compiling sass, you can use
|
||||
``paver test_acceptance -s lms --fasttest`` and
|
||||
``paver test_acceptance -s cms --fasttest``.
|
||||
|
||||
By default, all acceptance tests are run with the 'draft' ModuleStore.
|
||||
To override the modulestore that is used, use the default\_store option.
|
||||
Currently, the possible stores for acceptance tests are: 'split'
|
||||
(xmodule.modulestore.split\_mongo.split\_draft.DraftVersioningModuleStore)
|
||||
and 'draft' (xmodule.modulestore.mongo.DraftMongoModuleStore). For
|
||||
example: paver test\_acceptance --default\_store='draft' Note, however,
|
||||
all acceptance tests currently do not pass with 'split'.
|
||||
|
||||
Acceptance tests will run on a randomized port and can be run in the
|
||||
background of paver cms and lms or unit tests. To specify the port,
|
||||
change the LETTUCE\_SERVER\_PORT constant in cms/envs/acceptance.py and
|
||||
lms/envs/acceptance.py as well as the port listed in
|
||||
cms/djangoapps/contentstore/feature/upload.py
|
||||
|
||||
During acceptance test execution, Django log files are written to
|
||||
``test_root/log/lms_acceptance.log`` and
|
||||
``test_root/log/cms_acceptance.log``.
|
||||
|
||||
**Note**: The acceptance tests can *not* currently run in parallel.
|
||||
|
||||
Running Tests on Paver Scripts
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -685,50 +617,6 @@ can find those in the following locations::
|
||||
Do not commit the ``.po``, ``.mo``, ``.js`` files that are generated
|
||||
in the above locations during the dummy translation process!
|
||||
|
||||
|
||||
Debugging Acceptance Tests on Vagrant
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
If you are using a local Vagrant dev environment to run acceptance
|
||||
tests, then you will only get console text output. To actually see what
|
||||
is happening, you can turn on automatic screenshots. For each step two
|
||||
screenshots will be taken - before, and after. To do this, simply add
|
||||
the step::
|
||||
|
||||
Given I enable capturing of screenshots before and after each step
|
||||
|
||||
to your scenario. This step can be added anywhere, and will enable
|
||||
automatic screenshots for all following steps for that scenario only.
|
||||
You can also use the step::
|
||||
|
||||
Given I disable capturing of screenshots before and after each step
|
||||
|
||||
to turn off auto screenshots for all steps following it.
|
||||
|
||||
Screenshots will be placed in the folder
|
||||
``{TEST_ROOT}/log/auto_screenshots``. Each time you launch acceptance
|
||||
tests, this folder will be cleaned. Each screenshot will be named
|
||||
according to the template string
|
||||
``{scenario_number}__{step_number}__{step_function_name}__{"1_before"|"2_after"}``.
|
||||
|
||||
If you don't want to have screenshots be captured for all steps, but
|
||||
rather want fine grained control, you can use this decorator before any Python function in ``feature_name.py`` file::
|
||||
|
||||
@capture_screenshot_before_after
|
||||
|
||||
The decorator will capture two screenshots: one before the decorated function runs,
|
||||
and one after. Also, this function is available, and can be inserted at any point in code to capture a
|
||||
screenshot specifically in that place::
|
||||
|
||||
from lettuce import world; world.capture_screenshot("image_name")
|
||||
|
||||
In both cases the captured screenshots will go to the same folder as when using the step method: ``{TEST_ROOT}/log/auto_screenshot``.
|
||||
|
||||
A totally different approach to visually seeing acceptance tests run in
|
||||
Vagrant is to redirect Vagrant X11 session to your local machine. Please
|
||||
see https://github.com/edx/edx-platform/wiki/Test-engineering-FAQ for
|
||||
instruction on how to achieve this.
|
||||
|
||||
Viewing Test Coverage
|
||||
---------------------
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
import time
|
||||
from logging import getLogger
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.urls import reverse
|
||||
from lettuce import step, world
|
||||
from lettuce.django import django_url
|
||||
|
||||
from courseware.courses import get_course_by_id
|
||||
from student.models import CourseEnrollment
|
||||
from xmodule import seq_module, vertical_block
|
||||
from xmodule.course_module import CourseDescriptor
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@step('I (.*) capturing of screenshots before and after each step$')
|
||||
def configure_screenshots_for_all_steps(_step, action):
|
||||
"""
|
||||
A step to be used in *.feature files. Enables/disables
|
||||
automatic saving of screenshots before and after each step in a
|
||||
scenario.
|
||||
"""
|
||||
action = action.strip()
|
||||
if action == 'enable':
|
||||
world.auto_capture_screenshots = True
|
||||
elif action == 'disable':
|
||||
world.auto_capture_screenshots = False
|
||||
else:
|
||||
raise ValueError('Parameter `action` should be one of "enable" or "disable".')
|
||||
|
||||
|
||||
@world.absorb
|
||||
def capture_screenshot_before_after(func):
|
||||
"""
|
||||
A decorator that will take a screenshot before and after the applied
|
||||
function is run. Use this if you do not want to capture screenshots
|
||||
for each step in a scenario, but rather want to debug a single function.
|
||||
"""
|
||||
def inner(*args, **kwargs):
|
||||
prefix = round(time.time() * 1000)
|
||||
|
||||
world.capture_screenshot("{}_{}_{}".format(
|
||||
prefix, func.func_name, 'before'
|
||||
))
|
||||
ret_val = func(*args, **kwargs)
|
||||
world.capture_screenshot("{}_{}_{}".format(
|
||||
prefix, func.func_name, 'after'
|
||||
))
|
||||
return ret_val
|
||||
return inner
|
||||
|
||||
|
||||
@step(u'The course "([^"]*)" exists$')
|
||||
def create_course(_step, course):
|
||||
|
||||
# First clear the modulestore so we don't try to recreate
|
||||
# the same course twice
|
||||
# This also ensures that the necessary templates are loaded
|
||||
world.clear_courses()
|
||||
|
||||
# Create the course
|
||||
# We always use the same org and display name,
|
||||
# but vary the course identifier (e.g. 600x or 191x)
|
||||
world.scenario_dict['COURSE'] = world.CourseFactory.create(
|
||||
org='edx',
|
||||
number=course,
|
||||
display_name='Test Course'
|
||||
)
|
||||
|
||||
# Add a chapter to the course to contain problems
|
||||
world.scenario_dict['CHAPTER'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['COURSE'].location,
|
||||
category='chapter',
|
||||
display_name='Test Chapter',
|
||||
publish_item=True, # Not needed for direct-only but I'd rather the test didn't know that
|
||||
)
|
||||
|
||||
world.scenario_dict['SECTION'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['CHAPTER'].location,
|
||||
category='sequential',
|
||||
display_name='Test Section',
|
||||
publish_item=True,
|
||||
)
|
||||
|
||||
|
||||
@step(u'I am registered for the course "([^"]*)"$')
|
||||
def i_am_registered_for_the_course(step, course):
|
||||
# Create the course
|
||||
create_course(step, course)
|
||||
|
||||
# Create the user
|
||||
world.create_user('robot', 'test')
|
||||
user = User.objects.get(username='robot')
|
||||
|
||||
# If the user is not already enrolled, enroll the user.
|
||||
# TODO: change to factory
|
||||
CourseEnrollment.enroll(user, course_id(course))
|
||||
|
||||
world.log_in(username='robot', password='test')
|
||||
|
||||
|
||||
@step(u'The course "([^"]*)" has extra tab "([^"]*)"$')
|
||||
def add_tab_to_course(_step, course, extra_tab_name):
|
||||
world.ItemFactory.create(
|
||||
parent_location=course_location(course),
|
||||
category="static_tab",
|
||||
display_name=str(extra_tab_name))
|
||||
|
||||
|
||||
@step(u'I am in a course$')
|
||||
def go_into_course(step):
|
||||
step.given('I am registered for the course "6.002x"')
|
||||
step.given('And I am logged in')
|
||||
step.given('And I click on View Courseware')
|
||||
|
||||
|
||||
# Do we really use these 3 w/ a different course than is in the scenario_dict? if so, why? If not,
|
||||
# then get rid of the override arg
|
||||
def course_id(course_num):
|
||||
return world.scenario_dict['COURSE'].id.replace(course=course_num)
|
||||
|
||||
|
||||
def course_location(course_num):
|
||||
return world.scenario_dict['COURSE'].location.replace(course=course_num)
|
||||
|
||||
|
||||
def section_location(course_num):
|
||||
return world.scenario_dict['SECTION'].location.replace(course=course_num)
|
||||
|
||||
|
||||
def visit_scenario_item(item_key):
|
||||
"""
|
||||
Go to the courseware page containing the item stored in `world.scenario_dict`
|
||||
under the key `item_key`
|
||||
"""
|
||||
|
||||
url = django_url(reverse(
|
||||
'jump_to',
|
||||
kwargs={
|
||||
'course_id': unicode(world.scenario_dict['COURSE'].id),
|
||||
'location': unicode(world.scenario_dict[item_key].location),
|
||||
}
|
||||
))
|
||||
|
||||
world.browser.visit(url)
|
||||
|
||||
|
||||
def get_courses():
|
||||
'''
|
||||
Returns dict of lists of courses available, keyed by course.org (ie university).
|
||||
Courses are sorted by course.number.
|
||||
'''
|
||||
courses = [c for c in modulestore().get_courses()
|
||||
if isinstance(c, CourseDescriptor)] # skip error descriptors
|
||||
courses = sorted(courses, key=lambda course: course.location.course)
|
||||
return courses
|
||||
|
||||
|
||||
def get_courseware_with_tabs(course_id):
|
||||
"""
|
||||
Given a course_id (string), return a courseware array of dictionaries for the
|
||||
top three levels of navigation. Same as get_courseware() except include
|
||||
the tabs on the right hand main navigation page.
|
||||
|
||||
This hides the appropriate courseware as defined by the hide_from_toc field:
|
||||
chapter.hide_from_toc
|
||||
|
||||
Example:
|
||||
|
||||
[{
|
||||
'chapter_name': 'Overview',
|
||||
'sections': [{
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Welcome',
|
||||
'tab_classes': []
|
||||
}, {
|
||||
'clickable_tab_count': 1,
|
||||
'section_name': 'System Usage Sequence',
|
||||
'tab_classes': ['VerticalBlock']
|
||||
}, {
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Lab0: Using the tools',
|
||||
'tab_classes': ['HtmlDescriptor', 'HtmlDescriptor', 'CapaDescriptor']
|
||||
}, {
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Circuit Sandbox',
|
||||
'tab_classes': []
|
||||
}]
|
||||
}, {
|
||||
'chapter_name': 'Week 1',
|
||||
'sections': [{
|
||||
'clickable_tab_count': 4,
|
||||
'section_name': 'Administrivia and Circuit Elements',
|
||||
'tab_classes': ['VerticalBlock', 'VerticalBlock', 'VerticalBlock', 'VerticalBlock']
|
||||
}, {
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Basic Circuit Analysis',
|
||||
'tab_classes': ['CapaDescriptor', 'CapaDescriptor', 'CapaDescriptor']
|
||||
}, {
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Resistor Divider',
|
||||
'tab_classes': []
|
||||
}, {
|
||||
'clickable_tab_count': 0,
|
||||
'section_name': 'Week 1 Tutorials',
|
||||
'tab_classes': []
|
||||
}]
|
||||
}, {
|
||||
'chapter_name': 'Midterm Exam',
|
||||
'sections': [{
|
||||
'clickable_tab_count': 2,
|
||||
'section_name': 'Midterm Exam',
|
||||
'tab_classes': ['VerticalBlock', 'VerticalBlock']
|
||||
}]
|
||||
}]
|
||||
"""
|
||||
|
||||
course = get_course_by_id(course_id)
|
||||
chapters = [chapter for chapter in course.get_children() if not chapter.hide_from_toc]
|
||||
courseware = [{
|
||||
'chapter_name': c.display_name_with_default_escaped,
|
||||
'sections': [{
|
||||
'section_name': s.display_name_with_default_escaped,
|
||||
'clickable_tab_count': len(s.get_children()) if isinstance(s, seq_module.SequenceDescriptor) else 0,
|
||||
'tabs': [{
|
||||
'children_count': len(t.get_children()) if isinstance(t, vertical_block.VerticalBlock) else 0,
|
||||
'class': t.__class__.__name__} for t in s.get_children()
|
||||
]
|
||||
} for s in c.get_children() if not s.hide_from_toc]
|
||||
} for c in chapters]
|
||||
|
||||
return courseware
|
||||
@@ -1,11 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from lettuce import step, world
|
||||
from lettuce.django import django_url
|
||||
|
||||
|
||||
@step('I visit the courseware URL$')
|
||||
def i_visit_the_course_info_url(step):
|
||||
url = django_url('/courses/MITx/6.002x/2012_Fall/courseware')
|
||||
world.browser.visit(url)
|
||||
@@ -1,46 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=unused-argument
|
||||
|
||||
from lettuce import step, world
|
||||
|
||||
|
||||
@step('I click on View Courseware')
|
||||
def i_click_on_view_courseware(step):
|
||||
world.css_click('a.enter-course')
|
||||
|
||||
|
||||
@step('I click on the "([^"]*)" tab$')
|
||||
def i_click_on_the_tab(step, tab_text):
|
||||
world.click_link(tab_text)
|
||||
|
||||
|
||||
@step('I click the "([^"]*)" button$')
|
||||
def i_click_on_the_button(step, data_attr):
|
||||
world.click_button(data_attr)
|
||||
|
||||
|
||||
@step('I click on the "([^"]*)" link$')
|
||||
def i_click_on_the_link(step, link_text):
|
||||
world.click_link(link_text)
|
||||
|
||||
|
||||
@step('I visit the courseware URL$')
|
||||
def i_visit_the_course_info_url(step):
|
||||
world.visit('/courses/MITx/6.002x/2012_Fall/courseware')
|
||||
|
||||
|
||||
@step(u'I am on the dashboard page$')
|
||||
def i_am_on_the_dashboard_page(step):
|
||||
assert world.is_css_present('section.courses')
|
||||
assert world.url_equals('/dashboard')
|
||||
|
||||
|
||||
@step('the "([^"]*)" tab is active$')
|
||||
def the_tab_is_active(step, tab_text):
|
||||
assert world.css_text('.course-tabs a.active') == tab_text
|
||||
|
||||
|
||||
@step('the login dialog is visible$')
|
||||
def login_dialog_visible(step):
|
||||
assert world.css_visible('form#login_form.login_form')
|
||||
@@ -1,82 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
from django.conf import settings
|
||||
from lettuce import before, step, world
|
||||
from pymongo import MongoClient
|
||||
|
||||
from openedx.core.lib.tests.tools import assert_equals, assert_in # pylint: disable=no-name-in-module
|
||||
|
||||
REQUIRED_EVENT_FIELDS = [
|
||||
'agent',
|
||||
'event',
|
||||
'event_source',
|
||||
'event_type',
|
||||
'host',
|
||||
'ip',
|
||||
'page',
|
||||
'time',
|
||||
'username'
|
||||
]
|
||||
|
||||
|
||||
@before.all # pylint: disable=no-member
|
||||
def connect_to_mongodb():
|
||||
world.mongo_client = MongoClient(host=settings.MONGO_HOST, port=settings.MONGO_PORT_NUM)
|
||||
world.event_collection = world.mongo_client['track']['events']
|
||||
|
||||
|
||||
@before.each_scenario # pylint: disable=no-member
|
||||
def reset_captured_events(_scenario):
|
||||
world.event_collection.drop()
|
||||
|
||||
|
||||
@before.outline # pylint: disable=no-member
|
||||
def reset_between_outline_scenarios(_scenario, _order, _outline, _reasons_to_fail):
|
||||
world.event_collection.drop()
|
||||
|
||||
|
||||
@step(r'[aA]n? course url "(.*)" event is emitted$')
|
||||
def course_url_event_is_emitted(_step, url_regex):
|
||||
event_type = url_regex.format(world.scenario_dict['COURSE'].id) # pylint: disable=no-member
|
||||
n_events_are_emitted(_step, 1, event_type, "server")
|
||||
|
||||
|
||||
@step(r'([aA]n?|\d+) "(.*)" (server|browser) events? is emitted$')
|
||||
def n_events_are_emitted(_step, count, event_type, event_source):
|
||||
|
||||
# Ensure all events are written out to mongo before querying.
|
||||
world.mongo_client.fsync()
|
||||
|
||||
# Note that splinter makes 2 requests when you call browser.visit('/foo')
|
||||
# the first just checks to see if the server responds with a status
|
||||
# code of 200, the next actually uses the browser to submit the request.
|
||||
# We filter out events associated with the status code checks by ignoring
|
||||
# events that come directly from splinter.
|
||||
criteria = {
|
||||
'event_type': event_type,
|
||||
'event_source': event_source,
|
||||
'agent': {
|
||||
'$ne': 'python/splinter'
|
||||
}
|
||||
}
|
||||
|
||||
cursor = world.event_collection.find(criteria)
|
||||
|
||||
try:
|
||||
number_events = int(count)
|
||||
except ValueError:
|
||||
number_events = 1
|
||||
|
||||
assert_equals(cursor.count(), number_events)
|
||||
|
||||
event = cursor.next()
|
||||
|
||||
expected_field_values = {
|
||||
"username": world.scenario_dict['USER'].username, # pylint: disable=no-member
|
||||
"event_type": event_type,
|
||||
}
|
||||
for key, value in expected_field_values.iteritems():
|
||||
assert_equals(event[key], value)
|
||||
|
||||
for field in REQUIRED_EVENT_FIELDS:
|
||||
assert_in(field, event)
|
||||
@@ -1,150 +0,0 @@
|
||||
@shard_1 @requires_stub_lti
|
||||
Feature: LMS.LTI component
|
||||
As a student, I want to view LTI component in LMS.
|
||||
|
||||
#1
|
||||
Scenario: LTI component in LMS with no launch_url is not rendered
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with no_launch_url fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI and error is shown
|
||||
|
||||
#2
|
||||
Scenario: LTI component in LMS with incorrect lti_id is rendered incorrectly
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with incorrect_lti_id fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
|
||||
#3
|
||||
Scenario: LTI component in LMS is rendered incorrectly
|
||||
Given the course has incorrect LTI credentials
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI but incorrect_signature warning is rendered
|
||||
|
||||
#5
|
||||
Scenario: LTI component in LMS is correctly rendered in iframe
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page |
|
||||
| False |
|
||||
Then I view the LTI and it is rendered in iframe
|
||||
|
||||
#6
|
||||
Scenario: Graded LTI component in LMS is correctly works
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 1 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 5/10"
|
||||
And I see graph with total progress "5%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click the "Student Admin" button
|
||||
And I click on the "View Gradebook" link
|
||||
And I see in the gradebook table that "HW" is "50"
|
||||
And I see in the gradebook table that "Total" is "5"
|
||||
|
||||
#7
|
||||
Scenario: Graded LTI component in LMS role's masquerading correctly works
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | has_score |
|
||||
| False | True |
|
||||
And I view the LTI and it is rendered in iframe
|
||||
And I see in iframe that LTI role is Instructor
|
||||
And I switch to student
|
||||
And I view the LTI and it is rendered in iframe
|
||||
Then I see in iframe that LTI role is Student
|
||||
|
||||
#8
|
||||
Scenario: Graded LTI component in LMS is correctly works with beta testers
|
||||
Given the course has correct LTI credentials with registered BetaTester
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 1 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 5/10"
|
||||
And I see graph with total progress "5%"
|
||||
|
||||
#9
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2v0 PUT callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 2 question
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 8/10"
|
||||
And I see graph with total progress "8%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click the "Student Admin" button
|
||||
And I click on the "View Gradebook" link
|
||||
And I see in the gradebook table that "HW" is "80"
|
||||
And I see in the gradebook table that "Total" is "8"
|
||||
And I visit the LTI component
|
||||
Then I see LTI component progress with text "(8.0 / 10.0 points)"
|
||||
Then I see LTI component feedback with text "This is awesome."
|
||||
|
||||
#10
|
||||
Scenario: Graded LTI component in LMS is correctly works with LTI2v0 PUT delete callback
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | weight | graded | has_score |
|
||||
| False | 10 | True | True |
|
||||
And I submit answer to LTI 2 question
|
||||
And I visit the LTI component
|
||||
Then I see LTI component progress with text "(8.0 / 10.0 points)"
|
||||
Then I see LTI component feedback with text "This is awesome."
|
||||
And the LTI provider deletes my grade and feedback
|
||||
And I visit the LTI component (have to reload)
|
||||
Then I see LTI component progress with text "(10.0 points possible)"
|
||||
Then in the LTI component I do not see feedback
|
||||
And I click on the "Progress" tab
|
||||
Then I see text "Problem Scores: 0/10"
|
||||
And I see graph with total progress "0%"
|
||||
Then I click on the "Instructor" tab
|
||||
And I click the "Student Admin" button
|
||||
And I click on the "View Gradebook" link
|
||||
And I see in the gradebook table that "HW" is "0"
|
||||
And I see in the gradebook table that "Total" is "0"
|
||||
|
||||
#11
|
||||
Scenario: LTI component that set to hide_launch and open_in_a_new_page shows no button
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | hide_launch |
|
||||
| False | True |
|
||||
Then in the LTI component I do not see a launch button
|
||||
Then I see LTI component module title with text "LTI (External resource)"
|
||||
|
||||
#12
|
||||
Scenario: LTI component that set to hide_launch and not open_in_a_new_page shows no iframe
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| open_in_a_new_page | hide_launch |
|
||||
| True | True |
|
||||
Then in the LTI component I do not see an provider iframe
|
||||
Then I see LTI component module title with text "LTI (External resource)"
|
||||
|
||||
#13
|
||||
Scenario: LTI component button text is correctly displayed
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| button_text |
|
||||
| Launch Application |
|
||||
Then I see LTI component button with text "Launch Application"
|
||||
|
||||
#14
|
||||
Scenario: LTI component description is correctly displayed
|
||||
Given the course has correct LTI credentials with registered Instructor
|
||||
And the course has an LTI component with correct fields:
|
||||
| description |
|
||||
| Application description |
|
||||
Then I see LTI component description with text "Application description"
|
||||
@@ -1,345 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
import datetime
|
||||
import os
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from lettuce import step, world
|
||||
from mock import patch
|
||||
from pytz import UTC
|
||||
from splinter.exceptions import ElementDoesNotExist
|
||||
|
||||
from common import visit_scenario_item
|
||||
from courseware.access import has_access
|
||||
from courseware.tests.factories import BetaTesterFactory, InstructorFactory
|
||||
from openedx.core.lib.tests.tools import assert_equal, assert_in, assert_true # pylint: disable=no-name-in-module
|
||||
from student.tests.factories import UserFactory
|
||||
|
||||
TEST_COURSE_NAME = "test_course_a"
|
||||
|
||||
|
||||
@step('I view the LTI and error is shown$')
|
||||
def lti_is_not_rendered(_step):
|
||||
# error is shown
|
||||
assert world.is_css_present('.error_message', wait_time=0)
|
||||
|
||||
# iframe is not presented
|
||||
assert not world.is_css_present('iframe', wait_time=0)
|
||||
|
||||
# link is not presented
|
||||
assert not world.is_css_present('.link_lti_new_window', wait_time=0)
|
||||
|
||||
|
||||
def check_lti_iframe_content(text):
|
||||
# inside iframe test content is presented
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
iframe_name = 'ltiFrame-' + location
|
||||
with world.browser.get_iframe(iframe_name) as iframe:
|
||||
# iframe does not contain functions from terrain/ui_helpers.py
|
||||
assert iframe.is_element_present_by_css('.result', wait_time=0)
|
||||
assert (text == world.retry_on_exception(
|
||||
lambda: iframe.find_by_css('.result')[0].text,
|
||||
max_attempts=5
|
||||
))
|
||||
|
||||
|
||||
@step('I view the LTI and it is rendered in iframe$')
|
||||
def lti_is_rendered_iframe(_step):
|
||||
world.wait_for_present('iframe') # pylint: disable=no-member
|
||||
assert world.is_css_present('iframe', wait_time=2) # pylint: disable=no-member
|
||||
assert not world.is_css_present('.link_lti_new_window', wait_time=0) # pylint: disable=no-member
|
||||
assert not world.is_css_present('.error_message', wait_time=0) # pylint: disable=no-member
|
||||
|
||||
# iframe is visible
|
||||
assert world.css_visible('iframe') # pylint: disable=no-member
|
||||
check_lti_iframe_content("This is LTI tool. Success.")
|
||||
|
||||
|
||||
@step('I view the LTI but incorrect_signature warning is rendered$')
|
||||
def incorrect_lti_is_rendered(_step):
|
||||
assert world.is_css_present('iframe', wait_time=2)
|
||||
assert not world.is_css_present('.link_lti_new_window', wait_time=0)
|
||||
assert not world.is_css_present('.error_message', wait_time=0)
|
||||
|
||||
# inside iframe test content is presented
|
||||
check_lti_iframe_content("Wrong LTI signature")
|
||||
|
||||
|
||||
@step('the course has correct LTI credentials with registered (.*)$')
|
||||
def set_correct_lti_passport(_step, user='Instructor'):
|
||||
coursenum = TEST_COURSE_NAME
|
||||
metadata = {
|
||||
'lti_passports': ["correct_lti_id:test_client_key:test_client_secret"]
|
||||
}
|
||||
|
||||
i_am_registered_for_the_course(coursenum, metadata, user)
|
||||
|
||||
|
||||
@step('the course has incorrect LTI credentials$')
|
||||
def set_incorrect_lti_passport(_step):
|
||||
coursenum = TEST_COURSE_NAME
|
||||
metadata = {
|
||||
'lti_passports': ["test_lti_id:test_client_key:incorrect_lti_secret_key"]
|
||||
}
|
||||
|
||||
i_am_registered_for_the_course(coursenum, metadata)
|
||||
|
||||
|
||||
@step(r'the course has an LTI component with (.*) fields(?:\:)?$') # , new_page is(.*), graded is(.*)
|
||||
def add_correct_lti_to_course(_step, fields):
|
||||
category = 'lti'
|
||||
host = getattr(settings, 'LETTUCE_HOST', '127.0.0.1')
|
||||
metadata = {
|
||||
'lti_id': 'correct_lti_id',
|
||||
'launch_url': 'http://{}:{}/correct_lti_endpoint'.format(host, settings.LTI_PORT),
|
||||
}
|
||||
|
||||
if fields.strip() == 'incorrect_lti_id': # incorrect fields
|
||||
metadata.update({
|
||||
'lti_id': 'incorrect_lti_id'
|
||||
})
|
||||
elif fields.strip() == 'correct': # correct fields
|
||||
pass
|
||||
elif fields.strip() == 'no_launch_url':
|
||||
metadata.update({
|
||||
'launch_url': u''
|
||||
})
|
||||
else: # incorrect parameter
|
||||
assert False
|
||||
|
||||
if _step.hashes:
|
||||
metadata.update(_step.hashes[0])
|
||||
|
||||
world.scenario_dict['LTI'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['SECTION'].location,
|
||||
category=category,
|
||||
display_name='LTI',
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
visit_scenario_item('LTI')
|
||||
|
||||
|
||||
def create_course_for_lti(course, metadata):
|
||||
# First clear the modulestore so we don't try to recreate
|
||||
# the same course twice
|
||||
# This also ensures that the necessary templates are loaded
|
||||
world.clear_courses()
|
||||
|
||||
weight = 0.1
|
||||
grading_policy = {
|
||||
"GRADER": [
|
||||
{
|
||||
"type": "Homework",
|
||||
"min_count": 1,
|
||||
"drop_count": 0,
|
||||
"short_label": "HW",
|
||||
"weight": weight
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
# Create the course
|
||||
# We always use the same org and display name,
|
||||
# but vary the course identifier (e.g. 600x or 191x)
|
||||
world.scenario_dict['COURSE'] = world.CourseFactory.create(
|
||||
org='edx',
|
||||
number=course,
|
||||
display_name='Test Course',
|
||||
metadata=metadata,
|
||||
grading_policy=grading_policy,
|
||||
)
|
||||
|
||||
# Add a section to the course to contain problems
|
||||
world.scenario_dict['CHAPTER'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['COURSE'].location,
|
||||
category='chapter',
|
||||
display_name='Test Chapter',
|
||||
)
|
||||
world.scenario_dict['SECTION'] = world.ItemFactory.create(
|
||||
parent_location=world.scenario_dict['CHAPTER'].location,
|
||||
category='sequential',
|
||||
display_name='Test Section',
|
||||
metadata={'graded': True, 'format': 'Homework'})
|
||||
|
||||
|
||||
@patch.dict('courseware.access.settings.FEATURES', {'DISABLE_START_DATES': False})
|
||||
def i_am_registered_for_the_course(coursenum, metadata, user='Instructor'):
|
||||
# Create user
|
||||
if user == 'BetaTester':
|
||||
# Create the course
|
||||
now = datetime.datetime.now(pytz.UTC)
|
||||
tomorrow = now + datetime.timedelta(days=5)
|
||||
metadata.update({'days_early_for_beta': 5, 'start': tomorrow})
|
||||
create_course_for_lti(coursenum, metadata)
|
||||
course_descriptor = world.scenario_dict['COURSE']
|
||||
|
||||
# create beta tester
|
||||
user = BetaTesterFactory(course_key=course_descriptor.id)
|
||||
normal_student = UserFactory()
|
||||
instructor = InstructorFactory(course_key=course_descriptor.id)
|
||||
|
||||
assert not has_access(normal_student, 'load', course_descriptor)
|
||||
assert has_access(user, 'load', course_descriptor)
|
||||
assert has_access(instructor, 'load', course_descriptor)
|
||||
else:
|
||||
metadata.update({'start': datetime.datetime(1970, 1, 1, tzinfo=UTC)})
|
||||
create_course_for_lti(coursenum, metadata)
|
||||
course_descriptor = world.scenario_dict['COURSE']
|
||||
user = InstructorFactory(course_key=course_descriptor.id)
|
||||
|
||||
# Enroll the user in the course and log them in
|
||||
if has_access(user, 'load', course_descriptor):
|
||||
world.enroll_user(user, course_descriptor.id)
|
||||
|
||||
world.log_in(username=user.username, password='test')
|
||||
|
||||
|
||||
def check_lti_popup(parent_window):
|
||||
# You should now have 2 browser windows open, the original courseware and the LTI
|
||||
windows = world.browser.windows
|
||||
assert_equal(len(windows), 2)
|
||||
|
||||
# For verification, iterate through the window titles and make sure that
|
||||
# both are there.
|
||||
tabs = []
|
||||
expected_tabs = [
|
||||
u'LTI | Test Section | {course} Courseware | {platform}'.format(
|
||||
course=TEST_COURSE_NAME,
|
||||
platform=settings.PLATFORM_NAME
|
||||
),
|
||||
u'TEST TITLE'
|
||||
]
|
||||
|
||||
for window in windows:
|
||||
world.browser.switch_to_window(window)
|
||||
tabs.append(world.browser.title)
|
||||
assert_equal(tabs, expected_tabs)
|
||||
|
||||
# Now verify the contents of the LTI window (which is the 2nd window/tab)
|
||||
# Note: The LTI opens in a new browser window, but Selenium sticks with the
|
||||
# current window until you explicitly switch to the context of the new one.
|
||||
world.browser.switch_to_window(windows[1])
|
||||
url = world.browser.url
|
||||
basename = os.path.basename(url)
|
||||
pathname = os.path.splitext(basename)[0]
|
||||
assert_equal(pathname, u'correct_lti_endpoint')
|
||||
|
||||
result = world.css_find('.result').first.text
|
||||
assert_equal(result, u'This is LTI tool. Success.')
|
||||
|
||||
world.browser.driver.close() # Close the pop-up window
|
||||
world.browser.switch_to_window(parent_window) # Switch to the main window again
|
||||
|
||||
|
||||
def click_and_check_lti_popup():
|
||||
parent_window = world.browser.current_window # Save the parent window
|
||||
world.css_find('.link_lti_new_window').first.click()
|
||||
check_lti_popup(parent_window)
|
||||
|
||||
|
||||
@step('visit the LTI component')
|
||||
def visit_lti_component(_step):
|
||||
visit_scenario_item('LTI')
|
||||
|
||||
|
||||
@step('I see LTI component (.*) with text "([^"]*)"$')
|
||||
def see_elem_text(_step, elem, text):
|
||||
selector_map = {
|
||||
'progress': '.problem-progress',
|
||||
'feedback': '.problem-feedback',
|
||||
'module title': '.problem-header',
|
||||
'button': '.link_lti_new_window',
|
||||
'description': '.lti-description'
|
||||
}
|
||||
assert_in(elem, selector_map)
|
||||
assert_true(world.css_has_text(selector_map[elem], text))
|
||||
|
||||
|
||||
@step('I see text "([^"]*)"$')
|
||||
def check_progress(_step, text):
|
||||
assert world.browser.is_text_present(text)
|
||||
|
||||
|
||||
@step('I see graph with total progress "([^"]*)"$')
|
||||
def see_graph(_step, progress):
|
||||
assert_equal(progress, world.css_find('#grade-detail-graph .overallGrade').first.text.split('\n')[1])
|
||||
|
||||
|
||||
@step('I see in the gradebook table that "([^"]*)" is "([^"]*)"$')
|
||||
def see_value_in_the_gradebook(_step, label, text):
|
||||
table_selector = '.grade-table'
|
||||
index = 0
|
||||
table_headers = world.css_find(u'{0} thead th'.format(table_selector))
|
||||
|
||||
for i, element in enumerate(table_headers):
|
||||
if element.text.strip() == label:
|
||||
index = i
|
||||
break
|
||||
|
||||
assert_true(world.css_has_text(u'{0} tbody td'.format(table_selector), text, index=index))
|
||||
|
||||
|
||||
@step('I submit answer to LTI (.*) question$')
|
||||
def click_grade(_step, version):
|
||||
version_map = {
|
||||
'1': {'selector': 'submit-button', 'expected_text': 'LTI consumer (edX) responded with XML content'},
|
||||
'2': {'selector': 'submit-lti2-button', 'expected_text': 'LTI consumer (edX) responded with HTTP 200'},
|
||||
}
|
||||
assert_in(version, version_map)
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
iframe_name = 'ltiFrame-' + location
|
||||
with world.browser.get_iframe(iframe_name) as iframe:
|
||||
css_ele = version_map[version]['selector']
|
||||
css_loc = '#' + css_ele
|
||||
world.wait_for_visible(css_loc)
|
||||
world.css_click(css_loc)
|
||||
assert iframe.is_text_present(version_map[version]['expected_text'])
|
||||
|
||||
|
||||
@step('LTI provider deletes my grade and feedback$')
|
||||
def click_delete_button(_step):
|
||||
with world.browser.get_iframe(get_lti_frame_name()) as iframe:
|
||||
iframe.find_by_name('submit-lti2-delete-button').first.click()
|
||||
|
||||
|
||||
def get_lti_frame_name():
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
return 'ltiFrame-' + location
|
||||
|
||||
|
||||
@step('I see in iframe that LTI role is (.*)$')
|
||||
def check_role(_step, role):
|
||||
world.wait_for_present('iframe')
|
||||
location = world.scenario_dict['LTI'].location.html_id()
|
||||
iframe_name = 'ltiFrame-' + location
|
||||
with world.browser.get_iframe(iframe_name) as iframe:
|
||||
expected_role = 'Role: ' + role
|
||||
role = world.retry_on_exception(
|
||||
lambda: iframe.find_by_tag('h5').first.value,
|
||||
max_attempts=5,
|
||||
ignored_exceptions=ElementDoesNotExist
|
||||
)
|
||||
assert_equal(expected_role, role)
|
||||
|
||||
|
||||
@step('I switch to (.*)$')
|
||||
def switch_view(_step, view):
|
||||
staff_status = world.css_find('#action-preview-select').first.value
|
||||
if staff_status != view:
|
||||
world.browser.select("select", view)
|
||||
world.wait_for_ajax_complete()
|
||||
assert_equal(world.css_find('#action-preview-select').first.value, view)
|
||||
|
||||
|
||||
@step("in the LTI component I do not see (.*)$")
|
||||
def check_lti_component_no_elem(_step, text):
|
||||
selector_map = {
|
||||
'a launch button': '.link_lti_new_window',
|
||||
'an provider iframe': '.ltiLaunchFrame',
|
||||
'feedback': '.problem-feedback',
|
||||
'progress': '.problem-progress',
|
||||
}
|
||||
assert_in(text, selector_map)
|
||||
assert_true(world.is_css_not_present(selector_map[text]))
|
||||
@@ -1,460 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
# EVERY PROBLEM TYPE MUST HAVE THE FOLLOWING:
|
||||
# -Section in Dictionary containing:
|
||||
# -factory
|
||||
# -kwargs
|
||||
# -(optional metadata)
|
||||
# -Correct, Incorrect and Unanswered CSS selectors
|
||||
# -A way to answer the problem correctly and incorrectly
|
||||
# -A way to check the problem was answered correctly, incorrectly and blank
|
||||
|
||||
import random
|
||||
import textwrap
|
||||
|
||||
from lettuce import world
|
||||
|
||||
from capa.tests.response_xml_factory import (
|
||||
ChoiceResponseXMLFactory,
|
||||
ChoiceTextResponseXMLFactory,
|
||||
CodeResponseXMLFactory,
|
||||
CustomResponseXMLFactory,
|
||||
FormulaResponseXMLFactory,
|
||||
ImageResponseXMLFactory,
|
||||
MultipleChoiceResponseXMLFactory,
|
||||
NumericalResponseXMLFactory,
|
||||
OptionResponseXMLFactory,
|
||||
StringResponseXMLFactory
|
||||
)
|
||||
from common import section_location
|
||||
|
||||
# Factories from capa.tests.response_xml_factory that we will use
|
||||
# to generate the problem XML, with the keyword args used to configure
|
||||
# the output.
|
||||
# 'correct', 'incorrect', and 'unanswered' keys are lists of CSS selectors
|
||||
# the presence of any in the list is sufficient
|
||||
PROBLEM_DICT = {
|
||||
'drop down': {
|
||||
'factory': OptionResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Option 2',
|
||||
'options': ['Option 1', 'Option 2', 'Option 3', 'Option 4'],
|
||||
'correct_option': 'Option 2'},
|
||||
'correct': ['span.correct'],
|
||||
'incorrect': ['span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'multiple choice': {
|
||||
'factory': MultipleChoiceResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choice 3',
|
||||
'choices': [False, False, True, False],
|
||||
'choice_names': ['choice_0', 'choice_1', 'choice_2', 'choice_3']},
|
||||
'correct': ['label.choicegroup_correct', 'span.correct'],
|
||||
'incorrect': ['label.choicegroup_incorrect', 'span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'checkbox': {
|
||||
'factory': ChoiceResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choices 1 and 3',
|
||||
'choice_type': 'checkbox',
|
||||
'choices': [True, False, True, False, False],
|
||||
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']},
|
||||
'correct': ['span.correct'],
|
||||
'incorrect': ['span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'radio': {
|
||||
'factory': ChoiceResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choice 3',
|
||||
'choice_type': 'radio',
|
||||
'choices': [False, False, True, False],
|
||||
'choice_names': ['Choice 1', 'Choice 2', 'Choice 3', 'Choice 4']},
|
||||
'correct': ['label.choicegroup_correct', 'span.correct'],
|
||||
'incorrect': ['label.choicegroup_incorrect', 'span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'string': {
|
||||
'factory': StringResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The answer is "correct string"',
|
||||
'case_sensitive': False,
|
||||
'answer': 'correct string'},
|
||||
'correct': ['div.correct'],
|
||||
'incorrect': ['div.incorrect'],
|
||||
'unanswered': ['div.unanswered', 'div.unsubmitted']},
|
||||
|
||||
'numerical': {
|
||||
'factory': NumericalResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The answer is pi + 1',
|
||||
'answer': '4.14159',
|
||||
'tolerance': '0.00001',
|
||||
'math_display': True},
|
||||
'correct': ['div.correct'],
|
||||
'incorrect': ['div.incorrect'],
|
||||
'unanswered': ['div.unanswered', 'div.unsubmitted']},
|
||||
|
||||
'formula': {
|
||||
'factory': FormulaResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The solution is [mathjax]x^2+2x+y[/mathjax]',
|
||||
'sample_dict': {'x': (-100, 100), 'y': (-100, 100)},
|
||||
'num_samples': 10,
|
||||
'tolerance': 0.00001,
|
||||
'math_display': True,
|
||||
'answer': 'x^2+2*x+y'},
|
||||
'correct': ['div.correct'],
|
||||
'incorrect': ['div.incorrect'],
|
||||
'unanswered': ['div.unanswered', 'div.unsubmitted']},
|
||||
|
||||
'script': {
|
||||
'factory': CustomResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'Enter two integers that sum to 10.',
|
||||
'cfn': 'test_add_to_ten',
|
||||
'expect': '10',
|
||||
'num_inputs': 2,
|
||||
'script': textwrap.dedent("""
|
||||
def test_add_to_ten(expect,ans):
|
||||
try:
|
||||
a1=int(ans[0])
|
||||
a2=int(ans[1])
|
||||
except ValueError:
|
||||
a1=0
|
||||
a2=0
|
||||
return (a1+a2)==int(expect)
|
||||
""")},
|
||||
'correct': ['div.correct'],
|
||||
'incorrect': ['div.incorrect'],
|
||||
'unanswered': ['div.unanswered', 'div.unsubmitted']},
|
||||
|
||||
'code': {
|
||||
'factory': CodeResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'Submit code to an external grader',
|
||||
'initial_display': 'print "Hello world!"',
|
||||
'grader_payload': '{"grader": "ps1/Spring2013/test_grader.py"}', },
|
||||
'correct': ['span.correct'],
|
||||
'incorrect': ['span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'radio_text': {
|
||||
'factory': ChoiceTextResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choice 0 and input 8',
|
||||
'type': 'radiotextgroup',
|
||||
'choices': [("true", {"answer": "8", "tolerance": "1"}),
|
||||
("false", {"answer": "8", "tolerance": "1"})
|
||||
]
|
||||
},
|
||||
'correct': ['section.choicetextgroup_correct'],
|
||||
'incorrect': ['section.choicetextgroup_incorrect', 'span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'checkbox_text': {
|
||||
'factory': ChoiceTextResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'question_text': 'The correct answer is Choice 0 and input 8',
|
||||
'type': 'checkboxtextgroup',
|
||||
'choices': [("true", {"answer": "8", "tolerance": "1"}),
|
||||
("false", {"answer": "8", "tolerance": "1"})
|
||||
]
|
||||
},
|
||||
'correct': ['span.correct'],
|
||||
'incorrect': ['span.incorrect'],
|
||||
'unanswered': ['span.unanswered']},
|
||||
|
||||
'image': {
|
||||
'factory': ImageResponseXMLFactory(),
|
||||
'kwargs': {
|
||||
'src': '/static/images/placeholder-image.png',
|
||||
'rectangle': '(50,50)-(100,100)'
|
||||
},
|
||||
'correct': ['span.correct'],
|
||||
'incorrect': ['span.incorrect'],
|
||||
'unanswered': ['span.unanswered']}
|
||||
}
|
||||
|
||||
|
||||
def answer_problem(course, problem_type, correctness):
|
||||
# Make sure that the problem has been completely rendered before
|
||||
# starting to input an answer.
|
||||
world.wait_for_ajax_complete()
|
||||
|
||||
section_loc = section_location(course)
|
||||
|
||||
if problem_type == "drop down":
|
||||
select_name = "input_{}_2_1".format(
|
||||
section_loc.course_key.make_usage_key('problem', 'drop_down').html_id()
|
||||
)
|
||||
option_text = 'Option 2' if correctness == 'correct' else 'Option 3'
|
||||
world.select_option(select_name, option_text)
|
||||
|
||||
elif problem_type == "multiple choice":
|
||||
if correctness == 'correct':
|
||||
world.css_check(inputfield(course, 'multiple choice', choice='choice_2'))
|
||||
else:
|
||||
world.css_check(inputfield(course, 'multiple choice', choice='choice_1'))
|
||||
|
||||
elif problem_type == "checkbox":
|
||||
if correctness == 'correct':
|
||||
world.css_check(inputfield(course, 'checkbox', choice='choice_0'))
|
||||
world.css_check(inputfield(course, 'checkbox', choice='choice_2'))
|
||||
else:
|
||||
world.css_check(inputfield(course, 'checkbox', choice='choice_3'))
|
||||
|
||||
elif problem_type == 'radio':
|
||||
if correctness == 'correct':
|
||||
world.css_check(inputfield(course, 'radio', choice='choice_2'))
|
||||
else:
|
||||
world.css_check(inputfield(course, 'radio', choice='choice_1'))
|
||||
|
||||
elif problem_type == 'string':
|
||||
textvalue = 'correct string' if correctness == 'correct' else 'incorrect'
|
||||
world.css_fill(inputfield(course, 'string'), textvalue)
|
||||
|
||||
elif problem_type == 'numerical':
|
||||
textvalue = "pi + 1" if correctness == 'correct' else str(random.randint(-2, 2))
|
||||
world.css_fill(inputfield(course, 'numerical'), textvalue)
|
||||
|
||||
elif problem_type == 'formula':
|
||||
textvalue = "x^2+2*x+y" if correctness == 'correct' else 'x^2'
|
||||
world.css_fill(inputfield(course, 'formula'), textvalue)
|
||||
|
||||
elif problem_type == 'script':
|
||||
# Correct answer is any two integers that sum to 10
|
||||
first_addend = random.randint(-100, 100)
|
||||
second_addend = 10 - first_addend
|
||||
|
||||
# If we want an incorrect answer, then change
|
||||
# the second addend so they no longer sum to 10
|
||||
if correctness == 'incorrect':
|
||||
second_addend += random.randint(1, 10)
|
||||
|
||||
world.css_fill(inputfield(course, 'script', input_num=1), str(first_addend))
|
||||
world.css_fill(inputfield(course, 'script', input_num=2), str(second_addend))
|
||||
|
||||
elif problem_type == 'code':
|
||||
# The fake xqueue server is configured to respond
|
||||
# correct / incorrect no matter what we submit.
|
||||
# Furthermore, since the inline code response uses
|
||||
# JavaScript to make the code display nicely, it's difficult
|
||||
# to programatically input text
|
||||
# (there's not <textarea> we can just fill text into)
|
||||
# For this reason, we submit the initial code in the response
|
||||
# (configured in the problem XML above)
|
||||
pass
|
||||
|
||||
elif problem_type == 'radio_text' or problem_type == 'checkbox_text':
|
||||
|
||||
input_value = "8" if correctness == 'correct' else "5"
|
||||
choice = "choiceinput_0bc" if correctness == 'correct' else "choiceinput_1bc"
|
||||
world.css_fill(
|
||||
inputfield(
|
||||
course,
|
||||
problem_type,
|
||||
choice="choiceinput_0_numtolerance_input_0"
|
||||
),
|
||||
input_value
|
||||
)
|
||||
world.css_check(inputfield(course, problem_type, choice=choice))
|
||||
elif problem_type == 'image':
|
||||
offset = 25 if correctness == "correct" else -25
|
||||
|
||||
def try_click():
|
||||
problem_html_loc = section_loc.course_key.make_usage_key('problem', 'image').html_id()
|
||||
image_selector = "#imageinput_{}_2_1".format(problem_html_loc)
|
||||
input_selector = "#input_{}_2_1".format(problem_html_loc)
|
||||
|
||||
world.browser.execute_script('$("body").on("click", function(event) {console.log(event);})') # pylint: disable=unicode-format-string
|
||||
initial_input = world.css_value(input_selector)
|
||||
world.wait_for_visible(image_selector)
|
||||
image = world.css_find(image_selector).first
|
||||
(image.action_chains
|
||||
.move_to_element(image._element)
|
||||
.move_by_offset(offset, offset)
|
||||
.click()
|
||||
.perform())
|
||||
|
||||
world.wait_for(lambda _: world.css_value(input_selector) != initial_input)
|
||||
|
||||
world.retry_on_exception(try_click)
|
||||
|
||||
|
||||
def problem_has_answer(course, problem_type, answer_class):
|
||||
if problem_type == "drop down":
|
||||
if answer_class == 'blank':
|
||||
assert world.is_css_not_present('option[selected="true"]')
|
||||
else:
|
||||
actual = world.css_value('option[selected="true"]')
|
||||
expected = 'Option 2' if answer_class == 'correct' else 'Option 3'
|
||||
assert actual == expected
|
||||
|
||||
elif problem_type == "multiple choice":
|
||||
if answer_class == 'correct':
|
||||
assert_submitted(course, 'multiple choice', ['choice_2'])
|
||||
elif answer_class == 'incorrect':
|
||||
assert_submitted(course, 'multiple choice', ['choice_1'])
|
||||
else:
|
||||
assert_submitted(course, 'multiple choice', [])
|
||||
|
||||
elif problem_type == "checkbox":
|
||||
if answer_class == 'correct':
|
||||
assert_submitted(course, 'checkbox', ['choice_0', 'choice_2'])
|
||||
elif answer_class == 'incorrect':
|
||||
assert_submitted(course, 'checkbox', ['choice_3'])
|
||||
else:
|
||||
assert_submitted(course, 'checkbox', [])
|
||||
|
||||
elif problem_type == "radio":
|
||||
if answer_class == 'correct':
|
||||
assert_submitted(course, 'radio', ['choice_2'])
|
||||
elif answer_class == 'incorrect':
|
||||
assert_submitted(course, 'radio', ['choice_1'])
|
||||
else:
|
||||
assert_submitted(course, 'radio', [])
|
||||
|
||||
elif problem_type == 'string':
|
||||
if answer_class == 'blank':
|
||||
expected = ''
|
||||
else:
|
||||
expected = 'correct string' if answer_class == 'correct' else 'incorrect'
|
||||
assert_textfield(course, 'string', expected)
|
||||
|
||||
elif problem_type == 'formula':
|
||||
if answer_class == 'blank':
|
||||
expected = ''
|
||||
else:
|
||||
expected = "x^2+2*x+y" if answer_class == 'correct' else 'x^2'
|
||||
assert_textfield(course, 'formula', expected)
|
||||
|
||||
elif problem_type in ("radio_text", "checkbox_text"):
|
||||
if answer_class == 'blank':
|
||||
expected = ('', '')
|
||||
assert_choicetext_values(course, problem_type, (), expected)
|
||||
elif answer_class == 'incorrect':
|
||||
expected = ('5', '')
|
||||
assert_choicetext_values(course, problem_type, ["choiceinput_1bc"], expected)
|
||||
else:
|
||||
expected = ('8', '')
|
||||
assert_choicetext_values(course, problem_type, ["choiceinput_0bc"], expected)
|
||||
|
||||
else:
|
||||
# The other response types use random data,
|
||||
# which would be difficult to check
|
||||
# We trade input value coverage in the other tests for
|
||||
# input type coverage in this test.
|
||||
pass
|
||||
|
||||
|
||||
def add_problem_to_course(course, problem_type, extra_meta=None):
|
||||
'''
|
||||
Add a problem to the course we have created using factories.
|
||||
'''
|
||||
|
||||
assert problem_type in PROBLEM_DICT
|
||||
|
||||
# Generate the problem XML using capa.tests.response_xml_factory
|
||||
factory_dict = PROBLEM_DICT[problem_type]
|
||||
problem_xml = factory_dict['factory'].build_xml(**factory_dict['kwargs'])
|
||||
metadata = {'rerandomize': 'always'} if 'metadata' not in factory_dict else factory_dict['metadata']
|
||||
if extra_meta:
|
||||
metadata = dict(metadata, **extra_meta)
|
||||
|
||||
# Create a problem item using our generated XML
|
||||
# We set rerandomize=always in the metadata so that the "Reset" button
|
||||
# will appear.
|
||||
category_name = "problem"
|
||||
return world.ItemFactory.create(
|
||||
parent_location=section_location(course),
|
||||
category=category_name,
|
||||
display_name=str(problem_type),
|
||||
data=problem_xml,
|
||||
metadata=metadata
|
||||
)
|
||||
|
||||
|
||||
def inputfield(course, problem_type, choice=None, input_num=1):
|
||||
""" Return the css selector for `problem_type`.
|
||||
For example, if problem_type is 'string', return
|
||||
the text field for the string problem in the test course.
|
||||
|
||||
`choice` is the name of the checkbox input in a group
|
||||
of checkboxes. """
|
||||
|
||||
section_loc = section_location(course)
|
||||
|
||||
ptype = problem_type.replace(" ", "_")
|
||||
# this is necessary due to naming requirement for this problem type
|
||||
if problem_type in ("radio_text", "checkbox_text"):
|
||||
selector_template = "input#{}_2_{input}"
|
||||
else:
|
||||
selector_template = "input#input_{}_2_{input}"
|
||||
|
||||
sel = selector_template.format(
|
||||
section_loc.course_key.make_usage_key('problem', ptype).html_id(),
|
||||
input=input_num,
|
||||
)
|
||||
|
||||
if choice is not None:
|
||||
base = "_choice_" if problem_type == "multiple choice" else "_"
|
||||
sel = sel + base + str(choice)
|
||||
|
||||
# If the input element doesn't exist, fail immediately
|
||||
assert world.is_css_present(sel)
|
||||
|
||||
# Retrieve the input element
|
||||
return sel
|
||||
|
||||
|
||||
def assert_submitted(course, problem_type, choices):
|
||||
'''
|
||||
Assert that choice names given in *choices* are the only
|
||||
ones submitted.
|
||||
|
||||
Works for both radio and checkbox problems
|
||||
'''
|
||||
|
||||
all_choices = ['choice_0', 'choice_1', 'choice_2', 'choice_3']
|
||||
for this_choice in all_choices:
|
||||
def submit_problem():
|
||||
element = world.css_find(inputfield(course, problem_type, choice=this_choice))
|
||||
if this_choice in choices:
|
||||
assert element.checked
|
||||
else:
|
||||
assert not element.checked
|
||||
world.retry_on_exception(submit_problem)
|
||||
|
||||
|
||||
def assert_textfield(course, problem_type, expected_text, input_num=1):
|
||||
element_value = world.css_value(inputfield(course, problem_type, input_num=input_num))
|
||||
assert element_value == expected_text
|
||||
|
||||
|
||||
def assert_choicetext_values(course, problem_type, choices, expected_values):
|
||||
"""
|
||||
Asserts that only the given choices are checked, and given
|
||||
text fields have a desired value
|
||||
"""
|
||||
# Names of the radio buttons or checkboxes
|
||||
all_choices = ['choiceinput_0bc', 'choiceinput_1bc']
|
||||
# Names of the numtolerance_inputs
|
||||
all_inputs = [
|
||||
"choiceinput_0_numtolerance_input_0",
|
||||
"choiceinput_1_numtolerance_input_0"
|
||||
]
|
||||
for this_choice in all_choices:
|
||||
element = world.css_find(inputfield(course, problem_type, choice=this_choice))
|
||||
|
||||
if this_choice in choices:
|
||||
assert element.checked
|
||||
else:
|
||||
assert not element.checked
|
||||
|
||||
for (name, expected) in zip(all_inputs, expected_values):
|
||||
element = world.css_find(inputfield(course, problem_type, name))
|
||||
# Remove any trailing spaces that may have been added
|
||||
assert element.value.strip() == expected
|
||||
@@ -1,61 +0,0 @@
|
||||
# pylint: disable=missing-docstring
|
||||
|
||||
import time
|
||||
|
||||
from lettuce import step, world
|
||||
from lettuce.django import django_url
|
||||
from six import text_type
|
||||
|
||||
|
||||
@step('I register for the course "([^"]*)"$')
|
||||
def i_register_for_the_course(_step, course):
|
||||
url = django_url('courses/%s/about' % text_type(world.scenario_dict['COURSE'].id))
|
||||
world.browser.visit(url)
|
||||
world.css_click('.intro a.register')
|
||||
assert world.is_css_present('.dashboard')
|
||||
|
||||
|
||||
@step('I register to audit the course$')
|
||||
def i_register_to_audit_the_course(_step):
|
||||
url = django_url('courses/%s/about' % text_type(world.scenario_dict['COURSE'].id))
|
||||
world.browser.visit(url)
|
||||
world.css_click('.intro a.register')
|
||||
# When the page first loads some animation needs to
|
||||
# complete before this button is in a stable location
|
||||
world.retry_on_exception(
|
||||
lambda: world.browser.find_by_name("honor_mode").click(),
|
||||
max_attempts=10,
|
||||
ignored_exceptions=AttributeError
|
||||
)
|
||||
time.sleep(1)
|
||||
assert world.is_css_present('.dashboard')
|
||||
|
||||
|
||||
@step(u'I should see an empty dashboard message')
|
||||
def i_should_see_empty_dashboard(_step):
|
||||
empty_dash_css = '.empty-dashboard-message'
|
||||
assert world.is_css_present(empty_dash_css)
|
||||
|
||||
|
||||
@step(u'I should( NOT)? see the course numbered "([^"]*)" in my dashboard$')
|
||||
def i_should_see_that_course_in_my_dashboard(_step, doesnt_appear, course):
|
||||
course_link_css = '.my-courses a[href*="%s"]' % course
|
||||
if doesnt_appear:
|
||||
assert world.is_css_not_present(course_link_css)
|
||||
else:
|
||||
assert world.is_css_present(course_link_css)
|
||||
|
||||
|
||||
@step(u'I unenroll from the course numbered "([^"]*)"')
|
||||
def i_unenroll_from_that_course(_step, course):
|
||||
more_actions_dropdown_link_selector = '[id*=actions-dropdown-link-0]'
|
||||
assert world.is_css_present(more_actions_dropdown_link_selector)
|
||||
world.css_click(more_actions_dropdown_link_selector)
|
||||
|
||||
unregister_css = u'li.actions-item a.action-unenroll[data-course-number*="{course_number}"][href*=unenroll-modal]'.format(course_number=course)
|
||||
assert world.is_css_present(unregister_css)
|
||||
world.css_click(unregister_css)
|
||||
|
||||
button_css = '#unenroll-modal input[value="Unenroll"]'
|
||||
assert world.is_css_present(button_css)
|
||||
world.css_click(button_css)
|
||||
@@ -23,6 +23,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from edx_django_utils.monitoring import set_custom_metrics_for_course_key, set_monitoring_transaction_name
|
||||
from edx_proctoring.api import get_attempt_status_summary
|
||||
from edx_proctoring.services import ProctoringService
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from opaque_keys import InvalidKeyError
|
||||
@@ -257,28 +258,12 @@ def _add_timed_exam_info(user, course, section, section_context):
|
||||
settings.FEATURES.get('ENABLE_SPECIAL_EXAMS', False)
|
||||
)
|
||||
if section_is_time_limited:
|
||||
# We need to import this here otherwise Lettuce test
|
||||
# harness fails. When running in 'harvest' mode, the
|
||||
# test service appears to get into trouble with
|
||||
# circular references (not sure which as edx_proctoring.api
|
||||
# doesn't import anything from edx-platform). Odd thing
|
||||
# is that running: manage.py lms runserver --settings=acceptance
|
||||
# works just fine, it's really a combination of Lettuce and the
|
||||
# 'harvest' management command
|
||||
#
|
||||
# One idea is that there is some coupling between
|
||||
# lettuce and the 'terrain' Djangoapps projects in /common
|
||||
# This would need more investigation
|
||||
from edx_proctoring.api import get_attempt_status_summary
|
||||
|
||||
#
|
||||
# call into edx_proctoring subsystem
|
||||
# to get relevant proctoring information regarding this
|
||||
# level of the courseware
|
||||
#
|
||||
# This will return None, if (user, course_id, content_id)
|
||||
# is not applicable
|
||||
#
|
||||
timed_exam_attempt_context = None
|
||||
try:
|
||||
timed_exam_attempt_context = get_attempt_status_summary(
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
Define common steps for instructor dashboard acceptance tests.
|
||||
"""
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from __future__ import absolute_import
|
||||
|
||||
from lettuce import step, world
|
||||
from mock import patch
|
||||
|
||||
from courseware.tests.factories import InstructorFactory, StaffFactory
|
||||
from openedx.core.lib.tests.tools import assert_in # pylint: disable=no-name-in-module
|
||||
|
||||
|
||||
@step(u'Given I am "([^"]*)" for a very large course')
|
||||
def make_staff_or_instructor_for_large_course(step, role):
|
||||
make_large_course(step, role)
|
||||
|
||||
|
||||
@patch.dict('courseware.access.settings.FEATURES', {"MAX_ENROLLMENT_INSTR_BUTTONS": 0})
|
||||
def make_large_course(step, role):
|
||||
i_am_staff_or_instructor(step, role)
|
||||
|
||||
|
||||
@step(u'Given I am "([^"]*)" for a course')
|
||||
def i_am_staff_or_instructor(step, role): # pylint: disable=unused-argument
|
||||
## In summary: makes a test course, makes a new Staff or Instructor user
|
||||
## (depending on `role`), and logs that user in to the course
|
||||
|
||||
# Store the role
|
||||
assert_in(role, ['instructor', 'staff'])
|
||||
|
||||
# Clear existing courses to avoid conflicts
|
||||
world.clear_courses()
|
||||
|
||||
# Create a new course
|
||||
course = world.CourseFactory.create(
|
||||
org='edx',
|
||||
number='999',
|
||||
display_name='Test Course'
|
||||
)
|
||||
|
||||
world.course_key = course.id
|
||||
world.role = 'instructor'
|
||||
# Log in as the an instructor or staff for the course
|
||||
if role == 'instructor':
|
||||
# Make & register an instructor for the course
|
||||
world.instructor = InstructorFactory(course_key=world.course_key)
|
||||
world.enroll_user(world.instructor, world.course_key)
|
||||
|
||||
world.log_in(
|
||||
username=world.instructor.username,
|
||||
password='test',
|
||||
email=world.instructor.email,
|
||||
name=world.instructor.profile.name
|
||||
)
|
||||
|
||||
else:
|
||||
world.role = 'staff'
|
||||
# Make & register a staff member
|
||||
world.staff = StaffFactory(course_key=world.course_key)
|
||||
world.enroll_user(world.staff, world.course_key)
|
||||
|
||||
world.log_in(
|
||||
username=world.staff.username,
|
||||
password='test',
|
||||
email=world.staff.email,
|
||||
name=world.staff.profile.name
|
||||
)
|
||||
|
||||
|
||||
def go_to_section(section_name):
|
||||
# section name should be one of
|
||||
# course_info, membership, student_admin, data_download, analytics, send_email
|
||||
world.visit(u'/courses/{}'.format(world.course_key))
|
||||
world.css_click(u'a[href="/courses/{}/instructor"]'.format(world.course_key))
|
||||
world.css_click('[data-section="{0}"]'.format(section_name))
|
||||
|
||||
|
||||
@step(u'I click "([^"]*)"')
|
||||
def click_a_button(step, button): # pylint: disable=unused-argument
|
||||
|
||||
if button == "Generate Grade Report":
|
||||
# Go to the data download section of the instructor dash
|
||||
go_to_section("data_download")
|
||||
|
||||
# Click generate grade report button
|
||||
world.css_click('input[name="calculate-grades-csv"]')
|
||||
|
||||
# Expect to see a message that grade report is being generated
|
||||
expected_msg = "The grade report is being created." \
|
||||
" To view the status of the report, see" \
|
||||
" Pending Tasks below."
|
||||
world.wait_for_visible('#report-request-response')
|
||||
assert_in(
|
||||
expected_msg, world.css_text('#report-request-response'),
|
||||
msg="Could not find grade report generation success message."
|
||||
)
|
||||
|
||||
elif button == "Grading Configuration":
|
||||
# Go to the data download section of the instructor dash
|
||||
go_to_section("data_download")
|
||||
|
||||
world.css_click('input[name="dump-gradeconf"]')
|
||||
|
||||
elif button == "List enrolled students' profile information":
|
||||
# Go to the data download section of the instructor dash
|
||||
go_to_section("data_download")
|
||||
|
||||
world.css_click('input[name="list-profiles"]')
|
||||
|
||||
elif button == "Download profile information as a CSV":
|
||||
# Go to the data download section of the instructor dash
|
||||
go_to_section("data_download")
|
||||
|
||||
world.css_click('input[name="list-profiles-csv"]')
|
||||
|
||||
else:
|
||||
raise ValueError("Unrecognized button option " + button)
|
||||
|
||||
|
||||
@step(u'I visit the "([^"]*)" tab')
|
||||
def click_a_tab(step, tab_name): # pylint: disable=unused-argument
|
||||
# course_info, membership, student_admin, data_download, analytics, send_email
|
||||
tab_name_dict = {
|
||||
'Course Info': 'course_info',
|
||||
'Membership': 'membership',
|
||||
'Student Admin': 'student_admin',
|
||||
'Data Download': 'data_download',
|
||||
'Analytics': 'analytics',
|
||||
'Email': 'send_email',
|
||||
}
|
||||
go_to_section(tab_name_dict[tab_name])
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Define steps for instructor dashboard - data download tab
|
||||
acceptance tests.
|
||||
"""
|
||||
|
||||
# pylint: disable=missing-docstring
|
||||
# pylint: disable=no-member
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from django.utils import http
|
||||
from lettuce import step, world
|
||||
|
||||
from terrain.steps import reload_the_page
|
||||
|
||||
from openedx.core.lib.tests.tools import assert_in, assert_regexp_matches # pylint: disable=no-name-in-module
|
||||
|
||||
|
||||
@step(u'I see a table of student profiles')
|
||||
def find_student_profile_table(step): # pylint: disable=unused-argument
|
||||
# Find the grading configuration display
|
||||
world.wait_for_visible('#data-student-profiles-table')
|
||||
|
||||
# Wait for the data table to be populated
|
||||
world.wait_for(lambda _: world.css_text('#data-student-profiles-table') not in [u'', u'Loading'])
|
||||
|
||||
if world.role == 'instructor':
|
||||
expected_data = [
|
||||
world.instructor.username,
|
||||
world.instructor.email,
|
||||
world.instructor.profile.name,
|
||||
world.instructor.profile.gender,
|
||||
world.instructor.profile.goals
|
||||
]
|
||||
elif world.role == 'staff':
|
||||
expected_data = [
|
||||
world.staff.username,
|
||||
world.staff.email,
|
||||
world.staff.profile.name,
|
||||
world.staff.profile.gender,
|
||||
world.staff.profile.goals
|
||||
]
|
||||
for datum in expected_data:
|
||||
assert_in(datum, world.css_text('#data-student-profiles-table'))
|
||||
|
||||
|
||||
@step(u"I do not see a button to 'List enrolled students' profile information'")
|
||||
def no_student_profile_table(step): # pylint: disable=unused-argument
|
||||
world.is_css_not_present('input[name="list-profiles"]')
|
||||
|
||||
|
||||
@step(u"I see the grading configuration for the course")
|
||||
def find_grading_config(step): # pylint: disable=unused-argument
|
||||
# Find the grading configuration display
|
||||
world.wait_for_visible('#data-grade-config-text')
|
||||
# expected config is the default grading configuration from common/lib/xmodule/xmodule/course_module.py
|
||||
expected_config = u"""-----------------------------------------------------------------------------
|
||||
Course grader:
|
||||
<class 'xmodule.graders.WeightedSubsectionsGrader'>
|
||||
|
||||
Graded sections:
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Homework, category=Homework, weight=0.15
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Lab, category=Lab, weight=0.15
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Midterm Exam, category=Midterm Exam, weight=0.3
|
||||
subgrader=<class 'xmodule.graders.AssignmentFormatGrader'>, type=Final Exam, category=Final Exam, weight=0.4
|
||||
-----------------------------------------------------------------------------
|
||||
Listing grading context for course {}
|
||||
graded sections:
|
||||
[]
|
||||
all graded blocks:
|
||||
length=0""".format(world.course_key)
|
||||
assert_in(expected_config, world.css_text('#data-grade-config-text'))
|
||||
|
||||
|
||||
def verify_report_is_generated(report_name_substring):
|
||||
# Need to reload the page to see the reports table updated
|
||||
reload_the_page(step)
|
||||
world.wait_for_visible('#report-downloads-table')
|
||||
# Find table and assert a .csv file is present
|
||||
quoted_id = http.urlquote(world.course_key).replace('/', '_')
|
||||
expected_file_regexp = quoted_id + '_' + report_name_substring + r'_\d{4}-\d{2}-\d{2}-\d{4}\.csv'
|
||||
assert_regexp_matches(
|
||||
world.css_html('#report-downloads-table'), expected_file_regexp,
|
||||
msg="Expected report filename was not found."
|
||||
)
|
||||
|
||||
|
||||
@step(u"I see a grade report csv file in the reports table")
|
||||
def find_grade_report_csv_link(step): # pylint: disable=unused-argument
|
||||
verify_report_is_generated('grade_report')
|
||||
|
||||
|
||||
@step(u"I see a student profile csv file in the reports table")
|
||||
def find_student_profile_report_csv_link(step): # pylint: disable=unused-argument
|
||||
verify_report_is_generated('student_profile_info')
|
||||
@@ -1,215 +0,0 @@
|
||||
"""
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
from .test import *
|
||||
|
||||
# You need to start the server in debug mode,
|
||||
# otherwise the browser will not render the pages correctly
|
||||
DEBUG = True
|
||||
SITE_NAME = 'localhost:{}'.format(LETTUCE_SERVER_PORT)
|
||||
|
||||
# Output Django logs to a file
|
||||
import logging
|
||||
logging.basicConfig(filename=TEST_ROOT / "log" / "lms_acceptance.log", level=logging.ERROR)
|
||||
|
||||
# set root logger level
|
||||
logging.getLogger().setLevel(logging.ERROR)
|
||||
|
||||
import os
|
||||
from random import choice
|
||||
|
||||
|
||||
def seed():
|
||||
return os.getppid()
|
||||
|
||||
# Silence noisy logs
|
||||
LOG_OVERRIDES = [
|
||||
('track.middleware', logging.CRITICAL),
|
||||
('codejail.safe_exec', logging.ERROR),
|
||||
('edx.courseware', logging.ERROR),
|
||||
('audit', logging.ERROR),
|
||||
('lms.djangoapps.instructor_task.api_helper', logging.ERROR),
|
||||
]
|
||||
|
||||
for log_name, log_level in LOG_OVERRIDES:
|
||||
logging.getLogger(log_name).setLevel(log_level)
|
||||
|
||||
update_module_store_settings(
|
||||
MODULESTORE,
|
||||
doc_store_settings={
|
||||
'db': 'acceptance_xmodule',
|
||||
'collection': 'acceptance_modulestore_%s' % seed(),
|
||||
},
|
||||
module_store_options={
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
},
|
||||
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
|
||||
)
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'DOC_STORE_CONFIG': {
|
||||
'host': 'localhost',
|
||||
'db': 'acceptance_xcontent_%s' % seed(),
|
||||
}
|
||||
}
|
||||
|
||||
# Set this up so that 'paver lms --settings=acceptance' and running the
|
||||
# harvest command both use the same (test) database
|
||||
# which they can flush without messing up your dev db
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_edx.db",
|
||||
'OPTIONS': {
|
||||
'timeout': 30,
|
||||
},
|
||||
'ATOMIC_REQUESTS': True,
|
||||
'TEST': {
|
||||
'NAME': TEST_ROOT / "db" / "test_edx.db",
|
||||
},
|
||||
},
|
||||
'student_module_history': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': TEST_ROOT / "db" / "test_student_module_history.db",
|
||||
'OPTIONS': {
|
||||
'timeout': 30,
|
||||
},
|
||||
'TEST': {
|
||||
'NAME': TEST_ROOT / "db" / "test_student_module_history.db",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
TRACKING_BACKENDS.update({
|
||||
'mongo': {
|
||||
'ENGINE': 'track.backends.mongodb.MongoBackend'
|
||||
}
|
||||
})
|
||||
|
||||
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update({
|
||||
'mongo': {
|
||||
'ENGINE': 'eventtracking.backends.mongodb.MongoBackend',
|
||||
'OPTIONS': {
|
||||
'database': 'track'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
BULK_EMAIL_DEFAULT_FROM_EMAIL = "test@test.org"
|
||||
|
||||
# Forums are disabled in test.py to speed up unit tests, but we do not have
|
||||
# per-test control for lettuce acceptance tests.
|
||||
# If you are writing an acceptance test that needs the discussion service enabled,
|
||||
# do not write it in lettuce, but instead write it using bok-choy.
|
||||
# DO NOT CHANGE THIS SETTING HERE.
|
||||
FEATURES['ENABLE_DISCUSSION_SERVICE'] = False
|
||||
|
||||
# Use the auto_auth workflow for creating users and logging them in
|
||||
FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] = True
|
||||
FEATURES['RESTRICT_AUTOMATIC_AUTH'] = False
|
||||
|
||||
# Enable third-party authentication
|
||||
FEATURES['ENABLE_THIRD_PARTY_AUTH'] = True
|
||||
THIRD_PARTY_AUTH = {
|
||||
"Google": {
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_KEY": "test",
|
||||
"SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET": "test"
|
||||
},
|
||||
"Facebook": {
|
||||
"SOCIAL_AUTH_FACEBOOK_KEY": "test",
|
||||
"SOCIAL_AUTH_FACEBOOK_SECRET": "test"
|
||||
}
|
||||
}
|
||||
|
||||
# Enable fake payment processing page
|
||||
FEATURES['ENABLE_PAYMENT_FAKE'] = True
|
||||
|
||||
# Enable special exams
|
||||
FEATURES['ENABLE_SPECIAL_EXAMS'] = True
|
||||
|
||||
# Don't actually send any requests to Software Secure for student identity
|
||||
# verification.
|
||||
FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True
|
||||
|
||||
# HACK
|
||||
# Setting this flag to false causes imports to not load correctly in the lettuce python files
|
||||
# We do not yet understand why this occurs. Setting this to true is a stopgap measure
|
||||
USE_I18N = True
|
||||
|
||||
FEATURES['ENABLE_FEEDBACK_SUBMISSION'] = False
|
||||
|
||||
# Include the lettuce app for acceptance testing, including the 'harvest' django-admin command
|
||||
INSTALLED_APPS.append('lettuce.django')
|
||||
LETTUCE_APPS = ('courseware', 'instructor')
|
||||
|
||||
# Lettuce appears to have a bug that causes it to search
|
||||
# `instructor_task` when we specify the `instructor` app.
|
||||
# This causes some pretty cryptic errors as lettuce tries
|
||||
# to parse files in `instructor_task` as features.
|
||||
# As a quick workaround, explicitly exclude the `instructor_task` app.
|
||||
# The coursewarehistoryextended app also falls prey to this fuzzy
|
||||
# for the courseware app.
|
||||
LETTUCE_AVOID_APPS = ('instructor_task', 'coursewarehistoryextended')
|
||||
|
||||
LETTUCE_BROWSER = os.environ.get('LETTUCE_BROWSER', 'chrome')
|
||||
|
||||
# Where to run: local, saucelabs, or grid
|
||||
LETTUCE_SELENIUM_CLIENT = os.environ.get('LETTUCE_SELENIUM_CLIENT', 'local')
|
||||
|
||||
SELENIUM_GRID = {
|
||||
'URL': 'http://127.0.0.1:4444/wd/hub',
|
||||
'BROWSER': LETTUCE_BROWSER,
|
||||
}
|
||||
|
||||
|
||||
#####################################################################
|
||||
# See if the developer has any local overrides.
|
||||
try:
|
||||
from .private import *
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Because an override for where to run will affect which ports to use,
|
||||
# set these up after the local overrides.
|
||||
# Configure XQueue interface to use our stub XQueue server
|
||||
XQUEUE_INTERFACE = {
|
||||
"url": "http://127.0.0.1:{0:d}".format(XQUEUE_PORT),
|
||||
"django_auth": {
|
||||
"username": "lms",
|
||||
"password": "***REMOVED***"
|
||||
},
|
||||
"basic_auth": ('anant', 'agarwal'),
|
||||
}
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
YOUTUBE_HOSTNAME = os.environ.get('BOK_CHOY_HOSTNAME', '127.0.0.1')
|
||||
YOUTUBE['API'] = "http://{0}:{1}/get_youtube_api/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['METADATA_URL'] = "http://{0}:{1}/test_youtube/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['TEXT_API']['url'] = "{0}:{1}/test_transcripts_youtube/".format(YOUTUBE_HOSTNAME, YOUTUBE_PORT)
|
||||
YOUTUBE['TEST_TIMEOUT'] = 1500
|
||||
|
||||
if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \
|
||||
FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \
|
||||
FEATURES.get('ENABLE_COURSE_DISCOVERY'):
|
||||
# Use MockSearchEngine as the search engine for test scenario
|
||||
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
|
||||
|
||||
# Generate a random UUID so that different runs of acceptance tests don't break each other
|
||||
import uuid
|
||||
SECRET_KEY = uuid.uuid4().hex
|
||||
|
||||
############################### PIPELINE #######################################
|
||||
|
||||
PIPELINE_ENABLED = False
|
||||
REQUIRE_DEBUG = True
|
||||
|
||||
# We want to make sure that any new migrations are run
|
||||
# see https://groups.google.com/forum/#!msg/django-developers/PWPj3etj3-U/kCl6pMsQYYoJ
|
||||
MIGRATION_MODULES = {}
|
||||
@@ -1,82 +0,0 @@
|
||||
"""
|
||||
This config file extends the test environment configuration
|
||||
so that we can run the lettuce acceptance tests.
|
||||
"""
|
||||
|
||||
# We intentionally define lots of variables that aren't used, and
|
||||
# want to import all variables from base settings files
|
||||
# pylint: disable=wildcard-import, unused-wildcard-import
|
||||
|
||||
import os
|
||||
|
||||
os.environ['EDXAPP_TEST_MONGO_HOST'] = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'edx.devstack.mongo')
|
||||
|
||||
# noinspection PyUnresolvedReferences
|
||||
from .acceptance import *
|
||||
|
||||
LETTUCE_HOST = os.environ['BOK_CHOY_HOSTNAME']
|
||||
SITE_NAME = '{}:{}'.format(LETTUCE_HOST, LETTUCE_SERVER_PORT)
|
||||
|
||||
update_module_store_settings(
|
||||
MODULESTORE,
|
||||
doc_store_settings={
|
||||
'db': 'acceptance_xmodule',
|
||||
'host': MONGO_HOST,
|
||||
'port': MONGO_PORT_NUM,
|
||||
'collection': 'acceptance_modulestore_%s' % seed(),
|
||||
},
|
||||
module_store_options={
|
||||
'fs_root': TEST_ROOT / "data",
|
||||
},
|
||||
default_store=os.environ.get('DEFAULT_STORE', 'draft'),
|
||||
)
|
||||
CONTENTSTORE = {
|
||||
'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore',
|
||||
'DOC_STORE_CONFIG': {
|
||||
'host': MONGO_HOST,
|
||||
'port': MONGO_PORT_NUM,
|
||||
'db': 'acceptance_xcontent_%s' % seed(),
|
||||
}
|
||||
}
|
||||
|
||||
TRACKING_BACKENDS.update({
|
||||
'mongo': {
|
||||
'ENGINE': 'track.backends.mongodb.MongoBackend',
|
||||
'OPTIONS': {
|
||||
'database': 'test',
|
||||
'collection': 'events',
|
||||
'host': [
|
||||
'edx.devstack.mongo'
|
||||
],
|
||||
'port': 27017
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update({
|
||||
'mongo': {
|
||||
'ENGINE': 'eventtracking.backends.mongodb.MongoBackend',
|
||||
'OPTIONS': {
|
||||
'database': 'track',
|
||||
'host': [
|
||||
'edx.devstack.mongo'
|
||||
],
|
||||
'port': 27017
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# Where to run: local, saucelabs, or grid
|
||||
LETTUCE_SELENIUM_CLIENT = os.environ.get('LETTUCE_SELENIUM_CLIENT', 'grid')
|
||||
SELENIUM_HOST = 'edx.devstack.{}'.format(LETTUCE_BROWSER)
|
||||
SELENIUM_PORT = os.environ.get('SELENIUM_PORT', '4444')
|
||||
|
||||
SELENIUM_GRID = {
|
||||
'URL': 'http://{}:{}/wd/hub'.format(SELENIUM_HOST, SELENIUM_PORT),
|
||||
'BROWSER': LETTUCE_BROWSER,
|
||||
}
|
||||
|
||||
# Point the URL used to test YouTube availability to our stub YouTube server
|
||||
YOUTUBE['API'] = "http://{}:{}/get_youtube_api/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
YOUTUBE['METADATA_URL'] = "http://{}:{}/test_youtube/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
YOUTUBE['TEXT_API']['url'] = "{}:{}/test_transcripts_youtube/".format(LETTUCE_HOST, YOUTUBE_PORT)
|
||||
@@ -357,7 +357,6 @@ BLOCK_STRUCTURES_SETTINGS['PRUNING_ACTIVE'] = True
|
||||
|
||||
# These ports are carefully chosen so that if the browser needs to
|
||||
# access them, they will be available through the SauceLabs SSH tunnel
|
||||
LETTUCE_SERVER_PORT = 8003
|
||||
XQUEUE_PORT = 8040
|
||||
YOUTUBE_PORT = 8031
|
||||
LTI_PORT = 8765
|
||||
@@ -551,8 +550,6 @@ COMPREHENSIVE_THEME_LOCALE_PATHS = [REPO_ROOT / "themes/conf/locale", ]
|
||||
|
||||
LMS_ROOT_URL = "http://localhost:8000"
|
||||
|
||||
# TODO (felipemontoya): This key is only needed during lettuce tests.
|
||||
# To be removed during https://openedx.atlassian.net/browse/DEPR-19
|
||||
FRONTEND_LOGOUT_URL = LMS_ROOT_URL + '/logout'
|
||||
|
||||
ECOMMERCE_API_URL = 'https://ecommerce.example.com/api/v2/'
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
"""
|
||||
Copy of the useful parts of nose.tools. This is only used for lettuce test
|
||||
utility functions, which neither use pytest nor have access to a TestCase
|
||||
instance. This module should be deleted once the last lettuce tests have
|
||||
been ported over to bok-choy.
|
||||
|
||||
Tracebacks should not descend into these functions.
|
||||
We define the ``__unittest`` symbol in their module namespace so unittest will
|
||||
skip them when printing tracebacks, just as it does for their corresponding
|
||||
methods in ``unittest`` proper.
|
||||
"""
|
||||
from __future__ import absolute_import
|
||||
import re
|
||||
import unittest
|
||||
|
||||
__all__ = []
|
||||
|
||||
# Use the same flag as unittest itself to prevent descent into these functions:
|
||||
__unittest = 1
|
||||
|
||||
# Expose assert* from unittest.TestCase
|
||||
# - give them pep8 style names
|
||||
caps = re.compile('([A-Z])')
|
||||
|
||||
|
||||
def pep8(name):
|
||||
return caps.sub(lambda m: '_' + m.groups()[0].lower(), name)
|
||||
|
||||
|
||||
class Dummy(unittest.TestCase):
|
||||
def noop(self):
|
||||
pass
|
||||
|
||||
|
||||
_t = Dummy('noop')
|
||||
|
||||
for at in [at for at in dir(_t) if at.startswith('assert') and '_' not in at]:
|
||||
pepd = pep8(at)
|
||||
vars()[pepd] = getattr(_t, at)
|
||||
__all__.append(pepd)
|
||||
|
||||
del Dummy
|
||||
del _t
|
||||
del pep8
|
||||
@@ -2,6 +2,5 @@
|
||||
paver commands
|
||||
"""
|
||||
from . import (
|
||||
assets, servers, docs, prereqs, quality, tests, js_test, i18n, bok_choy,
|
||||
acceptance_test, database
|
||||
assets, servers, docs, prereqs, quality, tests, js_test, i18n, bok_choy, database
|
||||
)
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"""
|
||||
Acceptance test tasks
|
||||
"""
|
||||
from __future__ import print_function
|
||||
from optparse import make_option
|
||||
|
||||
from paver.easy import cmdopts, needs
|
||||
|
||||
from pavelib.utils.passthrough_opts import PassthroughTask
|
||||
from pavelib.utils.test.suites import AcceptanceTestSuite
|
||||
from pavelib.utils.timer import timed
|
||||
|
||||
try:
|
||||
from pygments.console import colorize
|
||||
except ImportError:
|
||||
colorize = lambda color, text: text
|
||||
|
||||
__test__ = False # do not collect
|
||||
|
||||
|
||||
@needs(
|
||||
'pavelib.prereqs.install_prereqs',
|
||||
'pavelib.utils.test.utils.clean_reports_dir',
|
||||
)
|
||||
@cmdopts([
|
||||
("system=", "s", "System to act on"),
|
||||
("default-store=", "m", "Default modulestore to use for course creation"),
|
||||
("fasttest", "a", "Run without collectstatic"),
|
||||
make_option("--verbose", action="store_const", const=2, dest="verbosity"),
|
||||
make_option("-q", "--quiet", action="store_const", const=0, dest="verbosity"),
|
||||
make_option("-v", "--verbosity", action="count", dest="verbosity"),
|
||||
("default_store=", None, "deprecated in favor of default-store"),
|
||||
('extra_args=', 'e', 'deprecated, pass extra options directly in the paver commandline'),
|
||||
])
|
||||
@PassthroughTask
|
||||
@timed
|
||||
def test_acceptance(options, passthrough_options):
|
||||
"""
|
||||
Run the acceptance tests for either lms or cms
|
||||
"""
|
||||
opts = {
|
||||
'fasttest': getattr(options, 'fasttest', False),
|
||||
'system': getattr(options, 'system', None),
|
||||
'default_store': getattr(options, 'default_store', None),
|
||||
'verbosity': getattr(options, 'verbosity', 3),
|
||||
'extra_args': getattr(options, 'extra_args', ''),
|
||||
'pdb': getattr(options, 'pdb', False),
|
||||
'passthrough_options': passthrough_options,
|
||||
}
|
||||
|
||||
if opts['system'] not in ['cms', 'lms']:
|
||||
msg = colorize(
|
||||
'red',
|
||||
'No system specified, running tests for both cms and lms.'
|
||||
)
|
||||
print(msg)
|
||||
if opts['default_store'] not in ['draft', 'split']:
|
||||
msg = colorize(
|
||||
'red',
|
||||
'No modulestore specified, running tests for both draft and split.'
|
||||
)
|
||||
print(msg)
|
||||
|
||||
suite = AcceptanceTestSuite(u'{} acceptance'.format(opts['system']), **opts)
|
||||
suite.run()
|
||||
@@ -88,9 +88,8 @@ class Env(object):
|
||||
BOK_CHOY_REPORT_DIR = BOK_CHOY_REPORT_DIR / shard_str
|
||||
BOK_CHOY_LOG_DIR = BOK_CHOY_LOG_DIR / shard_str
|
||||
|
||||
# For the time being, stubs are used by both the bok-choy and lettuce acceptance tests
|
||||
# For this reason, the stubs package is currently located in the Django app called "terrain"
|
||||
# where other lettuce configuration is stored.
|
||||
# The stubs package is currently located in the Django app called "terrain"
|
||||
# from when they were used by both the bok-choy and lettuce (deprecated) acceptance tests
|
||||
BOK_CHOY_STUB_DIR = REPO_ROOT / "common" / "djangoapps" / "terrain"
|
||||
|
||||
# Directory that videos are served from
|
||||
|
||||
@@ -5,5 +5,4 @@ from .suite import TestSuite
|
||||
from .pytest_suite import PytestSuite, SystemTestSuite, LibTestSuite
|
||||
from .python_suite import PythonTestSuite
|
||||
from .js_suite import JsTestSuite, JestSnapshotTestSuite
|
||||
from .acceptance_suite import AcceptanceTestSuite
|
||||
from .bokchoy_suite import BokChoyTestSuite, Pa11yCrawler
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
"""
|
||||
Acceptance test suite
|
||||
"""
|
||||
from os import environ
|
||||
from paver.easy import sh, call_task, task
|
||||
from pavelib.utils.test import utils as test_utils
|
||||
from pavelib.utils.test.suites.suite import TestSuite
|
||||
from pavelib.utils.envs import Env
|
||||
from pavelib.utils.timer import timed
|
||||
|
||||
__test__ = False # do not collect
|
||||
|
||||
|
||||
DBS = {
|
||||
'default': Env.REPO_ROOT / 'test_root/db/test_edx.db',
|
||||
'student_module_history': Env.REPO_ROOT / 'test_root/db/test_student_module_history.db'
|
||||
}
|
||||
DB_CACHES = {
|
||||
'default': Env.REPO_ROOT / 'common/test/db_cache/lettuce.db',
|
||||
'student_module_history': Env.REPO_ROOT / 'common/test/db_cache/lettuce_student_module_history.db'
|
||||
}
|
||||
|
||||
|
||||
@task
|
||||
@timed
|
||||
def setup_acceptance_db():
|
||||
"""
|
||||
TODO: Improve the following
|
||||
|
||||
Since the CMS depends on the existence of some database tables
|
||||
that are now in common but used to be in LMS (Role/Permissions for Forums)
|
||||
we need to create/migrate the database tables defined in the LMS.
|
||||
We might be able to address this by moving out the migrations from
|
||||
lms/django_comment_client, but then we'd have to repair all the existing
|
||||
migrations from the upgrade tables in the DB.
|
||||
But for now for either system (lms or cms), use the lms
|
||||
definitions to sync and migrate.
|
||||
"""
|
||||
|
||||
for db in DBS:
|
||||
if DBS[db].isfile():
|
||||
# Since we are using SQLLite, we can reset the database by deleting it on disk.
|
||||
DBS[db].remove()
|
||||
|
||||
settings = 'acceptance_docker' if Env.USING_DOCKER else 'acceptance'
|
||||
if all(DB_CACHES[cache].isfile() for cache in DB_CACHES):
|
||||
# To speed up migrations, we check for a cached database file and start from that.
|
||||
# The cached database file should be checked into the repo
|
||||
|
||||
# Copy the cached database to the test root directory
|
||||
for db_alias in DBS:
|
||||
sh(u"cp {db_cache} {db}".format(db_cache=DB_CACHES[db_alias], db=DBS[db_alias]))
|
||||
|
||||
# Run migrations to update the db, starting from its cached state
|
||||
for db_alias in sorted(DBS):
|
||||
# pylint: disable=line-too-long
|
||||
sh(u"./manage.py lms --settings {} migrate --traceback --noinput --fake-initial --database {}".format(settings, db_alias))
|
||||
sh(u"./manage.py cms --settings {} migrate --traceback --noinput --fake-initial --database {}".format(settings, db_alias))
|
||||
else:
|
||||
# If no cached database exists, migrate then create the cache
|
||||
for db_alias in sorted(DBS.keys()):
|
||||
sh(u"./manage.py lms --settings {} migrate --traceback --noinput --database {}".format(settings, db_alias))
|
||||
sh(u"./manage.py cms --settings {} migrate --traceback --noinput --database {}".format(settings, db_alias))
|
||||
|
||||
# Create the cache if it doesn't already exist
|
||||
for db_alias in DBS.keys():
|
||||
sh(u"cp {db} {db_cache}".format(db_cache=DB_CACHES[db_alias], db=DBS[db_alias]))
|
||||
|
||||
|
||||
class AcceptanceTest(TestSuite):
|
||||
"""
|
||||
A class for running lettuce acceptance tests.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AcceptanceTest, self).__init__(*args, **kwargs)
|
||||
self.report_dir = Env.REPORT_DIR / 'acceptance'
|
||||
self.fasttest = kwargs.get('fasttest', False)
|
||||
self.system = kwargs.get('system')
|
||||
self.default_store = kwargs.get('default_store')
|
||||
self.extra_args = kwargs.get('extra_args', '')
|
||||
self.settings = 'acceptance_docker' if Env.USING_DOCKER else 'acceptance'
|
||||
|
||||
def __enter__(self):
|
||||
super(AcceptanceTest, self).__enter__()
|
||||
self.report_dir.makedirs_p()
|
||||
if not self.fasttest:
|
||||
self._update_assets()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
super(AcceptanceTest, self).__exit__(exc_type, exc_value, traceback)
|
||||
test_utils.clean_mongo()
|
||||
|
||||
@property
|
||||
def cmd(self):
|
||||
|
||||
lettuce_host = ['LETTUCE_HOST={}'.format(Env.SERVER_HOST)] if Env.USING_DOCKER else []
|
||||
report_file = self.report_dir / "{}.xml".format(self.system)
|
||||
report_args = [u"--xunit-file {}".format(report_file)]
|
||||
return lettuce_host + [
|
||||
# set DBUS_SESSION_BUS_ADDRESS to avoid hangs on Chrome
|
||||
"DBUS_SESSION_BUS_ADDRESS=/dev/null",
|
||||
"DEFAULT_STORE={}".format(self.default_store),
|
||||
"./manage.py",
|
||||
self.system,
|
||||
"--settings={}".format(self.settings),
|
||||
"harvest",
|
||||
"--traceback",
|
||||
"--debug-mode",
|
||||
"--verbosity={}".format(self.verbosity),
|
||||
] + report_args + [
|
||||
self.extra_args
|
||||
] + self.passthrough_options
|
||||
|
||||
def _update_assets(self):
|
||||
"""
|
||||
Internal helper method to manage asset compilation
|
||||
"""
|
||||
args = [self.system, '--settings={}'.format(self.settings)]
|
||||
call_task('pavelib.assets.update_assets', args=args)
|
||||
|
||||
|
||||
class AcceptanceTestSuite(TestSuite):
|
||||
"""
|
||||
A class for running lettuce acceptance tests.
|
||||
"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(AcceptanceTestSuite, self).__init__(*args, **kwargs)
|
||||
self.root = 'acceptance'
|
||||
self.fasttest = kwargs.get('fasttest', False)
|
||||
|
||||
# Set the environment so that webpack understands where to compile its resources.
|
||||
# This setting is expected in other environments, so we are setting it for the
|
||||
# bok-choy test run.
|
||||
environ['EDX_PLATFORM_SETTINGS'] = 'test_static_optimized'
|
||||
|
||||
if kwargs.get('system'):
|
||||
systems = [kwargs['system']]
|
||||
else:
|
||||
systems = ['lms', 'cms']
|
||||
|
||||
if kwargs.get('default_store'):
|
||||
stores = [kwargs['default_store']]
|
||||
else:
|
||||
# TODO fix Acceptance tests with Split (LMS-11300)
|
||||
# stores = ['split', 'draft']
|
||||
stores = ['draft']
|
||||
|
||||
self.subsuites = []
|
||||
for system in systems:
|
||||
for default_store in stores:
|
||||
kwargs['system'] = system
|
||||
kwargs['default_store'] = default_store
|
||||
self.subsuites.append(AcceptanceTest(u'{} acceptance using {}'.format(system, default_store), **kwargs))
|
||||
|
||||
def __enter__(self):
|
||||
super(AcceptanceTestSuite, self).__enter__()
|
||||
if not (self.fasttest or self.skip_clean):
|
||||
test_utils.clean_test_files()
|
||||
|
||||
if not self.fasttest:
|
||||
setup_acceptance_db()
|
||||
@@ -28,9 +28,6 @@ mysqlclient<1.5
|
||||
# Can be removed when we get to Python 3.
|
||||
pylint-plugin-utils==0.3
|
||||
|
||||
# Browser driver used by lettuce - pinned because splinter==0.10.0 breaks lettuce tests. EDUCATOR-3795
|
||||
splinter==0.9.0
|
||||
|
||||
# transifex-client 0.13.6 requires urllib3<1.24, but requests will pull in urllib3==1.24 (https://github.com/transifex/transifex-client/pull/241/files)
|
||||
urllib3<1.24
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc8685
|
||||
-e git+https://github.com/edx/DoneXBlock.git@01a14f3bd80ae47dd08cdbbe2f88f3eb88d00fba#egg=done-xblock
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme
|
||||
git+https://github.com/mitodl/edx-sga.git@3828ba9e413080a81b907a3381e5ffa05e063f81#egg=edx-sga==0.8.3
|
||||
git+https://github.com/edx/lettuce.git@7a04591c78ac56dac3eb3e91ca94b15cce844133#egg=lettuce==0.2.23+edx.1
|
||||
git+https://github.com/edx/xblock-lti-consumer.git@v1.1.8#egg=lti_consumer-xblock==1.1.8
|
||||
git+https://github.com/edx/MongoDBProxy.git@25b99097615bda06bd7cdfe5669ed80dc2a7fed0#egg=MongoDBProxy==0.1.0
|
||||
-e .
|
||||
@@ -155,13 +154,11 @@ entrypoints==0.3
|
||||
enum34==1.1.6
|
||||
event-tracking==0.2.8
|
||||
execnet==1.6.0
|
||||
extras==1.0.0
|
||||
factory_boy==2.8.1
|
||||
faker==1.0.5
|
||||
feedparser==5.1.3
|
||||
filelock==3.0.10
|
||||
firebase-token-generator==1.3.2
|
||||
fixtures==3.0.0
|
||||
flake8-polyfill==1.0.2
|
||||
flake8==3.7.7
|
||||
flask==1.0.2
|
||||
@@ -172,7 +169,6 @@ funcsigs==1.0.2
|
||||
functools32==3.2.3.post2 ; python_version == "2.7"
|
||||
future==0.17.1
|
||||
futures==3.2.0 ; python_version == "2.7"
|
||||
fuzzywuzzy==0.17.0
|
||||
geoip2==2.9.0
|
||||
glob2==0.6
|
||||
gunicorn==19.0
|
||||
@@ -199,7 +195,6 @@ lazy-object-proxy==1.3.1
|
||||
lazy==1.1
|
||||
lepl==5.1.3
|
||||
libsass==0.10.0
|
||||
linecache2==1.0.0
|
||||
loremipsum==1.0.5
|
||||
lxml==3.8.0
|
||||
mailsnake==1.6.4
|
||||
@@ -279,10 +274,8 @@ pytest==4.4.1
|
||||
python-dateutil==2.4.0
|
||||
python-levenshtein==0.12.0
|
||||
python-memcached==1.59
|
||||
python-mimeparse==1.6.0
|
||||
python-openid==2.2.5 ; python_version == "2.7"
|
||||
python-slugify==1.2.6
|
||||
python-subunit==1.3.0
|
||||
python-swiftclient==3.7.0
|
||||
python3-saml==1.5.0
|
||||
pytz==2019.1
|
||||
@@ -320,25 +313,20 @@ sortedcontainers==2.1.0
|
||||
soupsieve==1.9.1
|
||||
sphinx==1.8.5
|
||||
sphinxcontrib-websupport==1.1.0 # via sphinx
|
||||
splinter==0.9.0
|
||||
sqlparse==0.3.0
|
||||
stevedore==1.30.1
|
||||
sure==1.4.11
|
||||
sympy==1.4
|
||||
testfixtures==6.7.0
|
||||
testtools==2.3.0
|
||||
text-unidecode==1.2
|
||||
tincan==0.0.5
|
||||
toml==0.10.0
|
||||
tox-battery==0.5.1
|
||||
tox==3.9.0
|
||||
traceback2==1.4.0
|
||||
transifex-client==0.13.6
|
||||
twisted==19.2.0
|
||||
typing==3.6.6
|
||||
unicodecsv==0.14.1
|
||||
unidecode==1.0.23
|
||||
unittest2==1.1.0
|
||||
uritemplate==3.0.0
|
||||
urllib3==1.23
|
||||
urlobject==2.4.3
|
||||
|
||||
@@ -45,11 +45,7 @@ pytest-xdist # Parallel execution of tests on multiple CPU cores or
|
||||
radon # Calculates cyclomatic complexity of Python code (code quality utility)
|
||||
selenium # Browser automation library, used for acceptance tests
|
||||
singledispatch # Backport of functools.singledispatch from Python 3.4+, used in tests of XBlock rendering
|
||||
splinter # Browser driver used by lettuce
|
||||
testfixtures # Provides a LogCapture utility used by several tests
|
||||
tox # virtualenv management for tests
|
||||
tox-battery # Makes tox aware of requirements file changes
|
||||
transifex-client # Command-line interface for the Transifex localization service
|
||||
|
||||
# Deprecated acceptance testing framework
|
||||
-e git+https://github.com/edx/lettuce.git@7a04591c78ac56dac3eb3e91ca94b15cce844133#egg=lettuce==0.2.23+edx.1
|
||||
|
||||
@@ -20,7 +20,6 @@ git+https://github.com/edx/django-rest-framework.git@1ceda7c086fddffd1c440cc8685
|
||||
-e git+https://github.com/edx/DoneXBlock.git@01a14f3bd80ae47dd08cdbbe2f88f3eb88d00fba#egg=done-xblock
|
||||
-e git+https://github.com/jazkarta/edx-jsme.git@690dbf75441fa91c7c4899df0b83d77f7deb5458#egg=edx-jsme
|
||||
git+https://github.com/mitodl/edx-sga.git@3828ba9e413080a81b907a3381e5ffa05e063f81#egg=edx-sga==0.8.3
|
||||
git+https://github.com/edx/lettuce.git@7a04591c78ac56dac3eb3e91ca94b15cce844133#egg=lettuce==0.2.23+edx.1
|
||||
git+https://github.com/edx/xblock-lti-consumer.git@v1.1.8#egg=lti_consumer-xblock==1.1.8
|
||||
git+https://github.com/edx/MongoDBProxy.git@25b99097615bda06bd7cdfe5669ed80dc2a7fed0#egg=MongoDBProxy==0.1.0
|
||||
-e .
|
||||
@@ -44,7 +43,7 @@ anyjson==0.3.3
|
||||
apipkg==1.5 # via execnet
|
||||
appdirs==1.4.3
|
||||
argh==0.26.2
|
||||
argparse==1.4.0 # via caniusepython3, unittest2
|
||||
argparse==1.4.0 # via caniusepython3
|
||||
asn1crypto==0.24.0
|
||||
astroid==1.5.3 # via pylint, pylint-celery
|
||||
atomicwrites==1.3.0 # via pytest
|
||||
@@ -150,13 +149,11 @@ entrypoints==0.3 # via flake8
|
||||
enum34==1.1.6
|
||||
event-tracking==0.2.8
|
||||
execnet==1.6.0 # via pytest-xdist
|
||||
extras==1.0.0 # via python-subunit, testtools
|
||||
factory_boy==2.8.1
|
||||
faker==1.0.5 # via factory-boy
|
||||
feedparser==5.1.3
|
||||
filelock==3.0.10 # via tox
|
||||
firebase-token-generator==1.3.2
|
||||
fixtures==3.0.0 # via testtools
|
||||
flake8-polyfill==1.0.2 # via radon
|
||||
flake8==3.7.7 # via flake8-polyfill
|
||||
flask==1.0.2 # via moto
|
||||
@@ -167,7 +164,6 @@ funcsigs==1.0.2 # via pytest
|
||||
functools32==3.2.3.post2 ; python_version == "2.7" # via flake8, parsel
|
||||
future==0.17.1
|
||||
futures==3.2.0 ; python_version == "2.7"
|
||||
fuzzywuzzy==0.17.0
|
||||
geoip2==2.9.0
|
||||
glob2==0.6
|
||||
gunicorn==19.0
|
||||
@@ -193,7 +189,6 @@ lazy-object-proxy==1.3.1 # via astroid
|
||||
lazy==1.1
|
||||
lepl==5.1.3
|
||||
libsass==0.10.0
|
||||
linecache2==1.0.0 # via traceback2
|
||||
loremipsum==1.0.5
|
||||
lxml==3.8.0
|
||||
mailsnake==1.6.4
|
||||
@@ -270,10 +265,8 @@ pytest==4.4.1
|
||||
python-dateutil==2.4.0
|
||||
python-levenshtein==0.12.0
|
||||
python-memcached==1.59
|
||||
python-mimeparse==1.6.0 # via testtools
|
||||
python-openid==2.2.5 ; python_version == "2.7"
|
||||
python-slugify==1.2.6 # via code-annotations, transifex-client
|
||||
python-subunit==1.3.0
|
||||
python-swiftclient==3.7.0
|
||||
python3-saml==1.5.0
|
||||
pytz==2019.1
|
||||
@@ -307,25 +300,20 @@ social-auth-core==1.7.0
|
||||
sorl-thumbnail==12.3
|
||||
sortedcontainers==2.1.0
|
||||
soupsieve==1.9.1
|
||||
splinter==0.9.0
|
||||
sqlparse==0.3.0
|
||||
stevedore==1.30.1
|
||||
sure==1.4.11
|
||||
sympy==1.4
|
||||
testfixtures==6.7.0
|
||||
testtools==2.3.0 # via fixtures, python-subunit
|
||||
text-unidecode==1.2 # via faker
|
||||
tincan==0.0.5
|
||||
toml==0.10.0 # via tox
|
||||
tox-battery==0.5.1
|
||||
tox==3.9.0
|
||||
traceback2==1.4.0 # via testtools, unittest2
|
||||
transifex-client==0.13.6
|
||||
twisted==19.2.0 # via scrapy
|
||||
typing==3.6.6 # via flake8
|
||||
unicodecsv==0.14.1
|
||||
unidecode==1.0.23 # via python-slugify
|
||||
unittest2==1.1.0 # via testtools
|
||||
uritemplate==3.0.0
|
||||
urllib3==1.23
|
||||
urlobject==2.4.3 # via pa11ycrawler
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
def runLettuceTests() {
|
||||
// Determine git refspec, branch, and clone type
|
||||
if (env.ghprbActualCommit) {
|
||||
git_branch = "${ghprbActualCommit}"
|
||||
git_refspec = "+refs/pull/${ghprbPullId}/*:refs/remotes/origin/pr/${ghprbPullId}/*"
|
||||
} else {
|
||||
git_branch = "${BRANCH_NAME}"
|
||||
git_refspec = "+refs/heads/${BRANCH_NAME}:refs/remotes/origin/${BRANCH_NAME}"
|
||||
}
|
||||
sshagent(credentials: ['jenkins-worker', 'jenkins-worker-pem'], ignoreMissing: true) {
|
||||
checkout changelog: false, poll: false, scm: [$class: 'GitSCM', branches: [[name: git_branch]],
|
||||
doGenerateSubmoduleConfigurations: false, extensions: [[$class: 'CloneOption', honorRefspec: true,
|
||||
noTags: true, shallow: true]], submoduleCfg: [], userRemoteConfigs: [[credentialsId: 'jenkins-worker',
|
||||
refspec: git_refspec, url: "git@github.com:edx/${REPO_NAME}.git"]]]
|
||||
sh 'bash scripts/all-tests.sh'
|
||||
}
|
||||
}
|
||||
|
||||
def lettuceTestCleanup() {
|
||||
archiveArtifacts allowEmptyArchive: true, artifacts: 'test_root/log/**/*.log,*.log'
|
||||
junit '**/reports/acceptance/*.xml'
|
||||
}
|
||||
|
||||
pipeline {
|
||||
agent { label "jenkins-worker" }
|
||||
options {
|
||||
timestamps()
|
||||
timeout(60)
|
||||
}
|
||||
stages {
|
||||
stage('Mark build as pending on Github') {
|
||||
when {
|
||||
// Only run github-build-status for master builds
|
||||
expression { env.ghprbActualCommit == null }
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
commit_sha = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
|
||||
build job: 'github-build-status',
|
||||
parameters: [
|
||||
string(name: 'GIT_SHA', value: commit_sha),
|
||||
string(name: 'GITHUB_ORG', value: 'edx'),
|
||||
string(name: 'GITHUB_REPO', value: "${REPO_NAME}"),
|
||||
string(name: 'TARGET_URL', value: "${BUILD_URL}"),
|
||||
string(name: 'DESCRIPTION', value: 'Pending'),
|
||||
string(name: 'CONTEXT', value: "${GITHUB_CONTEXT}"),
|
||||
string(name: 'CREATE_DEPLOYMENT', value: 'false'),
|
||||
string(name: 'BUILD_STATUS', value: 'pending')
|
||||
],
|
||||
propagate: false, wait: false
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Run Tests') {
|
||||
parallel {
|
||||
stage("lms-acceptance") {
|
||||
agent { label "jenkins-worker" }
|
||||
environment {
|
||||
TEST_SUITE = "lms-acceptance"
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
runLettuceTests()
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
lettuceTestCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stage("cms-acceptance") {
|
||||
agent { label "jenkins-worker" }
|
||||
environment {
|
||||
TEST_SUITE = "cms-acceptance"
|
||||
}
|
||||
steps {
|
||||
script {
|
||||
runLettuceTests()
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
script {
|
||||
lettuceTestCleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
always {
|
||||
script{
|
||||
if (env.ghprbPullId != null) {
|
||||
// For PR jobs, run the edx-platform-test-notifier for PR reporting
|
||||
build job: 'edx-platform-test-notifier', parameters: [string(name: 'PR_NUMBER', value: "${ghprbPullId}")], wait: false
|
||||
} else {
|
||||
// For master jobs run github-build-status and report to slack when necessary
|
||||
if (currentBuild.currentResult == "SUCCESS") {
|
||||
create_deployment = "true"
|
||||
build_status = "success"
|
||||
build_description = "Build Passed"
|
||||
}
|
||||
else {
|
||||
create_deployment = "false"
|
||||
build_status = "failure"
|
||||
build_description = "Build Failed"
|
||||
}
|
||||
|
||||
commit_sha = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
|
||||
build job: 'github-build-status',
|
||||
parameters: [
|
||||
string(name: 'GIT_SHA', value: commit_sha),
|
||||
string(name: 'GITHUB_ORG', value: 'edx'),
|
||||
string(name: 'GITHUB_REPO', value: "${REPO_NAME}"),
|
||||
string(name: 'TARGET_URL', value: "${BUILD_URL}"),
|
||||
string(name: 'DESCRIPTION', value: build_description),
|
||||
string(name: 'CONTEXT', value: "${GITHUB_CONTEXT}"),
|
||||
string(name: 'CREATE_DEPLOYMENT', value: create_deployment),
|
||||
string(name: 'BUILD_STATUS', value: build_status)
|
||||
],
|
||||
propagate: false, wait: false
|
||||
|
||||
if (currentBuild.currentResult != "SUCCESS"){
|
||||
slackSend "`${JOB_NAME}` #${BUILD_NUMBER}: ${currentBuild.currentResult} after ${currentBuild.durationString.replace(' and counting', '')}\n${BUILD_URL}"
|
||||
|
||||
email_body = "See: <${BUILD_URL}>\n\nChanges:\n"
|
||||
change_sets = currentBuild.changeSets
|
||||
for (int j = 0; j < change_sets.size(); j++) {
|
||||
change_set_items = change_sets[j].items
|
||||
for (int k = 0; k < change_set_items.length; k++) {
|
||||
item = change_set_items[k]
|
||||
email_body = email_body + "\n Commit: ${item.commitId} by ${item.author}: ${item.msg}"
|
||||
}
|
||||
}
|
||||
emailext body: email_body,
|
||||
subject: "Build failed in Jenkins: ${JOB_NAME} #${BUILD_NUMBER}", to: 'testeng@edx.org'
|
||||
} else if (currentBuild.currentResult == "SUCCESS" && currentBuild.previousBuild.currentResult != "SUCCESS") {
|
||||
slackSend "`${JOB_NAME}` #${BUILD_NUMBER}: Back to normal after ${currentBuild.durationString.replace(' and counting', '')}\n${BUILD_URL}"
|
||||
emailext body: "See <${BUILD_URL}>",
|
||||
subject: "Jenkins Build is back to normal: ${JOB_NAME} #${BUILD_NUMBER}", to: 'testeng@edx.org'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,6 @@ pattern_fragments = [
|
||||
r'/testutils\.py', # testutils.py
|
||||
r'/tests$', # tests/__init__.py
|
||||
r'conftest\.py', # conftest.py
|
||||
r'/envs/acceptance\.py', # cms/envs/acceptance.py, lms/envs/acceptance.py
|
||||
r'/envs/acceptance_docker\.py', # cms/envs/acceptance.py, lms/envs/acceptance.py
|
||||
r'/factories\.py', # factories.py
|
||||
r'^terrain', # terrain/*
|
||||
r'/setup_models_to_send_test_emails\.py', # setup_models_to_send_test_emails management command
|
||||
|
||||
@@ -20,10 +20,6 @@ set -e
|
||||
# - "commonlib-unit": Run Python unit tests from the common/lib directory
|
||||
# - "commonlib-js-unit": Run the JavaScript tests and the Python unit
|
||||
# tests from the common/lib directory
|
||||
# - "lms-acceptance": Run the acceptance (Selenium/Lettuce) tests for
|
||||
# the LMS
|
||||
# - "cms-acceptance": Run the acceptance (Selenium/Lettuce) tests for
|
||||
# Studio
|
||||
# - "bok-choy": Run acceptance tests that use the bok-choy framework
|
||||
#
|
||||
# `SHARD` is a number indicating which subset of the tests to build.
|
||||
@@ -176,14 +172,6 @@ case "$TEST_SUITE" in
|
||||
exit $EXIT
|
||||
;;
|
||||
|
||||
"lms-acceptance")
|
||||
$TOX paver test_acceptance -s lms -vvv --with-xunit
|
||||
;;
|
||||
|
||||
"cms-acceptance")
|
||||
$TOX paver test_acceptance -s cms -vvv --with-xunit
|
||||
;;
|
||||
|
||||
"bok-choy")
|
||||
|
||||
PAVER_ARGS="-n $NUMBER_OF_BOKCHOY_THREADS"
|
||||
|
||||
Reference in New Issue
Block a user