Merge pull request #20223 from edx/youngstrom/deprecate-lettuce

Remove lettuce infrastructure
This commit is contained in:
Michael Youngstrom
2019-04-18 11:54:57 -04:00
committed by GitHub
56 changed files with 6 additions and 6279 deletions

View File

@@ -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()

View File

@@ -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')

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
# """

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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))

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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;")

View File

@@ -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

View File

@@ -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.

View File

@@ -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
---------------------

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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)

View File

@@ -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"

View File

@@ -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]))

View File

@@ -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

View File

@@ -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)

View File

@@ -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(

View File

@@ -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])

View File

@@ -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')

View File

@@ -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 = {}

View File

@@ -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)

View File

@@ -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/'

View File

@@ -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

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'
}
}
}
}
}
}

View File

@@ -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

View File

@@ -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"