Skip to content

Commit

Permalink
feat: Adds xblock-utils repository code into this repository
Browse files Browse the repository at this point in the history
  • Loading branch information
farhan committed Sep 8, 2023
1 parent acd8da9 commit c616622
Show file tree
Hide file tree
Showing 41 changed files with 2,786 additions and 3 deletions.
7 changes: 7 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import pytest


# https://pytest-django.readthedocs.io/en/latest/faq.html#how-can-i-give-database-access-to-all-my-tests-without-the-django-db-marker
@pytest.fixture(autouse=True)
def enable_db_access_for_all_tests(db):
pass
1 change: 1 addition & 0 deletions requirements/base.in
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

fs
lxml
mako # Used by xblockutils.resources
markupsafe
python-dateutil
pytz
Expand Down
3 changes: 3 additions & 0 deletions requirements/test.in
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
-r django.txt # Package dependencies, including optional Django support

astroid
bok_choy
coverage
ddt
diff-cover >= 0.2.1
Expand All @@ -17,4 +18,6 @@ pylint
pytest
pytest-cov
pytest-django
selenium
tox
xblock-sdk
1 change: 1 addition & 0 deletions xblock/test/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@

# Uncomment the next line to enable admin documentation:
# 'django.contrib.admindocs',
'workbench',
)

# A sample logging configuration. The only tangible logging
Expand Down
6 changes: 3 additions & 3 deletions xblock/test/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ def _num_plugins_cached():
return len(plugin.PLUGIN_CACHE)


@XBlock.register_temp_plugin(AmbiguousBlock1, "thumbs")
@XBlock.register_temp_plugin(AmbiguousBlock1, "thumbs_1")
def test_plugin_caching():
plugin.PLUGIN_CACHE = {}
assert _num_plugins_cached() == 0

XBlock.load_class("thumbs")
XBlock.load_class("thumbs_1")
assert _num_plugins_cached() == 1

XBlock.load_class("thumbs")
XBlock.load_class("thumbs_1")
assert _num_plugins_cached() == 1
5 changes: 5 additions & 0 deletions xblockutils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""
Useful classes and functionality for building and testing XBlocks
"""

__version__ = '3.4.1'
174 changes: 174 additions & 0 deletions xblockutils/base_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
#
# Copyright (C) 2014-2015 edX
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
"""
Base classes for Selenium or bok-choy based integration tests of XBlocks.
"""

import time

from selenium.webdriver.support.ui import WebDriverWait
from workbench.runtime import WorkbenchRuntime
from workbench.scenarios import SCENARIOS, add_xml_scenario, remove_scenario
from workbench.test.selenium_test import SeleniumTest

from .resources import ResourceLoader


class SeleniumXBlockTest(SeleniumTest):
"""
Base class for using the workbench to test XBlocks with Selenium or bok-choy.
If you want to test an XBlock that's not already installed into the python environment,
you can use @XBlock.register_temp_plugin around your test method[s].
"""
timeout = 10 # seconds

def setUp(self):
super().setUp()
# Delete all scenarios from the workbench:
# Trigger initial scenario load.
import workbench.urls # pylint: disable=import-outside-toplevel
SCENARIOS.clear()
# Disable CSRF checks on XBlock handlers:
import workbench.views # pylint: disable=import-outside-toplevel
workbench.views.handler.csrf_exempt = True

def wait_until_visible(self, elem):
""" Wait until the given element is visible """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: e.is_displayed(), f"{elem.text} should be visible")

def wait_until_hidden(self, elem):
""" Wait until the DOM element elem is hidden """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: not e.is_displayed(), f"{elem.text} should be hidden")

def wait_until_disabled(self, elem):
""" Wait until the DOM element elem is disabled """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: not e.is_enabled(), f"{elem.text} should be disabled")

def wait_until_clickable(self, elem):
""" Wait until the DOM element elem is display and enabled """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: e.is_displayed() and e.is_enabled(), f"{elem.text} should be clickable")

def wait_until_text_in(self, text, elem):
""" Wait until the specified text appears in the DOM element elem """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: text in e.text, f"{text} should be in {elem.text}")

def wait_until_html_in(self, html, elem):
""" Wait until the specified HTML appears in the DOM element elem """
wait = WebDriverWait(elem, self.timeout)
wait.until(lambda e: html in e.get_attribute('innerHTML'),
"{} should be in {}".format(html, elem.get_attribute('innerHTML')))

def wait_until_exists(self, selector):
""" Wait until the specified selector exists on the page """
wait = WebDriverWait(self.browser, self.timeout)
wait.until(
lambda driver: driver.find_element_by_css_selector(selector),
f"Selector '{selector}' should exist."
)

@staticmethod
def set_scenario_xml(xml):
""" Reset the workbench to have only one scenario with the specified XML """
SCENARIOS.clear()
add_xml_scenario("test", "Test Scenario", xml)

def go_to_view(self, view_name='student_view', student_id="student_1"):
"""
Navigate to the page `page_name`, as listed on the workbench home
Returns the DOM element on the visited page located by the `css_selector`
"""
url = self.live_server_url + f'/scenario/test/{view_name}/'
if student_id:
url += f'?student={student_id}'
self.browser.get(url)
return self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')

def load_root_xblock(self, student_id="student_1"):
"""
Load (in Python) the XBlock at the root of the current scenario.
"""
dom_node = self.browser.find_element_by_css_selector('.workbench .preview > div.xblock-v1:first-child')
usage_id = dom_node.get_attribute('data-usage')
runtime = WorkbenchRuntime(student_id)
return runtime.get_block(usage_id)


class SeleniumBaseTest(SeleniumXBlockTest):
"""
Selenium Base Test for loading a whole folder of XML scenarios and then running tests.
This is kept for compatibility, but it is recommended that SeleniumXBlockTest be used
instead, since it is faster and more flexible (specifically, scenarios are only loaded
as needed, and can be defined inline with the tests).
"""
module_name = None # You must set this to __name__ in any subclass so ResourceLoader can find scenario XML files
default_css_selector = None # Selector used by go_to_page to return the XBlock DOM element
relative_scenario_path = 'xml' # Path from the module (module_name) to the secnario XML files

@property
def _module_name(self):
""" Internal method to access module_name with a friendly warning if it's unset """
if self.module_name is None:
raise NotImplementedError("Overwrite cls.module_name in your derived class.")
return self.module_name

