').append($element.clone()).html();
- console.log("Block #{elementTag} is missing data-runtime, data-runtime-version or data-init, and can't be initialized")
block = {}
+ if runtime? or version? or initFnName?
+ elementTag = $('
').append($element.clone()).html();
+ console.log("Block #{elementTag} is missing data-runtime, data-runtime-version or data-init, and can't be initialized")
block.element = element
block.name = $element.data("name")
diff --git a/common/static/js/spec_helpers/ajax_helpers.js b/common/static/js/spec_helpers/ajax_helpers.js
index 0b2769be5708..3e51f91fd229 100644
--- a/common/static/js/spec_helpers/ajax_helpers.js
+++ b/common/static/js/spec_helpers/ajax_helpers.js
@@ -77,13 +77,20 @@ define(['sinon', 'underscore'], function(sinon, _) {
JSON.stringify(jsonResponse));
};
- respondWithError = function(requests, requestIndex) {
+ respondWithError = function(requests, statusCode, jsonResponse, requestIndex) {
if (_.isUndefined(requestIndex)) {
requestIndex = requests.length - 1;
}
- requests[requestIndex].respond(500,
+ if (_.isUndefined(statusCode)) {
+ statusCode = 500;
+ }
+ if (_.isUndefined(jsonResponse)) {
+ jsonResponse = {};
+ }
+ requests[requestIndex].respond(statusCode,
{ 'Content-Type': 'application/json' },
- JSON.stringify({ }));
+ JSON.stringify(jsonResponse)
+ );
};
respondToDelete = function(requests, requestIndex) {
diff --git a/common/test/acceptance/fixtures/base.py b/common/test/acceptance/fixtures/base.py
new file mode 100644
index 000000000000..059f34bfce4b
--- /dev/null
+++ b/common/test/acceptance/fixtures/base.py
@@ -0,0 +1,184 @@
+import re
+import requests
+import json
+from lazy import lazy
+
+from . import STUDIO_BASE_URL
+
+
+class StudioApiLoginError(Exception):
+ """
+ Error occurred while logging in to the Studio API.
+ """
+ pass
+
+
+class StudioApiFixture(object):
+ """
+ Base class for fixtures that use the Studio restful API.
+ """
+ def __init__(self):
+ # Info about the auto-auth user used to create the course/library.
+ self.user = {}
+
+ @lazy
+ def session(self):
+ """
+ Log in as a staff user, then return a `requests` `session` object for the logged in user.
+ Raises a `StudioApiLoginError` if the login fails.
+ """
+ # Use auto-auth to retrieve the session for a logged in user
+ session = requests.Session()
+ response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
+
+ # Return the session from the request
+ if response.ok:
+ # auto_auth returns information about the newly created user
+ # capture this so it can be used by by the testcases.
+ user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
+ '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)'))
+ user_matches = re.match(user_pattern, response.text)
+ if user_matches:
+ self.user = user_matches.groupdict()
+
+ return session
+
+ else:
+ msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
+ raise StudioApiLoginError(msg)
+
+ @lazy
+ def session_cookies(self):
+ """
+ Log in as a staff user, then return the cookies for the session (as a dict)
+ Raises a `StudioApiLoginError` if the login fails.
+ """
+ return {key: val for key, val in self.session.cookies.items()}
+
+ @lazy
+ def headers(self):
+ """
+ Default HTTP headers dict.
+ """
+ return {
+ 'Content-type': 'application/json',
+ 'Accept': 'application/json',
+ 'X-CSRFToken': self.session_cookies.get('csrftoken', '')
+ }
+
+
+class XBlockContainerFixture(StudioApiFixture):
+ FixtureError = None
+
+ def __init__(self):
+ self.children = []
+ super(XBlockContainerFixture, self).__init__()
+
+ def add_children(self, *args):
+ """
+ Add children XBlock to the container.
+ Each item in `args` is an `XBlockFixtureDesc` object.
+
+ Returns the fixture to allow chaining.
+ """
+ self.children.extend(args)
+ return self
+
+ def _create_xblock_children(self, parent_loc, xblock_descriptions):
+ """
+ Recursively create XBlock children.
+ """
+ for desc in xblock_descriptions:
+ loc = self.create_xblock(parent_loc, desc)
+ self._create_xblock_children(loc, desc.children)
+
+ def create_xblock(self, parent_loc, xblock_desc):
+ """
+ Create an XBlock with `parent_loc` (the location of the parent block)
+ and `xblock_desc` (an `XBlockFixtureDesc` instance).
+ """
+ create_payload = {
+ 'category': xblock_desc.category,
+ 'display_name': xblock_desc.display_name,
+ }
+
+ if parent_loc is not None:
+ create_payload['parent_locator'] = parent_loc
+
+ # Create the new XBlock
+ response = self.session.post(
+ STUDIO_BASE_URL + '/xblock/',
+ data=json.dumps(create_payload),
+ headers=self.headers,
+ )
+
+ if not response.ok:
+ msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
+ raise self.FixtureError(msg)
+
+ try:
+ loc = response.json().get('locator')
+ xblock_desc.locator = loc
+ except ValueError:
+ raise self.FixtureError("Could not decode JSON from '{0}'".format(response.content))
+
+ # Configure the XBlock
+ response = self.session.post(
+ STUDIO_BASE_URL + '/xblock/' + loc,
+ data=xblock_desc.serialize(),
+ headers=self.headers,
+ )
+
+ if response.ok:
+ return loc
+ else:
+ raise self.FixtureError("Could not update {0}. Status code: {1}".format(xblock_desc, response.status_code))
+
+ def _update_xblock(self, locator, data):
+ """
+ Update the xblock at `locator`.
+ """
+ # Create the new XBlock
+ response = self.session.put(
+ "{}/xblock/{}".format(STUDIO_BASE_URL, locator),
+ data=json.dumps(data),
+ headers=self.headers,
+ )
+
+ if not response.ok:
+ msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
+ raise self.FixtureError(msg)
+
+ def _encode_post_dict(self, post_dict):
+ """
+ Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
+ """
+ return json.dumps({
+ k: v.encode('utf-8') if isinstance(v, basestring) else v
+ for k, v in post_dict.items()
+ })
+
+ def get_nested_xblocks(self, category=None):
+ """
+ Return a list of nested XBlocks for the container that can be filtered by
+ category.
+ """
+ xblocks = self._get_nested_xblocks(self)
+ if category:
+ xblocks = filter(lambda x: x.category == category, xblocks)
+ return xblocks
+
+ def _get_nested_xblocks(self, xblock_descriptor):
+ """
+ Return a list of nested XBlocks for the container.
+ """
+ xblocks = list(xblock_descriptor.children)
+ for child in xblock_descriptor.children:
+ xblocks.extend(self._get_nested_xblocks(child))
+ return xblocks
+
+ def _publish_xblock(self, locator):
+ """
+ Publish the xblock at `locator`.
+ """
+ self._update_xblock(locator, {'publish': 'make_public'})
diff --git a/common/test/acceptance/fixtures/course.py b/common/test/acceptance/fixtures/course.py
index 69836fbee048..d1c68d7b9b5f 100644
--- a/common/test/acceptance/fixtures/course.py
+++ b/common/test/acceptance/fixtures/course.py
@@ -4,77 +4,17 @@
import mimetypes
import json
-import re
+
import datetime
-import requests
+
from textwrap import dedent
from collections import namedtuple
from path import path
-from lazy import lazy
+
from opaque_keys.edx.keys import CourseKey
from . import STUDIO_BASE_URL
-
-
-class StudioApiLoginError(Exception):
- """
- Error occurred while logging in to the Studio API.
- """
- pass
-
-
-class StudioApiFixture(object):
- """
- Base class for fixtures that use the Studio restful API.
- """
- def __init__(self):
- # Info about the auto-auth user used to create the course.
- self.user = {}
-
- @lazy
- def session(self):
- """
- Log in as a staff user, then return a `requests` `session` object for the logged in user.
- Raises a `StudioApiLoginError` if the login fails.
- """
- # Use auto-auth to retrieve the session for a logged in user
- session = requests.Session()
- response = session.get(STUDIO_BASE_URL + "/auto_auth?staff=true")
-
- # Return the session from the request
- if response.ok:
- # auto_auth returns information about the newly created user
- # capture this so it can be used by by the testcases.
- user_pattern = re.compile('Logged in user {0} \({1}\) with password {2} and user_id {3}'.format(
- '(?P\S+)', '(?P[^\)]+)', '(?P\S+)', '(?P\d+)'))
- user_matches = re.match(user_pattern, response.text)
- if user_matches:
- self.user = user_matches.groupdict()
-
- return session
-
- else:
- msg = "Could not log in to use Studio restful API. Status code: {0}".format(response.status_code)
- raise StudioApiLoginError(msg)
-
- @lazy
- def session_cookies(self):
- """
- Log in as a staff user, then return the cookies for the session (as a dict)
- Raises a `StudioApiLoginError` if the login fails.
- """
- return {key: val for key, val in self.session.cookies.items()}
-
- @lazy
- def headers(self):
- """
- Default HTTP headers dict.
- """
- return {
- 'Content-type': 'application/json',
- 'Accept': 'application/json',
- 'X-CSRFToken': self.session_cookies.get('csrftoken', '')
- }
+from .base import XBlockContainerFixture
class XBlockFixtureDesc(object):
@@ -105,7 +45,7 @@ def __init__(self, category, display_name, data=None, metadata=None, grader_type
def add_children(self, *args):
"""
Add child XBlocks to this XBlock.
- Each item in `args` is an `XBlockFixtureDescriptor` object.
+ Each item in `args` is an `XBlockFixtureDesc` object.
Returns the `xblock_desc` instance to allow chaining.
"""
@@ -161,7 +101,7 @@ class CourseFixtureError(Exception):
pass
-class CourseFixture(StudioApiFixture):
+class CourseFixture(XBlockContainerFixture):
"""
Fixture for ensuring that a course exists.
@@ -169,6 +109,8 @@ class CourseFixture(StudioApiFixture):
between tests, you should use unique course identifiers for each fixture.
"""
+ FixtureError = CourseFixtureError
+
def __init__(self, org, number, run, display_name, start_date=None, end_date=None):
"""
Configure the course fixture to create a course with
@@ -181,6 +123,7 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non
These have the same meaning as in the Studio restful API /course end-point.
"""
+ super(CourseFixture, self).__init__()
self._course_dict = {
'org': org,
'number': number,
@@ -202,7 +145,6 @@ def __init__(self, org, number, run, display_name, start_date=None, end_date=Non
self._updates = []
self._handouts = []
- self.children = []
self._assets = []
self._advanced_settings = {}
self._course_key = None
@@ -213,16 +155,6 @@ def __str__(self):
"""
return "".format(**self._course_dict)
- def add_children(self, *args):
- """
- Add children XBlock to the course.
- Each item in `args` is an `XBlockFixtureDescriptor` object.
-
- Returns the course fixture to allow chaining.
- """
- self.children.extend(args)
- return self
-
def add_update(self, update):
"""
Add an update to the course. `update` should be a `CourseUpdateDesc`.
@@ -450,101 +382,7 @@ def _create_xblock_children(self, parent_loc, xblock_descriptions):
"""
Recursively create XBlock children.
"""
- for desc in xblock_descriptions:
- loc = self.create_xblock(parent_loc, desc)
- self._create_xblock_children(loc, desc.children)
-
+ super(CourseFixture, self)._create_xblock_children(parent_loc, xblock_descriptions)
self._publish_xblock(parent_loc)
- def get_nested_xblocks(self, category=None):
- """
- Return a list of nested XBlocks for the course that can be filtered by
- category.
- """
- xblocks = self._get_nested_xblocks(self)
- if category:
- xblocks = filter(lambda x: x.category == category, xblocks)
- return xblocks
-
- def _get_nested_xblocks(self, xblock_descriptor):
- """
- Return a list of nested XBlocks for the course.
- """
- xblocks = list(xblock_descriptor.children)
- for child in xblock_descriptor.children:
- xblocks.extend(self._get_nested_xblocks(child))
- return xblocks
-
- def create_xblock(self, parent_loc, xblock_desc):
- """
- Create an XBlock with `parent_loc` (the location of the parent block)
- and `xblock_desc` (an `XBlockFixtureDesc` instance).
- """
- create_payload = {
- 'category': xblock_desc.category,
- 'display_name': xblock_desc.display_name,
- }
-
- if parent_loc is not None:
- create_payload['parent_locator'] = parent_loc
-
- # Create the new XBlock
- response = self.session.post(
- STUDIO_BASE_URL + '/xblock/',
- data=json.dumps(create_payload),
- headers=self.headers,
- )
-
- if not response.ok:
- msg = "Could not create {0}. Status was {1}".format(xblock_desc, response.status_code)
- raise CourseFixtureError(msg)
-
- try:
- loc = response.json().get('locator')
- xblock_desc.locator = loc
- except ValueError:
- raise CourseFixtureError("Could not decode JSON from '{0}'".format(response.content))
- # Configure the XBlock
- response = self.session.post(
- STUDIO_BASE_URL + '/xblock/' + loc,
- data=xblock_desc.serialize(),
- headers=self.headers,
- )
-
- if response.ok:
- return loc
- else:
- raise CourseFixtureError(
- "Could not update {0}. Status code: {1}".format(
- xblock_desc, response.status_code))
-
- def _publish_xblock(self, locator):
- """
- Publish the xblock at `locator`.
- """
- self._update_xblock(locator, {'publish': 'make_public'})
-
- def _update_xblock(self, locator, data):
- """
- Update the xblock at `locator`.
- """
- # Create the new XBlock
- response = self.session.put(
- "{}/xblock/{}".format(STUDIO_BASE_URL, locator),
- data=json.dumps(data),
- headers=self.headers,
- )
-
- if not response.ok:
- msg = "Could not update {} with data {}. Status was {}".format(locator, data, response.status_code)
- raise CourseFixtureError(msg)
-
- def _encode_post_dict(self, post_dict):
- """
- Encode `post_dict` (a dictionary) as UTF-8 encoded JSON.
- """
- return json.dumps({
- k: v.encode('utf-8') if isinstance(v, basestring) else v
- for k, v in post_dict.items()
- })
diff --git a/common/test/acceptance/fixtures/library.py b/common/test/acceptance/fixtures/library.py
new file mode 100644
index 000000000000..35b8f60772de
--- /dev/null
+++ b/common/test/acceptance/fixtures/library.py
@@ -0,0 +1,101 @@
+"""
+Fixture to create a Content Library
+"""
+
+from opaque_keys.edx.keys import CourseKey
+
+from . import STUDIO_BASE_URL
+from .base import XBlockContainerFixture
+
+
+class LibraryFixtureError(Exception):
+ """
+ Error occurred while installing a library fixture.
+ """
+ pass
+
+
+class LibraryFixture(XBlockContainerFixture):
+ """
+ Fixture for ensuring that a library exists.
+
+ WARNING: This fixture is NOT idempotent. To avoid conflicts
+ between tests, you should use unique library identifiers for each fixture.
+ """
+
+ FixtureError = LibraryFixtureError
+
+ def __init__(self, org, number, display_name):
+ """
+ Configure the library fixture to create a library with
+ """
+ super(LibraryFixture, self).__init__()
+ self.library_info = {
+ 'org': org,
+ 'number': number,
+ 'display_name': display_name
+ }
+
+ self._library_key = None
+ super(LibraryFixture, self).__init__()
+
+ def __str__(self):
+ """
+ String representation of the library fixture, useful for debugging.
+ """
+ return "".format(**self.library_info)
+
+ def install(self):
+ """
+ Create the library and XBlocks within the library.
+ This is NOT an idempotent method; if the library already exists, this will
+ raise a `LibraryFixtureError`. You should use unique library identifiers to avoid
+ conflicts between tests.
+ """
+ self._create_library()
+ self._create_xblock_children(self.library_location, self.children)
+
+ return self
+
+ @property
+ def library_key(self):
+ """
+ Get the LibraryLocator for this library, as a string.
+ """
+ return self._library_key
+
+ @property
+ def library_location(self):
+ """
+ Return the locator string for the LibraryRoot XBlock that is the root of the library hierarchy.
+ """
+ lib_key = CourseKey.from_string(self._library_key)
+ return unicode(lib_key.make_usage_key('library', 'library'))
+
+ def _create_library(self):
+ """
+ Create the library described in the fixture.
+ Will fail if the library already exists.
+ """
+ response = self.session.post(
+ STUDIO_BASE_URL + '/library/',
+ data=self._encode_post_dict(self.library_info),
+ headers=self.headers
+ )
+
+ if response.ok:
+ self._library_key = response.json()['library_key']
+ else:
+ try:
+ err_msg = response.json().get('ErrMsg')
+ except ValueError:
+ err_msg = "Unknown Error"
+ raise LibraryFixtureError(
+ "Could not create library {}. Status was {}, error was: {}".format(self.library_info, response.status_code, err_msg)
+ )
+
+ def create_xblock(self, parent_loc, xblock_desc):
+ # Disable publishing for library XBlocks:
+ xblock_desc.publish = "not-applicable"
+
+ return super(LibraryFixture, self).create_xblock(parent_loc, xblock_desc)
diff --git a/common/test/acceptance/pages/studio/container.py b/common/test/acceptance/pages/studio/container.py
index 878a0e0bbfb8..491dd0d05169 100644
--- a/common/test/acceptance/pages/studio/container.py
+++ b/common/test/acceptance/pages/studio/container.py
@@ -6,7 +6,7 @@
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
-from utils import click_css, confirm_prompt
+from .utils import click_css, confirm_prompt, type_in_codemirror
class ContainerPage(PageObject):
@@ -362,6 +362,12 @@ def open_basic_tab(self):
"""
self._click_button('basic_tab')
+ def set_codemirror_text(self, text, index=0):
+ """
+ Set the text of a CodeMirror editor that is part of this xblock's settings.
+ """
+ type_in_codemirror(self, index, text, find_prefix='$("{}").find'.format(self.editor_selector))
+
def save_settings(self):
"""
Click on settings Save button.
diff --git a/common/test/acceptance/pages/studio/index.py b/common/test/acceptance/pages/studio/index.py
index af163eca6852..cc81ed3ed933 100644
--- a/common/test/acceptance/pages/studio/index.py
+++ b/common/test/acceptance/pages/studio/index.py
@@ -28,6 +28,10 @@ def course_runs(self):
def has_processing_courses(self):
return self.q(css='.courses-processing').present
+ @property
+ def page_subheader(self):
+ return self.q(css='.content-primary .introduction .copy p').first.text[0]
+
def create_rerun(self, display_name):
"""
Clicks the create rerun link of the course specified by display_name.
@@ -40,3 +44,68 @@ def click_course_run(self, run):
Clicks on the course with run given by run.
"""
self.q(css='.course-run .value').filter(lambda el: el.text == run)[0].click()
+
+ def has_new_library_button(self):
+ """
+ (bool) is the "New Library" button present?
+ """
+ return self.q(css='.new-library-button').present
+
+ def click_new_library(self):
+ """
+ Click on the "New Library" button
+ """
+ self.q(css='.new-library-button').click()
+
+ def is_new_library_form_visible(self):
+ """
+ Is the new library form visisble?
+ """
+ return self.q(css='.wrapper-create-library').visible
+
+ def fill_new_library_form(self, display_name, org, number):
+ """
+ Fill out the form to create a new library.
+ Must have called click_new_library() first.
+ """
+ field = lambda fn: self.q(css='.wrapper-create-library #new-library-{}'.format(fn))
+ field('name').fill(display_name)
+ field('org').fill(org)
+ field('number').fill(number)
+
+ def is_new_library_form_valid(self):
+ """
+ IS the new library form ready to submit?
+ """
+ return (
+ self.q(css='.wrapper-create-library .new-library-save:not(.is-disabled)').present and
+ not self.q(css='.wrapper-create-library .wrap-error.is-shown').present
+ )
+
+ def submit_new_library_form(self):
+ """
+ Submit the new library form.
+ """
+ self.q(css='.wrapper-create-library .new-library-save').click()
+
+ def list_libraries(self):
+ """
+ List all the libraries found on the page's list of libraries.
+ """
+ self.q(css='#course-index-tabs .libraries-tab a').click() # Workaround Selenium/Firefox bug: `.text` property is broken on invisible elements
+ div2info = lambda element: {
+ 'name': element.find_element_by_css_selector('.course-title').text,
+ 'org': element.find_element_by_css_selector('.course-org .value').text,
+ 'number': element.find_element_by_css_selector('.course-num .value').text,
+ 'url': element.find_element_by_css_selector('a.library-link').get_attribute('href'),
+ }
+ return self.q(css='.libraries li.course-item').map(div2info).results
+
+ def has_library(self, **kwargs):
+ """
+ Does the page's list of libraries include a library matching kwargs?
+ """
+ for lib in self.list_libraries():
+ if all([lib[key] == kwargs[key] for key in kwargs]):
+ return True
+ return False
diff --git a/common/test/acceptance/pages/studio/library.py b/common/test/acceptance/pages/studio/library.py
new file mode 100644
index 000000000000..e87c556da968
--- /dev/null
+++ b/common/test/acceptance/pages/studio/library.py
@@ -0,0 +1,97 @@
+"""
+Library edit page in Studio
+"""
+
+from bok_choy.page_object import PageObject
+from .container import XBlockWrapper
+from ...tests.helpers import disable_animations
+from .utils import confirm_prompt, wait_for_notification
+from . import BASE_URL
+
+
+class LibraryPage(PageObject):
+ """
+ Library page in Studio
+ """
+
+ def __init__(self, browser, locator):
+ super(LibraryPage, self).__init__(browser)
+ self.locator = locator
+
+ @property
+ def url(self):
+ """
+ URL to the library edit page for the given library.
+ """
+ return "{}/library/{}".format(BASE_URL, unicode(self.locator))
+
+ def is_browser_on_page(self):
+ """
+ Returns True iff the browser has loaded the library edit page.
+ """
+ return self.q(css='body.view-library').present
+
+ def get_header_title(self):
+ """
+ The text of the main heading (H1) visible on the page.
+ """
+ return self.q(css='h1.page-header-title').text
+
+ def wait_until_ready(self):
+ """
+ When the page first loads, there is a loading indicator and most
+ functionality is not yet available. This waits for that loading to
+ finish.
+
+ Always call this before using the page. It also disables animations
+ for improved test reliability.
+ """
+ self.wait_for_ajax()
+ self.wait_for_element_invisibility('.ui-loading', 'Wait for the page to complete its initial loading of XBlocks via AJAX')
+ disable_animations(self)
+
+ @property
+ def xblocks(self):
+ """
+ Return a list of xblocks loaded on the container page.
+ """
+ return self._get_xblocks()
+
+ def click_duplicate_button(self, xblock_id):
+ """
+ Click on the duplicate button for the given XBlock
+ """
+ self._action_btn_for_xblock_id(xblock_id, "duplicate").click()
+ wait_for_notification(self)
+ self.wait_for_ajax()
+
+ def click_delete_button(self, xblock_id, confirm=True):
+ """
+ Click on the delete button for the given XBlock
+ """
+ self._action_btn_for_xblock_id(xblock_id, "delete").click()
+ if confirm:
+ confirm_prompt(self) # this will also wait_for_notification()
+ self.wait_for_ajax()
+
+ def _get_xblocks(self):
+ """
+ Create an XBlockWrapper for each XBlock div found on the page.
+ """
+ prefix = '.wrapper-xblock.level-page '
+ return self.q(css=prefix + XBlockWrapper.BODY_SELECTOR).map(lambda el: XBlockWrapper(self.browser, el.get_attribute('data-locator'))).results
+
+ def _div_for_xblock_id(self, xblock_id):
+ """
+ Given an XBlock's usage locator as a string, return the WebElement for
+ that block's wrapper div.
+ """
+ return self.q(css='.wrapper-xblock.level-page .studio-xblock-wrapper').filter(lambda el: el.get_attribute('data-locator') == xblock_id)
+
+ def _action_btn_for_xblock_id(self, xblock_id, action):
+ """
+ Given an XBlock's usage locator as a string, return one of its action
+ buttons.
+ action is 'edit', 'duplicate', or 'delete'
+ """
+ return self._div_for_xblock_id(xblock_id)[0].find_element_by_css_selector('.header-actions .{action}-button.action-button'.format(action=action))
diff --git a/common/test/acceptance/pages/studio/utils.py b/common/test/acceptance/pages/studio/utils.py
index a94f50ba6fa1..dd8ec091a347 100644
--- a/common/test/acceptance/pages/studio/utils.py
+++ b/common/test/acceptance/pages/studio/utils.py
@@ -103,6 +103,30 @@ def add_advanced_component(page, menu_index, name):
click_css(page, component_css, 0)
+def add_component(page, item_type, specific_type):
+ """
+ Click one of the "Add New Component" buttons.
+
+ item_type should be "advanced", "html", "problem", or "video"
+
+ specific_type is required for some types and should be something like
+ "Blank Common Problem".
+ """
+ btn = page.q(css='.add-xblock-component .add-xblock-component-button[data-type={}]'.format(item_type))
+ multiple_templates = btn.filter(lambda el: 'multiple-templates' in el.get_attribute('class')).present
+ btn.click()
+ if multiple_templates:
+ sub_template_menu_div_selector = '.new-component-{}'.format(item_type)
+ page.wait_for_element_visibility(sub_template_menu_div_selector, 'Wait for the templates sub-menu to appear')
+ page.wait_for_element_invisibility('.add-xblock-component .new-component', 'Wait for the add component menu to disappear')
+
+ all_options = page.q(css='.new-component-{} ul.new-component-template li a span'.format(item_type))
+ chosen_option = all_options.filter(lambda el: el.text == specific_type).first
+ chosen_option.click()
+ wait_for_notification(page)
+ page.wait_for_ajax()
+
+
@js_defined('window.jQuery')
def type_in_codemirror(page, index, text, find_prefix="$"):
script = """
diff --git a/common/test/acceptance/tests/studio/base_studio_test.py b/common/test/acceptance/tests/studio/base_studio_test.py
index fa07533fba86..ec94f7f058c5 100644
--- a/common/test/acceptance/tests/studio/base_studio_test.py
+++ b/common/test/acceptance/tests/studio/base_studio_test.py
@@ -1,5 +1,10 @@
+"""
+Base classes used by studio tests.
+"""
+from bok_choy.web_app_test import WebAppTest
from ...pages.studio.auto_auth import AutoAuthPage
from ...fixtures.course import CourseFixture
+from ...fixtures.library import LibraryFixture
from ..helpers import UniqueCourseTest
from ...pages.studio.overview import CourseOutlinePage
from ...pages.studio.utils import verify_ordering
@@ -98,3 +103,46 @@ def do_action_and_verify(self, action, expected_ordering):
# Reload the page to see that the change was persisted.
container = self.go_to_nested_container_page()
verify_ordering(self, container, expected_ordering)
+
+
+class StudioLibraryTest(WebAppTest):
+ """
+ Base class for all Studio library tests.
+ """
+
+ def setUp(self, is_staff=False): # pylint: disable=arguments-differ
+ """
+ Install a library with no content using a fixture.
+ """
+ super(StudioLibraryTest, self).setUp()
+ fixture = LibraryFixture(
+ 'test_org',
+ self.unique_id,
+ 'Test Library {}'.format(self.unique_id),
+ )
+ self.populate_library_fixture(fixture)
+ fixture.install()
+ self.library_info = fixture.library_info
+ self.library_key = fixture.library_key
+ self.user = fixture.user
+ self.log_in(self.user, is_staff)
+
+ def populate_library_fixture(self, library_fixture):
+ """
+ Populate the children of the test course fixture.
+ """
+ pass
+
+ def log_in(self, user, is_staff=False):
+ """
+ Log in as the user that created the library.
+ By default the user will not have staff access unless is_staff is passed as True.
+ """
+ auth_page = AutoAuthPage(
+ self.browser,
+ staff=is_staff,
+ username=user.get('username'),
+ email=user.get('email'),
+ password=user.get('password')
+ )
+ auth_page.visit()
diff --git a/common/test/acceptance/tests/studio/test_studio_home.py b/common/test/acceptance/tests/studio/test_studio_home.py
new file mode 100644
index 000000000000..9dc9b0249716
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_home.py
@@ -0,0 +1,67 @@
+"""
+Acceptance tests for Home Page (My Courses / My Libraries).
+"""
+from bok_choy.web_app_test import WebAppTest
+from opaque_keys.edx.locator import LibraryLocator
+
+from ...pages.studio.auto_auth import AutoAuthPage
+from ...pages.studio.library import LibraryPage
+from ...pages.studio.index import DashboardPage
+
+
+class CreateLibraryTest(WebAppTest):
+ """
+ Test that we can create a new content library on the studio home page.
+ """
+
+ def setUp(self):
+ """
+ Load the helper for the home page (dashboard page)
+ """
+ super(CreateLibraryTest, self).setUp()
+
+ self.auth_page = AutoAuthPage(self.browser, staff=True)
+ self.dashboard_page = DashboardPage(self.browser)
+
+ def test_subheader(self):
+ """
+ From the home page:
+ Verify that subheader is correct
+ """
+ self.auth_page.visit()
+ self.dashboard_page.visit()
+
+ self.assertIn("courses and libraries", self.dashboard_page.page_subheader)
+
+ def test_create_library(self):
+ """
+ From the home page:
+ Click "New Library"
+ Fill out the form
+ Submit the form
+ We should be redirected to the edit view for the library
+ Return to the home page
+ The newly created library should now appear in the list of libraries
+ """
+ name = "New Library Name"
+ org = "TestOrgX"
+ number = "TESTLIB"
+
+ self.auth_page.visit()
+ self.dashboard_page.visit()
+ self.assertFalse(self.dashboard_page.has_library(name=name, org=org, number=number))
+ self.assertTrue(self.dashboard_page.has_new_library_button())
+
+ self.dashboard_page.click_new_library()
+ self.assertTrue(self.dashboard_page.is_new_library_form_visible())
+ self.dashboard_page.fill_new_library_form(name, org, number)
+ self.assertTrue(self.dashboard_page.is_new_library_form_valid())
+ self.dashboard_page.submit_new_library_form()
+
+ # The next page is the library edit view; make sure it loads:
+ lib_page = LibraryPage(self.browser, LibraryLocator(org, number))
+ lib_page.wait_for_page()
+
+ # Then go back to the home page and make sure the new library is listed there:
+ self.dashboard_page.visit()
+ self.assertTrue(self.dashboard_page.has_library(name=name, org=org, number=number))
diff --git a/common/test/acceptance/tests/studio/test_studio_library.py b/common/test/acceptance/tests/studio/test_studio_library.py
new file mode 100644
index 000000000000..d5ad890e376c
--- /dev/null
+++ b/common/test/acceptance/tests/studio/test_studio_library.py
@@ -0,0 +1,104 @@
+"""
+Acceptance tests for Content Libraries in Studio
+"""
+
+from .base_studio_test import StudioLibraryTest
+from ...pages.studio.utils import add_component
+from ...pages.studio.library import LibraryPage
+
+
+class LibraryEditPageTest(StudioLibraryTest):
+ """
+ Test the functionality of the library edit page.
+ """
+ def setUp(self): # pylint: disable=arguments-differ
+ """
+ Ensure a library exists and navigate to the library edit page.
+ """
+ super(LibraryEditPageTest, self).setUp(is_staff=True)
+ self.lib_page = LibraryPage(self.browser, self.library_key)
+ self.lib_page.visit()
+ self.lib_page.wait_until_ready()
+
+ def test_page_header(self):
+ """
+ Scenario: Ensure that the library's name is displayed in the header and title.
+ Given I have a library in Studio
+ And I navigate to Library Page in Studio
+ Then I can see library name in page header title
+ And I can see library name in browser page title
+ """
+ self.assertIn(self.library_info['display_name'], self.lib_page.get_header_title())
+ self.assertIn(self.library_info['display_name'], self.browser.title)
+
+ def test_add_duplicate_delete_actions(self):
+ """
+ Scenario: Ensure that we can add an HTML block, duplicate it, then delete the original.
+ Given I have a library in Studio with no XBlocks
+ And I navigate to Library Page in Studio
+ Then there are no XBlocks displayed
+ When I add Text XBlock
+ Then one XBlock is displayed
+ When I duplicate first XBlock
+ Then two XBlocks are displayed
+ And those XBlocks locators' are different
+ When I delete first XBlock
+ Then one XBlock is displayed
+ And displayed XBlock are second one
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+
+ # Create a new block:
+ add_component(self.lib_page, "html", "Text")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ first_block_id = self.lib_page.xblocks[0].locator
+
+ # Duplicate the block:
+ self.lib_page.click_duplicate_button(first_block_id)
+ self.assertEqual(len(self.lib_page.xblocks), 2)
+ second_block_id = self.lib_page.xblocks[1].locator
+ self.assertNotEqual(first_block_id, second_block_id)
+
+ # Delete the first block:
+ self.lib_page.click_delete_button(first_block_id, confirm=True)
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ self.assertEqual(self.lib_page.xblocks[0].locator, second_block_id)
+
+ def test_add_edit_xblock(self):
+ """
+ Scenario: Ensure that we can add an XBlock, edit it, then see the resulting changes.
+ Given I have a library in Studio with no XBlocks
+ And I navigate to Library Page in Studio
+ Then there are no XBlocks displayed
+ When I add Multiple Choice XBlock
+ Then one XBlock is displayed
+ When I edit first XBlock
+ And I go to basic tab
+ And set it's text to a fairly trivial question about Battlestar Galactica
+ And save XBlock
+ Then one XBlock is displayed
+ And first XBlock student content contains at least part of text I set
+ """
+ self.assertEqual(len(self.lib_page.xblocks), 0)
+ # Create a new problem block:
+ add_component(self.lib_page, "problem", "Multiple Choice")
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ problem_block = self.lib_page.xblocks[0]
+ # Edit it:
+ problem_block.edit()
+ problem_block.open_basic_tab()
+ problem_block.set_codemirror_text(
+ """
+ >>Who is "Starbuck"?<<
+ (x) Kara Thrace
+ ( ) William Adama
+ ( ) Laura Roslin
+ ( ) Lee Adama
+ ( ) Gaius Baltar
+ """
+ )
+ problem_block.save_settings()
+ # Check that the save worked:
+ self.assertEqual(len(self.lib_page.xblocks), 1)
+ problem_block = self.lib_page.xblocks[0]
+ self.assertIn("Laura Roslin", problem_block.student_content)