@property
def _default_css_selector(self):
""" Internal method to access default_css_selector with a warning if it's unset """
if self.default_css_selector is None:
raise NotImplementedError("Overwrite cls.default_css_selector in your derived class.")
return self.default_css_selector

def setUp(self):
super().setUp()
# Use test scenarios:
loader = ResourceLoader(self._module_name)
scenarios_list = loader.load_scenarios_from_path(self.relative_scenario_path, include_identifier=True)
for identifier, title, xml in scenarios_list:
add_xml_scenario(identifier, title, xml)
self.addCleanup(remove_scenario, identifier)

# Suzy opens the browser to visit the workbench
self.browser.get(self.live_server_url)

# She knows it's the site by the header
header1 = self.browser.find_element_by_css_selector('h1')
self.assertEqual(header1.text, 'XBlock scenarios')

def go_to_page(self, page_name, css_selector=None, view_name=None):
"""
Navigate to the page `page_name`, as listed on the workbench home
Returns the DOM element on the visited page located by the `css_selector`
"""
if css_selector is None:
css_selector = self._default_css_selector

self.browser.get(self.live_server_url)
target_url = self.browser.find_element_by_link_text(page_name).get_attribute('href')
if view_name:
target_url += f'{view_name}/'
self.browser.get(target_url)
time.sleep(1)
block = self.browser.find_element_by_css_selector(css_selector)
return block
25 changes: 25 additions & 0 deletions xblockutils/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""
Useful helper methods
"""


def child_isinstance(block, child_id, block_class_or_mixin):
"""
Efficiently check if a child of an XBlock is an instance of the given class.
Arguments:
block -- the parent (or ancestor) of the child block in question
child_id -- the usage key of the child block we are wondering about
block_class_or_mixin -- We return true if block's child indentified by child_id is an
instance of this.
This method is equivalent to
isinstance(block.runtime.get_block(child_id), block_class_or_mixin)
but is far more efficient, as it avoids the need to instantiate the child.
"""
def_id = block.runtime.id_reader.get_definition_id(child_id)
type_name = block.runtime.id_reader.get_block_type(def_id)
child_class = block.runtime.load_block_type(type_name)
return issubclass(child_class, block_class_or_mixin)
63 changes: 63 additions & 0 deletions xblockutils/public/studio_container.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
function StudioContainerXBlockWithNestedXBlocksMixin(runtime, element) {
var $buttons = $(".add-xblock-component-button", element),
$addComponent = $('.add-xblock-component', element),
$element = $(element);

function isSingleInstance($button) {
return $button.data('single-instance');
}

// We use delegated events here, i.e., not binding a click event listener
// directly to $buttons, because we want to make sure any other click event
// listeners of the button are called first before we disable the button.
// Ref: OSPR-1393
$addComponent.on('click', '.add-xblock-component-button', function(ev) {
var $button = $(ev.currentTarget);
if ($button.is('.disabled')) {
ev.preventDefault();
ev.stopPropagation();
} else {
if (isSingleInstance($button)) {
$button.addClass('disabled');
$button.attr('disabled', 'disabled');
}
}
});

function updateButtons() {
var nestedBlockLocations = $.map($element.find(".studio-xblock-wrapper"), function(block_wrapper) {
return $(block_wrapper).data('locator');
});

$buttons.each(function() {
var $this = $(this);
if (!isSingleInstance($this)) {
return;
}
var category = $this.data('category');
var childExists = false;

// FIXME: This is potentially buggy - if some XBlock's category is a substring of some other XBlock category
// it will exhibit wrong behavior. However, it's not possible to do anything about that unless studio runtime
// announces which block was deleted, not it's parent.
for (var i = 0; i < nestedBlockLocations.length; i++) {
if (nestedBlockLocations[i].indexOf(category) > -1) {
childExists = true;
break;
}
}

if (childExists) {
$this.attr('disabled', 'disabled');
$this.addClass('disabled')
}
else {
$this.removeAttr('disabled');
$this.removeClass('disabled');
}
});
}

updateButtons();
runtime.listenTo('deleted-child', updateButtons);
}
Loading

0 comments on commit c616622

Please sign in to comment.