diff --git a/.gitignore b/.gitignore index 78cd1732dc..256ff5d698 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ jscover.log jscover.log.* .tddium* common/test/data/test_unicode/static/ +test_root/courses/ django-pyfs ### Installation artifacts diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2d684a7a84..99dff6acd8 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -52,6 +52,18 @@ class CourseSettingsEncoderTest(CourseTestCase): self.assertIsNone(jsondetails['effort'], "effort somehow initialized") self.assertIsNone(jsondetails['language'], "language somehow initialized") + def test_pre_1900_date(self): + """ + Tests that the encoder can handle a pre-1900 date, since strftime + doesn't work for these dates. + """ + details = CourseDetails.fetch(self.course.id) + pre_1900 = datetime.datetime(1564, 4, 23, 1, 1, 1, tzinfo=UTC()) + details.enrollment_start = pre_1900 + dumped_jsondetails = json.dumps(details, cls=CourseSettingsEncoder) + loaded_jsondetails = json.loads(dumped_jsondetails) + self.assertEqual(loaded_jsondetails['enrollment_start'], pre_1900.isoformat()) + def test_ooc_encoder(self): """ Test the encoder out of its original constrained purpose to see if it functions for general use diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index aa60bcadca..530b2f9d6b 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -91,7 +91,7 @@ from util.organizations_helpers import ( organizations_enabled, ) from util.string_utils import _has_non_ascii_characters -from util.course_key_utils import from_string_or_404 +from util.course_key_utils import course_key_from_string_or_404 from xmodule.contentstore.content import StaticContent from xmodule.course_module import CourseFields from xmodule.course_module import DEFAULT_START_DATE @@ -875,7 +875,7 @@ def course_info_handler(request, course_key_string): GET html: return html for editing the course info handouts and updates. """ - course_key = from_string_or_404(course_key_string) + course_key = course_key_from_string_or_404(course_key_string) with modulestore().bulk_operations(course_key): course_module = get_course_and_check_access(course_key, request.user) diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 930a16d5a4..610279baff 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -16,7 +16,7 @@ from django.contrib.auth.decorators import login_required from django.core.exceptions import SuspiciousOperation, PermissionDenied from django.core.files.temp import NamedTemporaryFile from django.core.servers.basehttp import FileWrapper -from django.http import HttpResponse, HttpResponseNotFound +from django.http import HttpResponse, HttpResponseNotFound, Http404 from django.utils.translation import ugettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods, require_GET @@ -489,6 +489,8 @@ def export_handler(request, course_key_string): } else: courselike_module = modulestore().get_course(course_key) + if courselike_module is None: + raise Http404 context = { 'context_course': courselike_module, 'courselike_home_url': reverse_course_url("course_handler", course_key), diff --git a/cms/djangoapps/contentstore/views/tabs.py b/cms/djangoapps/contentstore/views/tabs.py index f79d4d0721..38d1c6a228 100644 --- a/cms/djangoapps/contentstore/views/tabs.py +++ b/cms/djangoapps/contentstore/views/tabs.py @@ -14,7 +14,8 @@ from edxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore from xmodule.modulestore import ModuleStoreEnum from xmodule.tabs import CourseTabList, CourseTab, InvalidTabsException, StaticTab -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import UsageKey +from util.course_key_utils import course_key_from_string_or_404 from ..utils import get_lms_link_for_item @@ -39,7 +40,7 @@ def tabs_handler(request, course_key_string): Creating a tab, deleting a tab, or changing its contents is not supported through this method. Instead use the general xblock URL (see item.xblock_handler). """ - course_key = CourseKey.from_string(course_key_string) + course_key = course_key_from_string_or_404(course_key_string) if not has_course_author_access(request.user, course_key): raise PermissionDenied() diff --git a/cms/djangoapps/contentstore/views/tests/test_import_export.py b/cms/djangoapps/contentstore/views/tests/test_import_export.py index f934efba62..34d0225bb1 100644 --- a/cms/djangoapps/contentstore/views/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/views/tests/test_import_export.py @@ -510,6 +510,7 @@ class ImportTestCase(CourseTestCase): @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +@ddt.ddt class ExportTestCase(CourseTestCase): """ Tests for export_handler. @@ -630,6 +631,17 @@ class ExportTestCase(CourseTestCase): self.test_export_targz_urlparam() + @ddt.data( + '/export/non.1/existence_1/Run_1', # For mongo + '/export/course-v1:non1+existence1+Run1', # For split + ) + def test_export_course_doest_not_exist(self, url): + """ + Export failure if course is not exist + """ + resp = self.client.get_html(url) + self.assertEquals(resp.status_code, 404) + @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class TestLibraryImportExport(CourseTestCase): diff --git a/cms/djangoapps/contentstore/views/tests/test_tabs.py b/cms/djangoapps/contentstore/views/tests/test_tabs.py index fde68931a3..bb8342cd7a 100644 --- a/cms/djangoapps/contentstore/views/tests/test_tabs.py +++ b/cms/djangoapps/contentstore/views/tests/test_tabs.py @@ -7,9 +7,12 @@ from contentstore.tests.utils import CourseTestCase from contentstore.utils import reverse_course_url from xmodule.x_module import STUDENT_VIEW from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from django.test.client import RequestFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.tabs import CourseTabList from xmodule.modulestore.django import modulestore +from django.http import Http404 +from contentstore.views.tabs import tabs_handler class TabsPageTests(CourseTestCase): @@ -191,6 +194,14 @@ class TabsPageTests(CourseTestCase): self.assertIn('Delete this component', html) self.assertIn('', html) + def test_invalid_course_id(self): + """ Asserts that Http404 is raised when the course id is not valid. """ + request_factory = RequestFactory() + request = request_factory.get('/dummy-url') + request.user = self.user + with self.assertRaises(Http404): + tabs_handler(request, "/some.invalid.key/course-v1:TTT+CS01+2015_T0") + class PrimitiveTabEdit(ModuleStoreTestCase): """Tests for the primitive tab edit data manipulations""" diff --git a/cms/djangoapps/contentstore/views/tests/test_user.py b/cms/djangoapps/contentstore/views/tests/test_user.py index 725858e44d..da82bdd00e 100644 --- a/cms/djangoapps/contentstore/views/tests/test_user.py +++ b/cms/djangoapps/contentstore/views/tests/test_user.py @@ -9,6 +9,9 @@ from django.contrib.auth.models import User from student.models import CourseEnrollment from student.roles import CourseStaffRole, CourseInstructorRole from student import auth +from django.http import Http404 +from contentstore.views.user import course_team_handler +from django.test.client import RequestFactory class UsersTestCase(CourseTestCase): @@ -315,3 +318,11 @@ class UsersTestCase(CourseTestCase): CourseEnrollment.is_enrolled(self.ext_user, self.course.id), 'User ext_user should have been enrolled in the course' ) + + def test_invalid_course_id(self): + """ Asserts that Http404 is raised when the course id is not valid. """ + request_factory = RequestFactory() + request = request_factory.get('/dummy-url') + request.user = self.user + with self.assertRaises(Http404): + course_team_handler(request, "/some.invalid.key/course-v1:TTT+CS01+2015_T0") diff --git a/cms/djangoapps/contentstore/views/user.py b/cms/djangoapps/contentstore/views/user.py index e5da0a8194..79208a535d 100644 --- a/cms/djangoapps/contentstore/views/user.py +++ b/cms/djangoapps/contentstore/views/user.py @@ -8,7 +8,7 @@ from django.views.decorators.csrf import ensure_csrf_cookie from edxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore -from opaque_keys.edx.keys import CourseKey +from util.course_key_utils import course_key_from_string_or_404 from opaque_keys.edx.locator import LibraryLocator from util.json_request import JsonResponse, expect_json from student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole @@ -49,7 +49,7 @@ def course_team_handler(request, course_key_string=None, email=None): DELETE: json: remove a particular course team member from the course team (email is required). """ - course_key = CourseKey.from_string(course_key_string) if course_key_string else None + course_key = course_key_from_string_or_404(course_key_string) if course_key_string else None # No permissions check here - each helper method does its own check. if 'application/json' in request.META.get('HTTP_ACCEPT', 'application/json'): diff --git a/cms/envs/aws.py b/cms/envs/aws.py index 15d33ab0aa..abcb74943e 100644 --- a/cms/envs/aws.py +++ b/cms/envs/aws.py @@ -168,6 +168,12 @@ if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): EDXMKTG_LOGGED_IN_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_LOGGED_IN_COOKIE_NAME', EDXMKTG_LOGGED_IN_COOKIE_NAME) EDXMKTG_USER_INFO_COOKIE_NAME = ENV_TOKENS.get('EDXMKTG_USER_INFO_COOKIE_NAME', EDXMKTG_USER_INFO_COOKIE_NAME) +# Determines whether the CSRF token can be transported on +# unencrypted channels. It is set to False here for backward compatibility, +# but it is highly recommended that this is True for environments accessed +# by end users. +CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) + #Email overrides DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) diff --git a/cms/envs/common.py b/cms/envs/common.py index a057dafd78..4fc14fdcb6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -292,7 +292,9 @@ from lms.envs.common import ( # Forwards-compatibility with Django 1.7 CSRF_COOKIE_AGE = 60 * 60 * 24 * 7 * 52 - +# It is highly recommended that you override this in any environment accessed by +# end users +CSRF_COOKIE_SECURE = False #################### CAPA External Code Evaluation ############################# XQUEUE_INTERFACE = { @@ -903,6 +905,9 @@ INSTALLED_APPS = ( # Management commands used for configuration automation 'edx_management_commands.management_commands', + + # Tagging + 'cms.lib.xblock.tagging', ) diff --git a/cms/lib/xblock/tagging/__init__.py b/cms/lib/xblock/tagging/__init__.py new file mode 100644 index 0000000000..98c9e4da77 --- /dev/null +++ b/cms/lib/xblock/tagging/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +""" +Structured Tagging based on XBlockAsides +""" + +from .tagging import StructuredTagsAside diff --git a/cms/lib/xblock/tagging/migrations/0001_initial.py b/cms/lib/xblock/tagging/migrations/0001_initial.py new file mode 100644 index 0000000000..a0d86d97ac --- /dev/null +++ b/cms/lib/xblock/tagging/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TagAvailableValues', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('value', models.CharField(max_length=255)), + ], + options={ + 'ordering': ('id',), + }, + ), + migrations.CreateModel( + name='TagCategories', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('name', models.CharField(max_length=255, unique=True)), + ('title', models.CharField(max_length=255)), + ], + options={ + 'ordering': ('title',), + }, + ), + migrations.AddField( + model_name='tagavailablevalues', + name='category', + field=models.ForeignKey(to='tagging.TagCategories'), + ), + ] diff --git a/cms/lib/xblock/tagging/migrations/__init__.py b/cms/lib/xblock/tagging/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cms/lib/xblock/tagging/models.py b/cms/lib/xblock/tagging/models.py new file mode 100644 index 0000000000..4024197fcc --- /dev/null +++ b/cms/lib/xblock/tagging/models.py @@ -0,0 +1,40 @@ +""" +Django Model for tags +""" +from django.db import models + + +class TagCategories(models.Model): + """ + This model represents tag categories. + """ + name = models.CharField(max_length=255, unique=True) + title = models.CharField(max_length=255) + + class Meta(object): + app_label = "tagging" + ordering = ('title',) + + def __unicode__(self): + return "[TagCategories] {}: {}".format(self.name, self.title) + + def get_values(self): + """ + Return the list of available values for the particular category + """ + return [t.value for t in TagAvailableValues.objects.filter(category=self)] + + +class TagAvailableValues(models.Model): + """ + This model represents available values for tags. + """ + category = models.ForeignKey(TagCategories, db_index=True) + value = models.CharField(max_length=255) + + class Meta(object): + app_label = "tagging" + ordering = ('id',) + + def __unicode__(self): + return "[TagAvailableValues] {}: {}".format(self.category, self.value) diff --git a/cms/lib/xblock/tagging.py b/cms/lib/xblock/tagging/tagging.py similarity index 56% rename from cms/lib/xblock/tagging.py rename to cms/lib/xblock/tagging/tagging.py index 7a7710e187..15ac9c36bd 100644 --- a/cms/lib/xblock/tagging.py +++ b/cms/lib/xblock/tagging/tagging.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Structured Tagging based on XBlockAsides """ @@ -7,64 +8,15 @@ from xblock.fragment import Fragment from xblock.fields import Scope, Dict from xmodule.x_module import STUDENT_VIEW from xmodule.capa_module import CapaModule -from abc import ABCMeta, abstractproperty from edxmako.shortcuts import render_to_string from django.conf import settings from webob import Response -from collections import OrderedDict +from .models import TagCategories _ = lambda text: text -class AbstractTag(object): - """ - Abstract class for tags - """ - __metaclass__ = ABCMeta - - @abstractproperty - def key(self): - """ - Subclasses must implement key - """ - raise NotImplementedError('Subclasses must implement key') - - @abstractproperty - def name(self): - """ - Subclasses must implement name - """ - raise NotImplementedError('Subclasses must implement name') - - @abstractproperty - def allowed_values(self): - """ - Subclasses must implement allowed_values - """ - raise NotImplementedError('Subclasses must implement allowed_values') - - -class DifficultyTag(AbstractTag): - """ - Particular implementation tags for difficulty - """ - @property - def key(self): - """ Identifier for the difficulty selector """ - return 'difficulty_tag' - - @property - def name(self): - """ Label for the difficulty selector """ - return _('Difficulty') - - @property - def allowed_values(self): - """ Allowed values for the difficulty selector """ - return OrderedDict([('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard')]) - - class StructuredTagsAside(XBlockAside): """ Aside that allows tagging blocks @@ -72,7 +24,12 @@ class StructuredTagsAside(XBlockAside): saved_tags = Dict(help=_("Dictionary with the available tags"), scope=Scope.content, default={},) - available_tags = [DifficultyTag()] + + def get_available_tags(self): + """ + Return available tags + """ + return TagCategories.objects.all() def _get_studio_resource_url(self, relative_url): """ @@ -88,14 +45,21 @@ class StructuredTagsAside(XBlockAside): """ if isinstance(block, CapaModule): tags = [] - for tag in self.available_tags: + for tag in self.get_available_tags(): + values = tag.get_values() + current_value = self.saved_tags.get(tag.name, None) + + if current_value is not None and current_value not in values: + values.insert(0, current_value) + tags.append({ - 'key': tag.key, - 'title': tag.name, - 'values': tag.allowed_values, - 'current_value': self.saved_tags.get(tag.key, None), + 'key': tag.name, + 'title': tag.title, + 'values': values, + 'current_value': current_value }) - fragment = Fragment(render_to_string('structured_tags_block.html', {'tags': tags})) + fragment = Fragment(render_to_string('structured_tags_block.html', {'tags': tags, + 'block_location': block.location})) fragment.add_javascript_url(self._get_studio_resource_url('/js/xblock_asides/structured_tags.js')) fragment.initialize_js('StructuredTagsInit') return fragment @@ -113,14 +77,14 @@ class StructuredTagsAside(XBlockAside): tag = request.params['tag'].split(':') - for av_tag in self.available_tags: - if av_tag.key == tag[0]: - if tag[1] in av_tag.allowed_values: - self.saved_tags[tag[0]] = tag[1] - found = True - elif tag[1] == '': + for av_tag in self.get_available_tags(): + if av_tag.name == tag[0]: + if tag[1] == '': self.saved_tags[tag[0]] = None found = True + elif tag[1] in av_tag.get_values(): + self.saved_tags[tag[0]] = tag[1] + found = True if not found: return Response("Invalid 'tag' parameter", status=400) diff --git a/cms/lib/xblock/test/test_tagging.py b/cms/lib/xblock/tagging/test.py similarity index 66% rename from cms/lib/xblock/test/test_tagging.py rename to cms/lib/xblock/tagging/test.py index 49907e1298..1b6227e90f 100644 --- a/cms/lib/xblock/test/test_tagging.py +++ b/cms/lib/xblock/tagging/test.py @@ -7,7 +7,11 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xblock_config.models import StudioConfig +from xblock.fields import ScopeIds +from xblock.runtime import DictKeyValueStore, KvsFieldData +from xblock.test.tools import TestRuntime from cms.lib.xblock.tagging import StructuredTagsAside +from cms.lib.xblock.tagging.models import TagCategories, TagAvailableValues from contentstore.views.preview import get_preview_fragment from contentstore.utils import reverse_usage_url from contentstore.tests.utils import AjaxEnabledTestClient @@ -30,8 +34,9 @@ class StructuredTagsAsideTestCase(ModuleStoreTestCase): """ self.user_password = super(StructuredTagsAsideTestCase, self).setUp() self.aside_name = 'tagging_aside' - self.aside_tag = 'difficulty_tag' - self.aside_tag_value = 'hard' + self.aside_tag_dif = 'difficulty' + self.aside_tag_dif_value = 'Hard' + self.aside_tag_lo = 'learning_outcome' course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) self.course = ItemFactory.create( @@ -75,16 +80,47 @@ class StructuredTagsAsideTestCase(ModuleStoreTestCase): user_id=self.user.id ) + _init_data = [ + { + 'name': 'difficulty', + 'title': 'Difficulty', + 'values': ['Easy', 'Medium', 'Hard'], + }, + { + 'name': 'learning_outcome', + 'title': 'Learning outcome', + 'values': ['Learned nothing', 'Learned a few things', 'Learned everything'] + } + ] + + for tag in _init_data: + category = TagCategories.objects.create(name=tag['name'], title=tag['title']) + for val in tag['values']: + TagAvailableValues.objects.create(category=category, value=val) + config = StudioConfig.current() config.enabled = True config.save() + def tearDown(self): + TagAvailableValues.objects.all().delete() + TagCategories.objects.all().delete() + super(StructuredTagsAsideTestCase, self).tearDown() + def test_aside_contains_tags(self): """ Checks that available_tags list is not empty """ - self.assertGreater(len(StructuredTagsAside.available_tags), 0, - "StructuredTagsAside should contains at least one available tag") + sids = ScopeIds(user_id="bob", + block_type="bobs-type", + def_id="definition-id", + usage_id="usage-id") + key_store = DictKeyValueStore() + field_data = KvsFieldData(key_store) + runtime = TestRuntime(services={'field-data': field_data}) # pylint: disable=abstract-class-instantiated + xblock_aside = StructuredTagsAside(scope_ids=sids, runtime=runtime) + available_tags = xblock_aside.get_available_tags() + self.assertEquals(len(available_tags), 2, "StructuredTagsAside should contains two tag categories") def test_preview_html(self): """ @@ -115,10 +151,26 @@ class StructuredTagsAsideTestCase(ModuleStoreTestCase): self.assertIn('xblock_asides-v1', div_node.get('class')) select_nodes = div_node.xpath('div/select') - self.assertEquals(len(select_nodes), 1) + self.assertEquals(len(select_nodes), 2) - select_node = select_nodes[0] - self.assertEquals(select_node.get('name'), self.aside_tag) + select_node1 = select_nodes[0] + self.assertEquals(select_node1.get('name'), self.aside_tag_dif) + + option_nodes1 = select_node1.xpath('option') + self.assertEquals(len(option_nodes1), 4) + + option_values1 = [opt_elem.text for opt_elem in option_nodes1] + self.assertEquals(option_values1, ['Not selected', 'Easy', 'Medium', 'Hard']) + + select_node2 = select_nodes[1] + self.assertEquals(select_node2.get('name'), self.aside_tag_lo) + + option_nodes2 = select_node2.xpath('option') + self.assertEquals(len(option_nodes2), 4) + + option_values2 = [opt_elem.text for opt_elem in option_nodes2 if opt_elem.text] + self.assertEquals(option_values2, ['Not selected', 'Learned nothing', + 'Learned a few things', 'Learned everything']) # Now ensure the acid_aside is not in the result self.assertNotRegexpMatches(problem_html, r"data-block-type=[\"\']acid_aside[\"\']") @@ -146,11 +198,11 @@ class StructuredTagsAsideTestCase(ModuleStoreTestCase): response = client.post(path=handler_url, data={'tag': 'undefined_tag:undefined'}) self.assertEqual(response.status_code, 400) - val = '%s:undefined' % self.aside_tag + val = '%s:undefined' % self.aside_tag_dif response = client.post(path=handler_url, data={'tag': val}) self.assertEqual(response.status_code, 400) - val = '%s:%s' % (self.aside_tag, self.aside_tag_value) + val = '%s:%s' % (self.aside_tag_dif, self.aside_tag_dif_value) response = client.post(path=handler_url, data={'tag': val}) self.assertEqual(response.status_code, 200) @@ -163,4 +215,4 @@ class StructuredTagsAsideTestCase(ModuleStoreTestCase): break self.assertIsNotNone(tag_aside, "Necessary StructuredTagsAside object isn't found") - self.assertEqual(tag_aside.saved_tags[self.aside_tag], self.aside_tag_value) + self.assertEqual(tag_aside.saved_tags[self.aside_tag_dif], self.aside_tag_dif_value) diff --git a/cms/static/coffee/spec/main.coffee b/cms/static/coffee/spec/main.coffee index b7efe681c3..0aa0e96687 100644 --- a/cms/static/coffee/spec/main.coffee +++ b/cms/static/coffee/spec/main.coffee @@ -1,4 +1,5 @@ requirejs.config({ + baseUrl: '/base/', paths: { "gettext": "xmodule_js/common_static/js/test/i18n", "mustache": "xmodule_js/common_static/js/vendor/mustache", @@ -42,10 +43,9 @@ requirejs.config({ "accessibility": "xmodule_js/common_static/js/src/accessibility_tools", "sinon": "xmodule_js/common_static/js/vendor/sinon-1.17.0", "squire": "xmodule_js/common_static/js/vendor/Squire", - "jasmine-jquery": "xmodule_js/common_static/js/vendor/jasmine-jquery", "jasmine-imagediff": "xmodule_js/common_static/js/vendor/jasmine-imagediff", - "jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth", - "jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async", + "jasmine-stealth": "xmodule_js/common_static/js/libs/jasmine-stealth", + "jasmine-waituntil": "xmodule_js/common_static/js/libs/jasmine-waituntil", "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly", "domReady": "xmodule_js/common_static/js/vendor/domReady", "URI": "xmodule_js/common_static/js/vendor/URI.min", @@ -158,17 +158,17 @@ requirejs.config({ "mathjax": { exports: "MathJax", init: -> - MathJax.Hub.Config - tex2jax: - inlineMath: [ - ["\\(","\\)"], - ['[mathjaxinline]','[/mathjaxinline]'] - ] - displayMath: [ - ["\\[","\\]"], - ['[mathjax]','[/mathjax]'] - ] - MathJax.Hub.Configured() + MathJax.Hub.Config + tex2jax: + inlineMath: [ + ["\\(", "\\)"], + ['[mathjaxinline]', '[/mathjaxinline]'] + ] + displayMath: [ + ["\\[", "\\]"], + ['[mathjax]', '[/mathjax]'] + ] + MathJax.Hub.Configured() }, "URI": { exports: "URI" @@ -179,18 +179,12 @@ requirejs.config({ "sinon": { exports: "sinon" }, - "jasmine-jquery": { - deps: ["jasmine"] - }, - "jasmine-imagediff": { - deps: ["jasmine"] - }, + "jasmine-imagediff": {}, "jasmine-stealth": { - deps: ["jasmine"] + deps: ["underscore", "underscore.string"] }, - "jasmine.async": { - deps: ["jasmine"], - exports: "AsyncSpec" + "jasmine-waituntil": { + deps: ["jquery"] }, "xblock/core": { exports: "XBlock", @@ -201,7 +195,7 @@ requirejs.config({ deps: ["xblock/core"] }, "mock-ajax": { - deps: ["jasmine", "jquery"] + deps: ["jquery"] } "coffee/src/main": { @@ -221,35 +215,33 @@ requirejs.config({ jasmine.getFixtures().fixturesPath += 'coffee/fixtures' -define([ +testFiles = [ "coffee/spec/main_spec", - - "coffee/spec/models/course_spec", "coffee/spec/models/metadata_spec", + "coffee/spec/models/course_spec", + "coffee/spec/models/metadata_spec", "coffee/spec/models/section_spec", "coffee/spec/models/settings_course_grader_spec", - "coffee/spec/models/settings_grading_spec", "coffee/spec/models/textbook_spec", + "coffee/spec/models/settings_grading_spec", + "coffee/spec/models/textbook_spec", "coffee/spec/models/upload_spec", - "coffee/spec/views/course_info_spec", "coffee/spec/views/metadata_edit_spec", "coffee/spec/views/module_edit_spec", "coffee/spec/views/textbook_spec", "coffee/spec/views/upload_spec", - - "js/spec/video/transcripts/utils_spec", "js/spec/video/transcripts/editor_spec", - "js/spec/video/transcripts/videolist_spec", "js/spec/video/transcripts/message_manager_spec", - "js/spec/video/transcripts/file_uploader_spec", - + "js/spec/video/transcripts/utils_spec", + "js/spec/video/transcripts/editor_spec", +# "js/spec/video/transcripts/videolist_spec", +# "js/spec/video/transcripts/message_manager_spec", +# "js/spec/video/transcripts/file_uploader_spec", "js/spec/models/component_template_spec", "js/spec/models/explicit_url_spec", "js/spec/models/xblock_info_spec", "js/spec/models/xblock_validation_spec", "js/spec/models/license_spec", - "js/spec/utils/drag_and_drop_spec", "js/spec/utils/handle_iframe_binding_spec", "js/spec/utils/module_spec", - "js/spec/views/active_video_upload_list_spec", "js/spec/views/previous_video_upload_spec", "js/spec/views/previous_video_upload_list_spec", @@ -266,7 +258,6 @@ define([ "js/spec/views/license_spec", "js/spec/views/paging_spec", "js/spec/views/login_studio_spec", - "js/spec/views/pages/container_spec", "js/spec/views/pages/container_subviews_spec", "js/spec/views/pages/group_configurations_spec", @@ -274,25 +265,24 @@ define([ "js/spec/views/pages/course_rerun_spec", "js/spec/views/pages/index_spec", "js/spec/views/pages/library_users_spec", - "js/spec/views/modals/base_modal_spec", "js/spec/views/modals/edit_xblock_spec", "js/spec/views/modals/validation_error_modal_spec", - "js/spec/views/settings/main_spec", - "js/spec/factories/xblock_validation_spec", - "js/spec/xblock/cms.runtime.v1_spec", - - # Certificates application test suite mappings "js/certificates/spec/models/certificate_spec", "js/certificates/spec/views/certificate_details_spec", "js/certificates/spec/views/certificate_editor_spec", "js/certificates/spec/views/certificates_list_spec", - "js/certificates/spec/views/certificate_preview_spec", + "js/certificates/spec/views/certificate_preview_spec" +] - # these tests are run separately in the cms-squire suite, due to process - # isolation issues with Squire.js - # "coffee/spec/views/assets_spec" - ]) +i = 0 +while i < testFiles.length + testFiles[i] = '/base/' + testFiles[i] + '.js' + i++ + +require testFiles, -> +# start test run, once Require.js is done + window.__karma__.start() diff --git a/cms/static/coffee/spec/main_spec.coffee b/cms/static/coffee/spec/main_spec.coffee index 66e8048889..ee68e31326 100644 --- a/cms/static/coffee/spec/main_spec.coffee +++ b/cms/static/coffee/spec/main_spec.coffee @@ -1,4 +1,5 @@ -require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_helpers", "jasmine-stealth", "jquery.cookie"], +require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_helpers", + "jasmine-stealth", "jasmine-waituntil", "jquery.cookie"], ($, Backbone, main, AjaxHelpers) -> describe "CMS", -> it "should initialize URL", -> @@ -7,8 +8,12 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h describe "main helper", -> beforeEach -> @previousAjaxSettings = $.extend(true, {}, $.ajaxSettings) - spyOn($, "cookie") - $.cookie.when("csrftoken").thenReturn("stubCSRFToken") + spyOn($, "cookie").and.callFake( + (param) -> + if param == "csrftoken" + return "stubCSRFToken" + ) + main() afterEach -> @@ -21,12 +26,15 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h expect($.ajaxSettings.headers["X-CSRFToken"]).toEqual("stubCSRFToken") describe "AJAX Errors", -> - + server = null beforeEach -> appendSetFixtures(sandbox({id: "page-notification"})) + afterEach -> + server && server.restore() + it "successful AJAX request does not pop an error notification", -> - server = AjaxHelpers.server(this, [200, {}, '']) + server = AjaxHelpers.server([200, {}, '']) expect($("#page-notification")).toBeEmpty() $.ajax("/test") @@ -35,15 +43,15 @@ require ["jquery", "backbone", "coffee/src/main", "common/js/spec_helpers/ajax_h expect($("#page-notification")).toBeEmpty() it "AJAX request with error should pop an error notification", -> - server = AjaxHelpers.server(this, [500, {}, '']) + server = AjaxHelpers.server([500, {}, '']) $.ajax("/test") server.respond() expect($("#page-notification")).not.toBeEmpty() - expect($("#page-notification")).toContain('div.wrapper-notification-error') + expect($("#page-notification")).toContainElement('div.wrapper-notification-error') it "can override AJAX request with error so it does not pop an error notification", -> - server = AjaxHelpers.server(this, [500, {}, '']) + server = AjaxHelpers.server([500, {}, '']) $.ajax url: "/test" diff --git a/cms/static/coffee/spec/main_squire.coffee b/cms/static/coffee/spec/main_squire.coffee index afd40603df..1a537b4d64 100644 --- a/cms/static/coffee/spec/main_squire.coffee +++ b/cms/static/coffee/spec/main_squire.coffee @@ -1,4 +1,6 @@ requirejs.config({ + baseUrl: '/base/', + paths: { "gettext": "xmodule_js/common_static/js/test/i18n", "mustache": "xmodule_js/common_static/js/vendor/mustache", @@ -22,8 +24,8 @@ requirejs.config({ "datepair": "xmodule_js/common_static/js/vendor/timepicker/datepair", "date": "xmodule_js/common_static/js/vendor/date", "text": "xmodule_js/common_static/js/vendor/requirejs/text", - "underscore": "xmodule_js/common_static/common/js/vendor/underscore", - "underscore.string": "xmodule_js/common_static/common/js/vendor/underscore.string", + "underscore": "common/js/vendor/underscore", + "underscore.string": "common/js/vendor/underscore.string", "backbone": "xmodule_js/common_static/js/vendor/backbone-min", "backbone.associations": "xmodule_js/common_static/js/vendor/backbone-associations-min", "backbone.paginator": "xmodule_js/common_static/js/vendor/backbone.paginator.min", @@ -36,11 +38,11 @@ requirejs.config({ "utility": "xmodule_js/common_static/js/src/utility", "sinon": "xmodule_js/common_static/js/vendor/sinon-1.17.0", "squire": "xmodule_js/common_static/js/vendor/Squire", - "jasmine-stealth": "xmodule_js/common_static/js/vendor/jasmine-stealth", - "jasmine.async": "xmodule_js/common_static/js/vendor/jasmine.async", "modernizr": "xmodule_js/common_static/edx-pattern-library/js/modernizr-custom", "afontgarde": "xmodule_js/common_static/edx-pattern-library/js/afontgarde", "edxicons": "xmodule_js/common_static/edx-pattern-library/js/edx-icons", + "jasmine-stealth": "xmodule_js/common_static/js/libs/jasmine-stealth", + "jasmine-waituntil": "xmodule_js/common_static/js/libs/jasmine-waituntil", "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly", "domReady": "xmodule_js/common_static/js/vendor/domReady", "URI": "xmodule_js/common_static/js/vendor/URI.min", @@ -159,11 +161,10 @@ requirejs.config({ exports: "sinon" }, "jasmine-stealth": { - deps: ["jasmine"] + deps: ["underscore", "underscore.string"] }, - "jasmine.async": { - deps: ["jasmine"], - exports: "AsyncSpec" + "jasmine-waituntil": { + deps: ["jquery"] }, "xblock/core": { exports: "XBlock", @@ -191,9 +192,16 @@ requirejs.config({ jasmine.getFixtures().fixturesPath += 'coffee/fixtures' -define([ - "coffee/spec/views/assets_spec", - "js/spec/video/translations_editor_spec", - "js/spec/video/file_uploader_editor_spec", - "js/spec/models/group_configuration_spec" - ]) +testFiles = [ + 'coffee/spec/views/assets_spec', + 'js/spec/video/translations_editor_spec', + 'js/spec/video/file_uploader_editor_spec', + 'js/spec/models/group_configuration_spec' +] +i = 0 +while i < testFiles.length + testFiles[i] = '/base/' + testFiles[i] + '.js' + i++ +require testFiles, -> +# start test run, once Require.js is done + window.__karma__.start() diff --git a/cms/static/coffee/spec/models/section_spec.coffee b/cms/static/coffee/spec/models/section_spec.coffee index 536d3507d6..82fd1c9b3f 100644 --- a/cms/static/coffee/spec/models/section_spec.coffee +++ b/cms/static/coffee/spec/models/section_spec.coffee @@ -34,7 +34,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo }) it "show/hide a notification when it saves to the server", -> - server = AjaxHelpers.server(this, [200, {}, '']) + server = AjaxHelpers.server([200, {}, '']) @model.save() expect(Section.prototype.showNotification).toHaveBeenCalled() @@ -43,7 +43,7 @@ define ["js/models/section", "common/js/spec_helpers/ajax_helpers", "js/utils/mo it "don't hide notification when saving fails", -> # this is handled by the global AJAX error handler - server = AjaxHelpers.server(this, [500, {}, '']) + server = AjaxHelpers.server([500, {}, '']) @model.save() server.respond() diff --git a/cms/static/coffee/spec/models/textbook_spec.coffee b/cms/static/coffee/spec/models/textbook_spec.coffee index fa4f86742c..dd78ba8997 100644 --- a/cms/static/coffee/spec/models/textbook_spec.coffee +++ b/cms/static/coffee/spec/models/textbook_spec.coffee @@ -1,12 +1,6 @@ define ["backbone", "js/models/textbook", "js/collections/textbook", "js/models/chapter", "js/collections/chapter", "coffee/src/main"], (Backbone, Textbook, TextbookSet, Chapter, ChapterSet, main) -> - beforeEach -> - @addMatchers - toBeInstanceOf: (expected) -> - return @actual instanceof expected - - describe "Textbook model", -> beforeEach -> main() diff --git a/cms/static/coffee/spec/views/assets_spec.coffee b/cms/static/coffee/spec/views/assets_spec.coffee index aa24e82ac2..6c82b2e3e2 100644 --- a/cms/static/coffee/spec/views/assets_spec.coffee +++ b/cms/static/coffee/spec/views/assets_spec.coffee @@ -1,25 +1,25 @@ -define ["jquery", "jasmine", "common/js/spec_helpers/ajax_helpers", "squire"], -($, jasmine, AjaxHelpers, Squire) -> +define ["jquery", "common/js/spec_helpers/ajax_helpers", "squire"], +($, AjaxHelpers, Squire) -> assetLibraryTpl = readFixtures('asset-library.underscore') assetTpl = readFixtures('asset.underscore') describe "Asset view", -> - beforeEach -> + beforeEach (done) -> setFixtures($("" + ); schedule_template = _.template($('#schedule_template').html()); save_url = 'save_ccx'; $.fn.leanModal = function(param) { return true; - } + }; data = [{ "category": "chapter", @@ -44,10 +47,8 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/ccx/schedule'], ] }]; view = new edx.ccx.schedule.ScheduleView({el: $('#new-ccx-schedule')}); - view.schedule_collection.set(data) + view.schedule_collection.set(data); view.render(); - - }); it("verifies correct view setup", function() { diff --git a/lms/static/js/spec/dashboard/donation.js b/lms/static/js/spec/dashboard/donation.js index b6a90955c5..55447995ef 100644 --- a/lms/static/js/spec/dashboard/donation.js +++ b/lms/static/js/spec/dashboard/donation.js @@ -29,7 +29,7 @@ define(['common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/ajax_ // This function gets passed the dynamically constructed // form with signed payment parameters from the LMS server, // so we can verify that the form is constructed correctly. - spyOn(view, 'submitPaymentForm').andCallFake(function() {}); + spyOn(view, 'submitPaymentForm').and.callFake(function() {}); // Stub the analytics event tracker window.analytics = jasmine.createSpyObj('analytics', ['track']); @@ -64,7 +64,7 @@ define(['common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/ajax_ // We stub out the actual submission of the form to avoid // leaving the current page during the test. expect(view.submitPaymentForm).toHaveBeenCalled(); - var form = view.submitPaymentForm.mostRecentCall.args[0]; + var form = view.submitPaymentForm.calls.mostRecent().args[0]; expect(form.serialize()).toEqual($.param(PAYMENT_PARAMS)); expect(form.attr('method')).toEqual("post"); expect(form.attr('action')).toEqual(PAYMENT_URL); diff --git a/lms/static/js/spec/dashboard/dropdown_spec.js b/lms/static/js/spec/dashboard/dropdown_spec.js index 2b9b18177c..b3b63a3a5a 100644 --- a/lms/static/js/spec/dashboard/dropdown_spec.js +++ b/lms/static/js/spec/dashboard/dropdown_spec.js @@ -14,15 +14,10 @@ define(['js/dashboard/dropdown', 'jquery.simulate'], verifyDropdownNotVisible = function() { expect($(dropdownSelector)).not.toBeVisible(); }, - waitForElementToBeFocused = function(element, desc) { - // This is being used instead of toBeFocused which is flaky - waitsFor( - function () { - return element === document.activeElement; - }, - desc + ' element to have focus', - 500 - ); + waitForElementToBeFocused = function(element, done) { + jasmine.waitUntil(function () { + return element === document.activeElement; + }).always(done); }, openDropDownMenu = function() { verifyDropdownNotVisible(); @@ -47,39 +42,39 @@ define(['js/dashboard/dropdown', 'jquery.simulate'], clickToggleButton(); verifyDropdownNotVisible(); }); - it("ESCAPE will close dropdown and return focus to the button", function() { + it("ESCAPE will close dropdown and return focus to the button", function(done) { openDropDownMenu(); keydown({ keyCode: keys.ESCAPE }); verifyDropdownNotVisible(); - waitForElementToBeFocused($(toggleButtonSelector)[0], "button"); + waitForElementToBeFocused($(toggleButtonSelector)[0], done); }); - it("SPACE will close dropdown and return focus to the button", function() { + it("SPACE will close dropdown and return focus to the button", function(done) { openDropDownMenu(); keydown({ keyCode: keys.SPACE }); verifyDropdownNotVisible(); - waitForElementToBeFocused($(toggleButtonSelector)[0], "button"); + waitForElementToBeFocused($(toggleButtonSelector)[0], done); }); describe("Focus is trapped when navigating with", function() { - it("TAB key", function() { + it("TAB key", function(done) { openDropDownMenu(); keydown({ keyCode: keys.TAB }); - waitForElementToBeFocused($(dropdownItemSelector)[0], "first"); + waitForElementToBeFocused($(dropdownItemSelector)[0], done); }); - it("DOWN key", function() { + it("DOWN key", function(done) { openDropDownMenu(); keydown({ keyCode: keys.DOWN }); - waitForElementToBeFocused($(dropdownItemSelector)[0], "first"); + waitForElementToBeFocused($(dropdownItemSelector)[0], done); }); - it("TAB key + SHIFT key", function() { + it("TAB key + SHIFT key", function(done) { openDropDownMenu(); keydown({ keyCode: keys.TAB, shiftKey: true }); - waitForElementToBeFocused($(dropdownItemSelector)[1], "last"); + waitForElementToBeFocused($(dropdownItemSelector)[1], done); }); - it("UP key", function() { + it("UP key", function(done) { openDropDownMenu(); keydown({ keyCode: keys.UP }); - waitForElementToBeFocused($(dropdownItemSelector)[1], "last"); + waitForElementToBeFocused($(dropdownItemSelector)[1], done); }); }); }); diff --git a/lms/static/js/spec/discovery/discovery_factory_spec.js b/lms/static/js/spec/discovery/discovery_factory_spec.js index 199323d0ea..2395895946 100644 --- a/lms/static/js/spec/discovery/discovery_factory_spec.js +++ b/lms/static/js/spec/discovery/discovery_factory_spec.js @@ -121,7 +121,7 @@ define([ it('loads more', function () { var requests = AjaxHelpers.requests(this); - jasmine.Clock.useMock(); + jasmine.clock().install(); $('.discovery-input').val('test'); $('.discovery-submit').trigger('click'); AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); @@ -129,12 +129,14 @@ define([ expect($('.courses-listing .course-title')).toContainHtml('edX Demonstration Course'); window.scroll(0, $(document).height()); $(window).trigger('scroll'); - jasmine.Clock.tick(500); + jasmine.clock().tick(500); // TODO: determine why the search API is invoked twice AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); AjaxHelpers.respondWithJson(requests, JSON_RESPONSE); expect($('.courses-listing article').length).toEqual(2); + + jasmine.clock().uninstall(); }); it('displays not found message', function () { diff --git a/lms/static/js/spec/discovery/views/course_card_spec.js b/lms/static/js/spec/discovery/views/course_card_spec.js index 2e3c71c17a..97f49a09c2 100644 --- a/lms/static/js/spec/discovery/views/course_card_spec.js +++ b/lms/static/js/spec/discovery/views/course_card_spec.js @@ -43,8 +43,8 @@ define([ it('renders', function () { var data = this.view.model.attributes; expect(this.view.$el).toContainHtml(data.content.display_name); - expect(this.view.$el).toContain('a[href="/courses/' + data.course + '/about"]'); - expect(this.view.$el).toContain('img[src="' + data.image_url + '"]'); + expect(this.view.$el).toContainElement('a[href="/courses/' + data.course + '/about"]'); + expect(this.view.$el).toContainElement('img[src="' + data.image_url + '"]'); expect(this.view.$el.find('.course-name')).toContainHtml(data.org); expect(this.view.$el.find('.course-name')).toContainHtml(data.content.number); expect(this.view.$el.find('.course-name')).toContainHtml(data.content.display_name); diff --git a/lms/static/js/spec/discovery/views/courses_listing_spec.js b/lms/static/js/spec/discovery/views/courses_listing_spec.js index 2bd4d38b76..0001561773 100644 --- a/lms/static/js/spec/discovery/views/courses_listing_spec.js +++ b/lms/static/js/spec/discovery/views/courses_listing_spec.js @@ -34,7 +34,7 @@ define([ describe('discovery.views.CoursesListing', function () { beforeEach(function () { - jasmine.Clock.useMock(); + jasmine.clock().install(); loadFixtures('js/fixtures/discovery.html'); TemplateHelpers.installTemplate('templates/discovery/course_card'); var collection = new Backbone.Collection( @@ -44,10 +44,14 @@ define([ var mock = { collection: collection, latest: function () { return this.collection.last(20); } - } + }; this.view = new CoursesListing({ model: mock }); }); + afterEach(function() { + jasmine.clock().uninstall(); + }); + it('renders search results', function () { this.view.render(); expect($('.courses-listing article').length).toEqual(1); @@ -62,13 +66,13 @@ define([ this.view.render(); window.scroll(0, $(document).height()); $(window).trigger('scroll'); - jasmine.Clock.tick(500); + jasmine.clock().tick(500); expect(this.onNext).toHaveBeenCalled(); // should not be triggered again (while it is loading) $(window).trigger('scroll'); - jasmine.Clock.tick(500); - expect(this.onNext.calls.length).toEqual(1); + jasmine.clock().tick(500); + expect(this.onNext.calls.count()).toEqual(1); }); }); diff --git a/lms/static/js/spec/edxnotes/custom_matchers.js b/lms/static/js/spec/edxnotes/custom_matchers.js deleted file mode 100644 index c5309bd6c9..0000000000 --- a/lms/static/js/spec/edxnotes/custom_matchers.js +++ /dev/null @@ -1,32 +0,0 @@ -define(['jquery'], function($) { - 'use strict'; - return function (that) { - that.addMatchers({ - toContainText: function (text) { - var trimmedText = $.trim($(this.actual).text()); - - if (text && $.isFunction(text.test)) { - return text.test(trimmedText); - } else { - return trimmedText.indexOf(text) !== -1; - } - }, - - toHaveLength: function (number) { - return $(this.actual).length === number; - }, - - toHaveIndex: function (number) { - return $(this.actual).index() === number; - }, - - toBeInRange: function (min, max) { - return min <= this.actual && this.actual <= max; - }, - - toBeFocused: function () { - return $(this.actual)[0] === $(this.actual)[0].ownerDocument.activeElement; - } - }); - }; -}); diff --git a/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js b/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js index f4128df9b7..e4abb6122a 100644 --- a/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js +++ b/lms/static/js/spec/edxnotes/plugins/accessibility_spec.js @@ -1,6 +1,6 @@ define([ - 'jquery', 'underscore', 'annotator_1.2.9', 'logger', 'js/edxnotes/views/notes_factory', 'js/spec/edxnotes/custom_matchers' -], function($, _, Annotator, Logger, NotesFactory, customMatchers) { + 'jquery', 'underscore', 'annotator_1.2.9', 'logger', 'js/edxnotes/views/notes_factory' +], function($, _, Annotator, Logger, NotesFactory) { 'use strict'; describe('EdxNotes Accessibility Plugin', function() { function keyDownEvent (key) { @@ -25,7 +25,6 @@ define([ beforeEach(function() { this.KEY = $.ui.keyCode; - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); this.annotator = NotesFactory.factory( $('div#edx-notes-wrapper-123').get(0), { @@ -45,7 +44,7 @@ define([ describe('destroy', function () { it('should unbind all events', function () { spyOn($.fn, 'off'); - spyOn(this.annotator, 'unsubscribe').andCallThrough(); + spyOn(this.annotator, 'unsubscribe').and.callThrough(); this.plugin.destroy(); expect(this.annotator.unsubscribe).toHaveBeenCalledWith( 'annotationViewerTextField', this.plugin.addAriaAttributes @@ -82,7 +81,7 @@ define([ this.annotator.viewer.load([annotation]); note = $('.annotator-note'); expect(note).toExist(); - expect(note).toHaveAttr('tabindex', -1); + expect(note).toHaveAttr('tabindex', "-1"); expect(note).toHaveAttr('role', 'note'); expect(note).toHaveAttr('class', 'annotator-note'); }); @@ -116,9 +115,9 @@ define([ highlights: [highlight.get(0)] }; highlight.data('annotation', annotation); - spyOn(this.annotator, 'showViewer').andCallThrough(); - spyOn(this.annotator.viewer, 'hide').andCallThrough(); - spyOn(this.plugin, 'focusOnGrabber').andCallThrough(); + spyOn(this.annotator, 'showViewer').and.callThrough(); + spyOn(this.annotator.viewer, 'hide').and.callThrough(); + spyOn(this.plugin, 'focusOnGrabber').and.callThrough(); }); it('should open the viewer on SPACE keydown and focus on note', function () { @@ -175,7 +174,7 @@ define([ edit= this.annotator.element.find('.annotator-edit').first(); del = this.annotator.element.find('.annotator-delete').first(); close = this.annotator.element.find('.annotator-close').first(); - spyOn(this.annotator.viewer, 'hide').andCallThrough(); + spyOn(this.annotator.viewer, 'hide').and.callThrough(); }); it('should give focus to Note on Listing TAB keydown', function () { @@ -221,7 +220,7 @@ define([ control.focus(); control.trigger(keyDownEvent(this.KEY.ESCAPE)); }, this); - expect(this.annotator.viewer.hide.callCount).toBe(5); + expect(this.annotator.viewer.hide.calls.count()).toBe(5); }); }); @@ -243,8 +242,8 @@ define([ tags = annotatorItems.first().next().children('input'); save = this.annotator.element.find('.annotator-save'); cancel = this.annotator.element.find('.annotator-cancel'); - spyOn(this.annotator.editor, 'submit').andCallThrough(); - spyOn(this.annotator.editor, 'hide').andCallThrough(); + spyOn(this.annotator.editor, 'submit').and.callThrough(); + spyOn(this.annotator.editor, 'hide').and.callThrough(); }); it('should give focus to TextArea on Form TAB keydown', function () { @@ -287,7 +286,7 @@ define([ save.focus(); save.trigger(keyDownEvent(this.KEY.ENTER)); expect(this.annotator.editor.submit).toHaveBeenCalled(); - this.annotator.editor.submit.reset(); + this.annotator.editor.submit.calls.reset(); save.focus(); save.trigger(keyDownEvent(this.KEY.SPACE)); expect(this.annotator.editor.submit).toHaveBeenCalled(); @@ -297,7 +296,7 @@ define([ textArea.focus(); textArea.trigger(enterMetaKeyEvent()); expect(this.annotator.editor.submit).toHaveBeenCalled(); - this.annotator.editor.submit.reset(); + this.annotator.editor.submit.calls.reset(); textArea.focus(); textArea.trigger(enterControlKeyEvent()); expect(this.annotator.editor.submit).toHaveBeenCalled(); @@ -307,7 +306,7 @@ define([ cancel.focus(); cancel.trigger(keyDownEvent(this.KEY.ENTER)); expect(this.annotator.editor.hide).toHaveBeenCalled(); - this.annotator.editor.hide.reset(); + this.annotator.editor.hide.calls.reset(); cancel.focus(); save.trigger(keyDownEvent(this.KEY.SPACE)); expect(this.annotator.editor.hide).toHaveBeenCalled(); @@ -320,7 +319,7 @@ define([ control.focus(); control.trigger(keyDownEvent(this.KEY.ESCAPE)); }, this); - expect(this.annotator.editor.hide.callCount).toBe(3); + expect(this.annotator.editor.hide.calls.count()).toBe(3); }); }); }); diff --git a/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js b/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js index 4bd20371bf..544b6881a3 100644 --- a/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js +++ b/lms/static/js/spec/edxnotes/plugins/caret_navigation_spec.js @@ -87,10 +87,10 @@ define([ this.mockSubscriber = jasmine.createSpy(); this.annotator.subscribe('annotationCreated', this.mockSubscriber); - spyOn($.fn, 'position').andReturn(this.mockOffset); - spyOn(this.annotator, 'createAnnotation').andReturn(this.annotation); - spyOn(this.annotator, 'setupAnnotation').andReturn(this.annotation); - spyOn(this.annotator, 'getSelectedRanges').andReturn([{}]); + spyOn($.fn, 'position').and.returnValue(this.mockOffset); + spyOn(this.annotator, 'createAnnotation').and.returnValue(this.annotation); + spyOn(this.annotator, 'setupAnnotation').and.returnValue(this.annotation); + spyOn(this.annotator, 'getSelectedRanges').and.returnValue([{}]); spyOn(this.annotator, 'deleteAnnotation'); spyOn(this.annotator, 'showEditor'); spyOn(Annotator.Util, 'readRangeViaSelection'); @@ -100,7 +100,7 @@ define([ it('should create a new annotation', function () { triggerEvent(this.element); - expect(this.annotator.createAnnotation.callCount).toBe(1); + expect(this.annotator.createAnnotation.calls.count()).toBe(1); }); it('should set up the annotation', function () { @@ -111,25 +111,25 @@ define([ }); it('should display the Annotation#editor correctly if the Annotation#adder is hidden', function () { - spyOn($.fn, 'is').andReturn(false); + spyOn($.fn, 'is').and.returnValue(false); triggerEvent(this.element); - expect($('annotator-hl-temporary').position.callCount).toBe(1); + expect($('annotator-hl-temporary').position.calls.count()).toBe(1); expect(this.annotator.showEditor).toHaveBeenCalledWith( this.annotation, this.mockOffset ); }); it('should display the Annotation#editor in the same place as the Annotation#adder', function () { - spyOn($.fn, 'is').andReturn(true); + spyOn($.fn, 'is').and.returnValue(true); triggerEvent(this.element); - expect(this.annotator.adder.position.callCount).toBe(1); + expect(this.annotator.adder.position.calls.count()).toBe(1); expect(this.annotator.showEditor).toHaveBeenCalledWith( this.annotation, this.mockOffset ); }); it('should hide the Annotation#adder', function () { - spyOn($.fn, 'is').andReturn(true); + spyOn($.fn, 'is').and.returnValue(true); spyOn($.fn, 'hide'); triggerEvent(this.element); expect(this.annotator.adder.hide).toHaveBeenCalled(); @@ -187,13 +187,13 @@ define([ }); it('should do nothing if empty selection', function () { - this.annotator.getSelectedRanges.andReturn([]); + this.annotator.getSelectedRanges.and.returnValue([]); triggerEvent(this.element); expect(this.annotator.showEditor).not.toHaveBeenCalled(); }); it('should do nothing if selection is in Annotator', function () { - spyOn(this.annotator, 'isAnnotator').andReturn(true); + spyOn(this.annotator, 'isAnnotator').and.returnValue(true); triggerEvent(this.element); expect(this.annotator.showEditor).not.toHaveBeenCalled(); }); diff --git a/lms/static/js/spec/edxnotes/plugins/scroller_spec.js b/lms/static/js/spec/edxnotes/plugins/scroller_spec.js index 77c1d7eeb2..49c54dda6c 100644 --- a/lms/static/js/spec/edxnotes/plugins/scroller_spec.js +++ b/lms/static/js/spec/edxnotes/plugins/scroller_spec.js @@ -1,7 +1,6 @@ define([ - 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/views/notes_factory', - 'js/spec/edxnotes/custom_matchers' -], function($, _, Annotator, NotesFactory, customMatchers) { + 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/views/notes_factory' +], function($, _, Annotator, NotesFactory) { 'use strict'; describe('EdxNotes Scroll Plugin', function() { var annotators, highlights; @@ -19,7 +18,6 @@ define([ } beforeEach(function() { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); annotators = [ NotesFactory.factory($('div#edx-notes-wrapper-123').get(0), { @@ -31,9 +29,9 @@ define([ ]; highlights = _.map(annotators, function(annotator) { - spyOn(annotator, 'onHighlightClick').andCallThrough(); - spyOn(annotator, 'onHighlightMouseover').andCallThrough(); - spyOn(annotator, 'startViewerHideTimer').andCallThrough(); + spyOn(annotator, 'onHighlightClick').and.callThrough(); + spyOn(annotator, 'onHighlightMouseover').and.callThrough(); + spyOn(annotator, 'startViewerHideTimer').and.callThrough(); return $('', { 'class': 'annotator-hl', 'tabindex': -1, @@ -41,8 +39,8 @@ define([ }).appendTo(annotator.element); }); - spyOn(annotators[0].plugins.Scroller, 'getIdFromLocationHash').andReturn('abc123'); - spyOn($.fn, 'unbind').andCallThrough(); + spyOn(annotators[0].plugins.Scroller, 'getIdFromLocationHash').and.returnValue('abc123'); + spyOn($.fn, 'unbind').and.callThrough(); }); afterEach(function () { @@ -56,7 +54,7 @@ define([ id: 'abc123', highlights: [highlights[0]] }]); - annotators[0].onHighlightMouseover.reset(); + annotators[0].onHighlightMouseover.calls.reset(); expect(highlights[0]).toBeFocused(); highlights[0].mouseover(); highlights[0].mouseout(); diff --git a/lms/static/js/spec/edxnotes/utils/logger_spec.js b/lms/static/js/spec/edxnotes/utils/logger_spec.js index a84bad0eac..802ec55e72 100644 --- a/lms/static/js/spec/edxnotes/utils/logger_spec.js +++ b/lms/static/js/spec/edxnotes/utils/logger_spec.js @@ -1,6 +1,6 @@ define([ - 'logger', 'js/edxnotes/utils/logger', 'js/spec/edxnotes/custom_matchers' -], function(Logger, NotesLogger, customMatchers) { + 'logger', 'js/edxnotes/utils/logger' +], function(Logger, NotesLogger) { 'use strict'; describe('Edxnotes NotesLogger', function() { var getLogger = function(id, mode) { @@ -11,7 +11,6 @@ define([ spyOn(window.console, 'log'); spyOn(window.console, 'error'); spyOn(Logger, 'log'); - customMatchers(this); }); it('keeps a correct history of logs', function() { @@ -94,11 +93,11 @@ define([ it('can use timers', function() { var logger = getLogger('id', 1), logs, log; - spyOn(performance, 'now').andReturn(1); - spyOn(Date, 'now').andReturn(1); + spyOn(performance, 'now').and.returnValue(1); + spyOn(Date, 'now').and.returnValue(1); logger.time('timer'); - performance.now.andReturn(201); - Date.now.andReturn(201); + performance.now.and.returnValue(201); + Date.now.and.returnValue(201); logger.timeEnd('timer'); logs = logger.getHistory(); diff --git a/lms/static/js/spec/edxnotes/views/note_item_spec.js b/lms/static/js/spec/edxnotes/views/note_item_spec.js index 84ea75a39f..4b3a0af123 100644 --- a/lms/static/js/spec/edxnotes/views/note_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/note_item_spec.js @@ -2,10 +2,8 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'common/js/spec_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'logger', 'js/edxnotes/models/note', 'js/edxnotes/views/note_item', - 'js/spec/edxnotes/custom_matchers' ], function( - $, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView, - customMatchers + $, _, AjaxHelpers, TemplateHelpers, Helpers, Logger, NoteModel, NoteItemView ) { 'use strict'; describe('EdxNotes NoteItemView', function() { @@ -27,16 +25,15 @@ define([ }; beforeEach(function() { - customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/note-item'); - spyOn(Logger, 'log').andCallThrough(); + spyOn(Logger, 'log').and.callThrough(); }); it('can be rendered properly', function() { var view = getView(), unitLink = view.$('.reference-unit-link').get(0); - expect(view.$el).toContain('.note-excerpt-more-link'); + expect(view.$el).toContainElement('.note-excerpt-more-link'); expect(view.$el).toContainText(Helpers.PRUNED_TEXT); expect(view.$el).toContainText('More'); view.$('.note-excerpt-more-link').click(); diff --git a/lms/static/js/spec/edxnotes/views/notes_factory_spec.js b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js index dda98b8b59..0865da6ee0 100644 --- a/lms/static/js/spec/edxnotes/views/notes_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_factory_spec.js @@ -1,11 +1,10 @@ define([ 'annotator_1.2.9', 'js/edxnotes/views/notes_factory', 'common/js/spec_helpers/ajax_helpers', - 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' -], function(Annotator, NotesFactory, AjaxHelpers, Helpers, customMatchers) { + 'js/spec/edxnotes/helpers' +], function(Annotator, NotesFactory, AjaxHelpers, Helpers) { 'use strict'; describe('EdxNotes NotesFactory', function() { beforeEach(function() { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); this.wrapper = document.getElementById('edx-notes-wrapper-123'); }); diff --git a/lms/static/js/spec/edxnotes/views/notes_page_spec.js b/lms/static/js/spec/edxnotes/views/notes_page_spec.js index 2c2ad249bf..79388f9081 100644 --- a/lms/static/js/spec/edxnotes/views/notes_page_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_page_spec.js @@ -1,14 +1,13 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/ajax_helpers', 'js/spec/edxnotes/helpers', - 'js/edxnotes/views/page_factory', 'js/spec/edxnotes/custom_matchers' -], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory, customMatchers) { + 'js/edxnotes/views/page_factory' +], function($, _, TemplateHelpers, AjaxHelpers, Helpers, NotesFactory) { 'use strict'; describe('EdxNotes NotesPage', function() { var notes = Helpers.getDefaultNotes(); beforeEach(function() { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' diff --git a/lms/static/js/spec/edxnotes/views/notes_visibility_factory_spec.js b/lms/static/js/spec/edxnotes/views/notes_visibility_factory_spec.js index cf2f3fa7ff..26e1db8008 100644 --- a/lms/static/js/spec/edxnotes/views/notes_visibility_factory_spec.js +++ b/lms/static/js/spec/edxnotes/views/notes_visibility_factory_spec.js @@ -1,9 +1,8 @@ define([ 'jquery', 'underscore', 'annotator_1.2.9', 'common/js/spec_helpers/ajax_helpers', - 'js/edxnotes/views/notes_visibility_factory', 'js/spec/edxnotes/helpers', - 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' + 'js/edxnotes/views/notes_visibility_factory', 'js/spec/edxnotes/helpers' ], function( - $, _, Annotator, AjaxHelpers, NotesVisibilityFactory, Helpers, customMatchers + $, _, Annotator, AjaxHelpers, NotesVisibilityFactory, Helpers ) { 'use strict'; describe('EdxNotes ToggleNotesFactory', function() { @@ -17,7 +16,6 @@ define([ }; beforeEach(function() { - customMatchers(this); loadFixtures( 'js/fixtures/edxnotes/edxnotes_wrapper.html', 'js/fixtures/edxnotes/toggle_notes.html' @@ -32,7 +30,7 @@ define([ this.button = $('.action-toggle-notes'); this.label = this.button.find('.utility-control-label'); this.toggleMessage = $('.action-toggle-message'); - spyOn(this.toggleNotes, 'toggleHandler').andCallThrough(); + spyOn(this.toggleNotes, 'toggleHandler').and.callThrough(); }); afterEach(function () { diff --git a/lms/static/js/spec/edxnotes/views/search_box_spec.js b/lms/static/js/spec/edxnotes/views/search_box_spec.js index 3b1e780fcd..9c7bfceba5 100644 --- a/lms/static/js/spec/edxnotes/views/search_box_spec.js +++ b/lms/static/js/spec/edxnotes/views/search_box_spec.js @@ -1,7 +1,7 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers', 'js/edxnotes/views/search_box', - 'js/edxnotes/collections/notes', 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/helpers', 'jasmine-jquery' -], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, customMatchers, Helpers) { + 'js/edxnotes/collections/notes', 'js/spec/edxnotes/helpers' +], function($, _, AjaxHelpers, SearchBoxView, NotesCollection, Helpers) { 'use strict'; describe('EdxNotes SearchBoxView', function() { var getSearchBox, submitForm, assertBoxIsEnabled, assertBoxIsDisabled, searchResponse; @@ -47,7 +47,6 @@ define([ }; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); spyOn(Logger, 'log'); this.searchBox = getSearchBox(); diff --git a/lms/static/js/spec/edxnotes/views/shim_spec.js b/lms/static/js/spec/edxnotes/views/shim_spec.js index c568f6cb13..37795ce8cb 100644 --- a/lms/static/js/spec/edxnotes/views/shim_spec.js +++ b/lms/static/js/spec/edxnotes/views/shim_spec.js @@ -1,5 +1,5 @@ define([ - 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/views/notes_factory', 'jasmine-jquery' + 'jquery', 'underscore', 'annotator_1.2.9', 'js/edxnotes/views/notes_factory' ], function($, _, Annotator, NotesFactory) { 'use strict'; describe('EdxNotes Shim', function() { @@ -38,11 +38,11 @@ define([ ]; _.each(annotators, function(annotator) { highlights.push($('').appendTo(annotator.element)); - spyOn(annotator, 'onHighlightClick').andCallThrough(); - spyOn(annotator, 'onHighlightMouseover').andCallThrough(); - spyOn(annotator, 'startViewerHideTimer').andCallThrough(); + spyOn(annotator, 'onHighlightClick').and.callThrough(); + spyOn(annotator, 'onHighlightMouseover').and.callThrough(); + spyOn(annotator, 'startViewerHideTimer').and.callThrough(); }); - spyOn($.fn, 'off').andCallThrough(); + spyOn($.fn, 'off').and.callThrough(); }); afterEach(function () { @@ -59,9 +59,9 @@ define([ }); it('clicking on highlights does not open the viewer when the editor is opened', function() { - spyOn(annotators[1].editor, 'isShown').andReturn(false); + spyOn(annotators[1].editor, 'isShown').and.returnValue(false); highlights[0].click(); - annotators[1].editor.isShown.andReturn(true); + annotators[1].editor.isShown.and.returnValue(true); highlights[1].click(); expect($('#edx-notes-wrapper-123 .annotator-viewer')).not.toHaveClass('annotator-hide'); expect($('#edx-notes-wrapper-456 .annotator-viewer')).toHaveClass('annotator-hide'); @@ -76,7 +76,7 @@ define([ // in turn calls onHighlightMouseover. // To test if onHighlightMouseover is called or not on // mouseover, we'll have to reset onHighlightMouseover. - annotators[0].onHighlightMouseover.reset(); + annotators[0].onHighlightMouseover.calls.reset(); // Check that both instances of annotator are frozen _.invoke(highlights, 'mouseover'); _.invoke(highlights, 'mouseout'); @@ -86,7 +86,7 @@ define([ it('clicking twice reverts to default behavior', function() { highlights[0].click(); $(document).click(); - annotators[0].onHighlightMouseover.reset(); + annotators[0].onHighlightMouseover.calls.reset(); // Check that both instances of annotator are unfrozen _.invoke(highlights, 'mouseover'); @@ -116,7 +116,7 @@ define([ 'and unbinds one document click.edxnotes:freeze event handlers', function() { // Freeze all instances highlights[0].click(); - annotators[0].onHighlightMouseover.reset(); + annotators[0].onHighlightMouseover.calls.reset(); // Destroy second instance annotators[1].destroy(); @@ -163,17 +163,17 @@ define([ element: element }; - mockViewer.on = jasmine.createSpy().andReturn(mockViewer); - mockViewer.hide = jasmine.createSpy().andReturn(mockViewer); - mockViewer.destroy = jasmine.createSpy().andReturn(mockViewer); - mockViewer.addField = jasmine.createSpy().andCallFake(function (options) { + mockViewer.on = jasmine.createSpy().and.returnValue(mockViewer); + mockViewer.hide = jasmine.createSpy().and.returnValue(mockViewer); + mockViewer.destroy = jasmine.createSpy().and.returnValue(mockViewer); + mockViewer.addField = jasmine.createSpy().and.callFake(function (options) { mockViewer.fields.push(options); return mockViewer; }); - spyOn(element, 'bind').andReturn(element); - spyOn(element, 'appendTo').andReturn(element); - spyOn(Annotator, 'Viewer').andReturn(mockViewer); + spyOn(element, 'bind').and.returnValue(element); + spyOn(element, 'appendTo').and.returnValue(element); + spyOn(Annotator, 'Viewer').and.returnValue(mockViewer); annotators[0]._setupViewer(); }); @@ -183,13 +183,13 @@ define([ }); it('should hide the annotator on creation', function () { - expect(mockViewer.hide.callCount).toBe(1); + expect(mockViewer.hide.calls.count()).toBe(1); }); it('should setup the default text field', function () { - var args = mockViewer.addField.mostRecentCall.args[0]; + var args = mockViewer.addField.calls.mostRecent().args[0]; - expect(mockViewer.addField.callCount).toBe(1); + expect(mockViewer.addField.calls.count()).toBe(1); expect(_.isFunction(args.load)).toBeTruthy(); }); diff --git a/lms/static/js/spec/edxnotes/views/tab_item_spec.js b/lms/static/js/spec/edxnotes/views/tab_item_spec.js index d455f2d6f9..4e7cc208f3 100644 --- a/lms/static/js/spec/edxnotes/views/tab_item_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_item_spec.js @@ -1,11 +1,10 @@ define([ 'jquery', 'common/js/spec_helpers/template_helpers', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' -], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) { + 'js/edxnotes/views/tabs_list' +], function($, TemplateHelpers, TabsCollection, TabsListView) { 'use strict'; describe('EdxNotes TabItemView', function() { beforeEach(function () { - customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); this.collection = new TabsCollection([ {identifier: 'first-item'}, diff --git a/lms/static/js/spec/edxnotes/views/tab_view_spec.js b/lms/static/js/spec/edxnotes/views/tab_view_spec.js index 690d5f3339..6fb7400c27 100644 --- a/lms/static/js/spec/edxnotes/views/tab_view_spec.js +++ b/lms/static/js/spec/edxnotes/views/tab_view_spec.js @@ -1,9 +1,8 @@ define([ 'jquery', 'backbone', 'common/js/spec_helpers/template_helpers', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs_list', 'js/edxnotes/views/tab_view', - 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' + 'js/edxnotes/views/tabs_list', 'js/edxnotes/views/tab_view' ], function( - $, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView, customMatchers + $, Backbone, TemplateHelpers, TabsCollection, TabsListView, TabView ) { 'use strict'; describe('EdxNotes TabView', function() { @@ -41,7 +40,6 @@ define([ }; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' diff --git a/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js index 39b01573bd..f372118456 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/course_structure_spec.js @@ -1,11 +1,9 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs/course_structure', 'js/spec/edxnotes/custom_matchers', - 'jasmine-jquery' + 'js/edxnotes/views/tabs/course_structure' ], function( - $, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, CourseStructureView, - customMatchers + $, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, CourseStructureView ) { 'use strict'; describe('EdxNotes CourseStructureView', function() { @@ -34,7 +32,6 @@ define([ }; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' diff --git a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js index fa18680809..de731caf34 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/recent_activity_spec.js @@ -1,9 +1,9 @@ define([ 'jquery', 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/ajax_helpers', 'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/recent_activity', - 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/helpers', 'jasmine-jquery' + 'js/spec/edxnotes/helpers' ], function( - $, TemplateHelpers, AjaxHelpers, NotesCollection, TabsCollection, RecentActivityView, customMatchers, Helpers + $, TemplateHelpers, AjaxHelpers, NotesCollection, TabsCollection, RecentActivityView, Helpers ) { 'use strict'; describe('EdxNotes RecentActivityView', function() { @@ -64,7 +64,6 @@ define([ recentActivityTabId = '#recent-panel'; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' diff --git a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js index 58286bf1e8..df24fda839 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/search_results_spec.js @@ -1,10 +1,9 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/template_helpers', 'common/js/spec_helpers/ajax_helpers', 'logger', 'js/edxnotes/collections/tabs', 'js/edxnotes/views/tabs/search_results', - 'js/spec/edxnotes/custom_matchers', 'js/spec/edxnotes/helpers', 'jasmine-jquery' + 'js/spec/edxnotes/helpers' ], function( - $, _, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView, - customMatchers, Helpers + $, _, TemplateHelpers, AjaxHelpers, Logger, TabsCollection, SearchResultsView, Helpers ) { 'use strict'; describe('EdxNotes SearchResultsView', function() { @@ -69,7 +68,6 @@ define([ searchResultsTabId = "#search-results-panel"; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' @@ -146,7 +144,7 @@ define([ it('can clear search results if tab is closed', function () { var view = getView(this.tabsCollection), requests = AjaxHelpers.requests(this); - spyOn(view.searchBox, 'clearInput').andCallThrough(); + spyOn(view.searchBox, 'clearInput').and.callThrough(); submitForm(view.searchBox, 'test_query'); Helpers.respondToRequest(requests, responseJson, true); diff --git a/lms/static/js/spec/edxnotes/views/tabs/tags_spec.js b/lms/static/js/spec/edxnotes/views/tabs/tags_spec.js index 811167f589..fdeeec1dde 100644 --- a/lms/static/js/spec/edxnotes/views/tabs/tags_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs/tags_spec.js @@ -1,11 +1,9 @@ define([ 'jquery', 'underscore', 'common/js/spec_helpers/template_helpers', 'js/spec/edxnotes/helpers', 'js/edxnotes/collections/notes', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs/tags', 'js/spec/edxnotes/custom_matchers', - 'jasmine-jquery' + 'js/edxnotes/views/tabs/tags' ], function( - $, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, TagsView, - customMatchers + $, _, TemplateHelpers, Helpers, NotesCollection, TabsCollection, TagsView ) { 'use strict'; describe('EdxNotes TagsView', function() { @@ -38,7 +36,6 @@ define([ }; beforeEach(function () { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes.html'); TemplateHelpers.installTemplates([ 'templates/edxnotes/note-item', 'templates/edxnotes/tab-item' diff --git a/lms/static/js/spec/edxnotes/views/tabs_list_spec.js b/lms/static/js/spec/edxnotes/views/tabs_list_spec.js index 35bddcd34d..5109405c79 100644 --- a/lms/static/js/spec/edxnotes/views/tabs_list_spec.js +++ b/lms/static/js/spec/edxnotes/views/tabs_list_spec.js @@ -1,11 +1,10 @@ define([ 'jquery', 'common/js/spec_helpers/template_helpers', 'js/edxnotes/collections/tabs', - 'js/edxnotes/views/tabs_list', 'js/spec/edxnotes/custom_matchers', 'jasmine-jquery' -], function($, TemplateHelpers, TabsCollection, TabsListView, customMatchers) { + 'js/edxnotes/views/tabs_list' +], function($, TemplateHelpers, TabsCollection, TabsListView) { 'use strict'; describe('EdxNotes TabsListView', function() { beforeEach(function () { - customMatchers(this); TemplateHelpers.installTemplate('templates/edxnotes/tab-item'); this.collection = new TabsCollection([ {identifier: 'first-item'}, diff --git a/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js index ddd9c651b1..3aa99b33dc 100644 --- a/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js +++ b/lms/static/js/spec/edxnotes/views/visibility_decorator_spec.js @@ -1,7 +1,7 @@ define([ 'annotator_1.2.9', 'js/edxnotes/views/visibility_decorator', - 'js/spec/edxnotes/helpers', 'js/spec/edxnotes/custom_matchers' -], function(Annotator, VisibilityDecorator, Helpers, customMatchers) { + 'js/spec/edxnotes/helpers' +], function(Annotator, VisibilityDecorator, Helpers) { 'use strict'; describe('EdxNotes VisibilityDecorator', function() { var params = { @@ -14,7 +14,6 @@ define([ }; beforeEach(function() { - customMatchers(this); loadFixtures('js/fixtures/edxnotes/edxnotes_wrapper.html'); this.wrapper = document.getElementById('edx-notes-wrapper-123'); }); diff --git a/lms/static/js/spec/financial-assistance/financial_assistance_form_view_spec.js b/lms/static/js/spec/financial-assistance/financial_assistance_form_view_spec.js index 2aca04e9f6..cc1a68fcd3 100644 --- a/lms/static/js/spec/financial-assistance/financial_assistance_form_view_spec.js +++ b/lms/static/js/spec/financial-assistance/financial_assistance_form_view_spec.js @@ -108,7 +108,7 @@ define([ view.$('#financial-assistance-course').val(selectValue); view.$('#financial-assistance-income').val(1312); - view.$('textarea').html('w'.repeat(801)); + view.$('textarea').html(Array(802).join("w")); }; validSubmission = function() { diff --git a/lms/static/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js index ce9d81658c..9763baee7a 100644 --- a/lms/static/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js +++ b/lms/static/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js @@ -31,7 +31,7 @@ define([ it('bind the ajax call and the result will be success', function() { var submitCallback; - spyOn($, "ajax").andCallFake(function(params) { + spyOn($, "ajax").and.callFake(function(params) { params.success({ row_errors: {}, general_errors: [], @@ -41,7 +41,7 @@ define([ always: function() {} }; }); - submitCallback = jasmine.createSpy().andReturn(); + submitCallback = jasmine.createSpy().and.returnValue(); this.view.$el.find(SELECTORS.bulk_white_list_exception_form).submit(submitCallback); this.view.$el.find(SELECTORS.upload_csv_button).click(); expect($(SELECTORS.bulk_exception_results).text()).toContain('1 learner is successfully added to the ' + @@ -50,7 +50,7 @@ define([ it('bind the ajax call and the result will be general error', function() { var submitCallback; - spyOn($, "ajax").andCallFake(function(params) { + spyOn($, "ajax").and.callFake(function(params) { params.success({ row_errors: {}, general_errors: ["File is not attached."], @@ -60,7 +60,7 @@ define([ always: function() {} }; }); - submitCallback = jasmine.createSpy().andReturn(); + submitCallback = jasmine.createSpy().and.returnValue(); this.view.$el.find(SELECTORS.bulk_white_list_exception_form).submit(submitCallback); this.view.$el.find(SELECTORS.upload_csv_button).click(); expect($(SELECTORS.bulk_exception_results).text()).toContain('File is not attached.'); @@ -68,7 +68,7 @@ define([ it('bind the ajax call and the result will be singular form of row errors', function() { var submitCallback; - spyOn($, "ajax").andCallFake(function(params) { + spyOn($, "ajax").and.callFake(function(params) { params.success({ general_errors: [], row_errors: { @@ -83,7 +83,7 @@ define([ always: function() {} }; }); - submitCallback = jasmine.createSpy().andReturn(); + submitCallback = jasmine.createSpy().and.returnValue(); this.view.$el.find(SELECTORS.bulk_white_list_exception_form).submit(submitCallback); this.view.$el.find(SELECTORS.upload_csv_button).click(); expect($(SELECTORS.bulk_exception_results).text()).toContain('1 record is not in correct format'); @@ -95,7 +95,7 @@ define([ it('bind the ajax call and the result will be plural form of row errors', function() { var submitCallback; - spyOn($, "ajax").andCallFake(function(params) { + spyOn($, "ajax").and.callFake(function(params) { params.success({ general_errors: [], row_errors: { @@ -110,7 +110,7 @@ define([ always: function() {} }; }); - submitCallback = jasmine.createSpy().andReturn(); + submitCallback = jasmine.createSpy().and.returnValue(); this.view.$el.find(SELECTORS.bulk_white_list_exception_form).submit(submitCallback); this.view.$el.find(SELECTORS.upload_csv_button).click(); expect($(SELECTORS.bulk_exception_results).text()).toContain('2 records are not in correct format'); @@ -122,7 +122,7 @@ define([ it('toggle message details', function() { var submitCallback; - spyOn($, "ajax").andCallFake(function(params) { + spyOn($, "ajax").and.callFake(function(params) { params.success({ row_errors: {}, general_errors: [], @@ -132,7 +132,7 @@ define([ always: function() {} }; }); - submitCallback = jasmine.createSpy().andReturn(); + submitCallback = jasmine.createSpy().and.returnValue(); this.view.$el.find(SELECTORS.bulk_white_list_exception_form).submit(submitCallback); this.view.$el.find(SELECTORS.upload_csv_button).click(); expect(this.view.$el.find("div.message > .successfully-added")).toBeHidden(); diff --git a/lms/static/js/spec/instructor_dashboard/certificates_spec.js b/lms/static/js/spec/instructor_dashboard/certificates_spec.js index 6db15104dd..870ca8202b 100644 --- a/lms/static/js/spec/instructor_dashboard/certificates_spec.js +++ b/lms/static/js/spec/instructor_dashboard/certificates_spec.js @@ -65,21 +65,21 @@ define([ }); it("does not regenerate certificates if user cancels operation in confirm popup", function() { - spyOn(window, 'confirm').andReturn(false); + spyOn(window, 'confirm').and.returnValue(false); $regenerate_certificates_button.click(); expect(window.confirm).toHaveBeenCalled(); AjaxHelpers.expectNoRequests(requests); }); it("sends regenerate certificates request if user accepts operation in confirm popup", function() { - spyOn(window, 'confirm').andReturn(true); + spyOn(window, 'confirm').and.returnValue(true); $regenerate_certificates_button.click(); expect(window.confirm).toHaveBeenCalled(); AjaxHelpers.expectRequest(requests, 'POST', expected.url); }); it("sends regenerate certificates request with selected certificate statuses", function() { - spyOn(window, 'confirm').andReturn(true); + spyOn(window, 'confirm').and.returnValue(true); select_options(expected.selected_statuses); @@ -88,7 +88,7 @@ define([ }); it("displays error message in case of server side error", function() { - spyOn(window, 'confirm').andReturn(true); + spyOn(window, 'confirm').and.returnValue(true); select_options(expected.selected_statuses); $regenerate_certificates_button.click(); @@ -97,7 +97,7 @@ define([ }); it("displays error message returned by the server in case of unsuccessful request", function() { - spyOn(window, 'confirm').andReturn(true); + spyOn(window, 'confirm').and.returnValue(true); select_options(expected.selected_statuses); $regenerate_certificates_button.click(); @@ -106,7 +106,7 @@ define([ }); it("displays success message returned by the server in case of successful request", function() { - spyOn(window, 'confirm').andReturn(true); + spyOn(window, 'confirm').and.returnValue(true); select_options(expected.selected_statuses); $regenerate_certificates_button.click(); diff --git a/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js b/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js index 273fbd6f53..c2c8d7cb8c 100644 --- a/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js +++ b/lms/static/js/spec/instructor_dashboard/ecommerce_spec.js @@ -24,7 +24,7 @@ define(['backbone', 'jquery', 'js/instructor_dashboard/ecommerce'], var target = expiryCouponView.$el.find('input[type="checkbox"]'); target.attr("checked","checked"); target.click(); - expect(expiryCouponView.$el.find('#coupon_expiration_date')).toHaveAttr('style','display: inline;'); + expect(expiryCouponView.$el.find('#coupon_expiration_date').is(':visible')).toBe(true); }); it("hides the input field when the checkbox is unchecked", function () { diff --git a/lms/static/js/spec/instructor_dashboard/student_admin_spec.js b/lms/static/js/spec/instructor_dashboard/student_admin_spec.js index fe432883c5..662930be63 100644 --- a/lms/static/js/spec/instructor_dashboard/student_admin_spec.js +++ b/lms/static/js/spec/instructor_dashboard/student_admin_spec.js @@ -17,7 +17,7 @@ define(['jquery', 'coffee/src/instructor_dashboard/student_admin', 'common/js/sp dashboard_api_url = '/courses/PU/FSc/2014_T4/instructor/api'; unique_student_identifier = "test@example.com"; alert_msg = ''; - spyOn(window, 'alert').andCallFake(function(message) { + spyOn(window, 'alert').and.callFake(function(message) { alert_msg = message; }); diff --git a/lms/static/js/spec/learner_dashboard/certificate_view_spec.js b/lms/static/js/spec/learner_dashboard/certificate_view_spec.js new file mode 100644 index 0000000000..92e61daa07 --- /dev/null +++ b/lms/static/js/spec/learner_dashboard/certificate_view_spec.js @@ -0,0 +1,65 @@ +define([ + 'backbone', + 'jquery', + 'js/learner_dashboard/views/certificate_view' + ], function (Backbone, $, CertificateView) { + + 'use strict'; + describe('Certificate View', function () { + var view = null, + data = { + context: { + certificatesData: [ + { + "display_name": "Testing", + "credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-1/" + }, + { + "display_name": "Testing2", + "credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-2/" + } + ], + xseriesImage: "/images/testing.png" + } + }; + + beforeEach(function() { + setFixtures('
'); + view = new CertificateView(data); + view.render(); + }); + + afterEach(function() { + view.remove(); + }); + + it('should exist', function() { + expect(view).toBeDefined(); + }); + + it('should load the certificates based on passed in certificates list', function() { + var $certificates = view.$el.find('.certificate-box'); + expect($certificates.length).toBe(2); + + $certificates.each(function(index, el){ + expect($(el).html().trim()).toEqual(data.context.certificatesData[index].display_name); + expect($(el).attr('href')).toEqual(data.context.certificatesData[index].credential_url); + }); + expect(view.$el.find('.title').html().trim()).toEqual('XSeries Program Certificates:'); + expect(view.$el.find('img').attr('src')).toEqual('/images/testing.png'); + }); + + it('should display no certificate box if certificates list is empty', function() { + var $certificate; + view.remove(); + setFixtures('
'); + view = new CertificateView({ + context: {certificatesData: []} + }); + view.render(); + $certificate = view.$el.find('.certificate-box'); + expect($certificate.length).toBe(0); + }); + }); + } +); diff --git a/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js b/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js index 1499fce40b..fcfaf1ffbd 100644 --- a/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/collection_list_view_spec.js @@ -95,6 +95,7 @@ define([ view = new CollectionListView({ el: '.program-cards-container', childView: ProgramCardView, + context: {'xseriesUrl': '/programs'}, collection: programCollection }); view.render(); diff --git a/lms/static/js/spec/learner_dashboard/program_card_view_spec.js b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js index f0151c8ba8..43eac9542f 100644 --- a/lms/static/js/spec/learner_dashboard/program_card_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/program_card_view_spec.js @@ -67,7 +67,7 @@ define([ }); it('should handle exceptions from reEvaluatePicture', function(){ - spyOn(view, 'reEvaluatePicture').andCallFake(function(){ + spyOn(view, 'reEvaluatePicture').and.callFake(function(){ throw {name:'Picturefill had exceptions'}; }); view.reLoadBannerImage(); diff --git a/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js b/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js index 295d25c3be..14f57bd91f 100644 --- a/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js +++ b/lms/static/js/spec/learner_dashboard/sidebar_view_spec.js @@ -10,7 +10,14 @@ define([ describe('Sidebar View', function () { var view = null, context = { - xseriesUrl: 'http://www.edx.org/xseries' + xseriesUrl: 'http://www.edx.org/xseries', + certificatesData: [ + { + "display_name": "Testing", + "credential_url": "https://credentials.stage.edx.org/credentials/dummy-uuid-1/" + } + ], + xseriesImage: '/image/test.png' }; beforeEach(function() { @@ -38,17 +45,23 @@ define([ expect($sidebar.find('.program-advertise .ad-link a').attr('href')).toEqual(context.xseriesUrl); }); + it('should load the certificates based on passed in certificates list', function() { + expect(view.$('.certificate-box').length).toBe(1); + }); + it('should not load the xseries advertising if no xseriesUrl passed in', function(){ var $ad; view.remove(); view = new SidebarView({ el: '.sidebar', - context: {} + context: {certificatesData: []} }); view.render(); $ad = view.$el.find('.program-advertise'); expect($ad.length).toBe(0); + expect(view.$('.certificate-box').length).toBe(0); }); + }); } ); diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 0287a265e7..b8942877cd 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -1,6 +1,8 @@ (function(requirejs, define) { // TODO: how can we share the vast majority of this config that is in common with CMS? requirejs.config({ + baseUrl: '/base/', + paths: { 'gettext': 'xmodule_js/common_static/js/test/i18n', 'codemirror': 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror', @@ -29,8 +31,8 @@ 'moment': 'xmodule_js/common_static/js/vendor/moment.min', 'moment-with-locales': 'xmodule_js/common_static/js/vendor/moment-with-locales.min', 'text': 'xmodule_js/common_static/js/vendor/requirejs/text', - 'underscore': 'xmodule_js/common_static/common/js/vendor/underscore', - 'underscore.string': 'xmodule_js/common_static/common/js/vendor/underscore.string', + 'underscore': 'common/js/vendor/underscore', + 'underscore.string': 'common/js/vendor/underscore.string', 'backbone': 'xmodule_js/common_static/js/vendor/backbone-min', 'backbone.associations': 'xmodule_js/common_static/js/vendor/backbone-associations-min', 'backbone.paginator': 'xmodule_js/common_static/js/vendor/backbone.paginator.min', @@ -43,10 +45,10 @@ 'accessibility': 'xmodule_js/common_static/js/src/accessibility_tools', 'sinon': 'xmodule_js/common_static/js/vendor/sinon-1.17.0', 'squire': 'xmodule_js/common_static/js/vendor/Squire', - 'jasmine-jquery': 'xmodule_js/common_static/js/vendor/jasmine-jquery', 'jasmine-imagediff': 'xmodule_js/common_static/js/vendor/jasmine-imagediff', 'jasmine-stealth': 'xmodule_js/common_static/js/vendor/jasmine-stealth', - 'jasmine.async': 'xmodule_js/common_static/js/vendor/jasmine.async', + 'jasmine-waituntil': 'xmodule_js/common_static/js/libs/jasmine-waituntil', + 'jasmine-extensions': 'xmodule_js/common_static/js/libs/jasmine-extensions', 'domReady': 'xmodule_js/common_static/js/vendor/domReady', 'mathjax': '//cdn.mathjax.org/mathjax/2.6-latest/MathJax.js?config=TeX-MML-AM_SVG&delayStartupUntil=configured', // jshint ignore:line 'youtube': '//www.youtube.com/player_api?noext', @@ -102,8 +104,8 @@ // Common edx utils 'common/js/utils/edx.utils.validate': 'xmodule_js/common_static/common/js/utils/edx.utils.validate', - 'slick.grid': 'xmodule_js/common_static/js/vendor/slick.grid', - 'slick.core': 'xmodule_js/common_static/js/vendor/slick.core' + 'slick.core': 'xmodule_js/common_static/js/vendor/slick.core', + 'slick.grid': 'xmodule_js/common_static/js/vendor/slick.grid' }, shim: { 'gettext': { @@ -256,18 +258,15 @@ 'sinon': { exports: 'sinon' }, - 'jasmine-jquery': { - deps: ['jasmine'] - }, - 'jasmine-imagediff': { - deps: ['jasmine'] - }, + 'jasmine-imagediff': {}, 'jasmine-stealth': { - deps: ['jasmine'] + deps: ['underscore', 'underscore.string'] }, - 'jasmine.async': { - deps: ['jasmine'], - exports: 'AsyncSpec' + 'jasmine-waituntil': { + deps: ['jquery'] + }, + 'jasmine-extensions': { + deps: ['jquery'] }, 'xblock/core': { exports: 'XBlock', @@ -366,11 +365,18 @@ deps: [ 'jquery', 'underscore', 'underscore.string', 'backbone', 'gettext' ], init: function() { // Set global variables that the payment code is expecting to be defined - window._ = require('underscore'); - window._.str = require('underscore.string'); - window.edx = edx || {}; - window.edx.HtmlUtils = require('edx-ui-toolkit/js/utils/html-utils'); - window.edx.StringUtils = require('edx-ui-toolkit/js/utils/string-utils'); + require([ + 'underscore', + 'underscore.string', + 'edx-ui-toolkit/js/utils/html-utils', + 'edx-ui-toolkit/js/utils/string-utils' + ], function (_, str, HtmlUtils, StringUtils) { + window._ = _; + window._.str = str; + window.edx = edx || {}; + window.edx.HtmlUtils = HtmlUtils; + window.edx.StringUtils = StringUtils; + }); } }, 'js/verify_student/views/intro_step_view': { @@ -495,8 +501,10 @@ exports: 'DiscussionUtil', init: function() { // Set global variables that the discussion code is expecting to be defined - window.Backbone = require('backbone'); - window.URI = require('URI'); + require(['backbone', 'URI'], function (Backbone, URI) { + window.Backbone = Backbone; + window.URI = URI; + }); } }, 'xmodule_js/common_static/coffee/src/discussion/content': { @@ -550,13 +558,15 @@ }, 'xmodule_js/common_static/coffee/src/discussion/views/discussion_thread_show_view': { deps: [ - 'xmodule_js/common_static/coffee/src/discussion/utils' + 'xmodule_js/common_static/coffee/src/discussion/utils', + 'xmodule_js/common_static/coffee/src/discussion/views/discussion_content_view' ], exports: 'DiscussionThreadShowView' }, 'xmodule_js/common_static/coffee/src/discussion/views/discussion_thread_view': { deps: [ - 'xmodule_js/common_static/coffee/src/discussion/utils' + 'xmodule_js/common_static/coffee/src/discussion/utils', + 'xmodule_js/common_static/coffee/src/discussion/views/discussion_content_view' ], exports: 'DiscussionThreadView' }, @@ -638,123 +648,131 @@ } }); - // TODO: why do these need 'lms/include' at the front but the CMS equivalent logic doesn't? - define([ - // Run the LMS tests - 'lms/include/js/spec/components/header/header_spec.js', - 'lms/include/js/spec/components/card/card_spec.js', - 'lms/include/js/spec/staff_debug_actions_spec.js', - 'lms/include/js/spec/views/notification_spec.js', - 'lms/include/js/spec/views/file_uploader_spec.js', - 'lms/include/js/spec/dashboard/donation.js', - 'lms/include/js/spec/dashboard/dropdown_spec.js', - 'lms/include/js/spec/dashboard/track_events_spec.js', - 'lms/include/js/spec/groups/views/cohorts_spec.js', - 'lms/include/js/spec/shoppingcart/shoppingcart_spec.js', - 'lms/include/js/spec/instructor_dashboard/ecommerce_spec.js', - 'lms/include/js/spec/instructor_dashboard/student_admin_spec.js', - 'lms/include/js/spec/instructor_dashboard/certificates_exception_spec.js', - 'lms/include/js/spec/instructor_dashboard/certificates_invalidation_spec.js', - 'lms/include/js/spec/instructor_dashboard/certificates_bulk_exception_spec.js', - 'lms/include/js/spec/instructor_dashboard/certificates_spec.js', - 'lms/include/js/spec/student_account/account_spec.js', - 'lms/include/js/spec/student_account/access_spec.js', - 'lms/include/js/spec/student_account/logistration_factory_spec.js', - 'lms/include/js/spec/student_account/finish_auth_spec.js', - 'lms/include/js/spec/student_account/hinted_login_spec.js', - 'lms/include/js/spec/student_account/login_spec.js', - 'lms/include/js/spec/student_account/institution_login_spec.js', - 'lms/include/js/spec/student_account/register_spec.js', - 'lms/include/js/spec/student_account/password_reset_spec.js', - 'lms/include/js/spec/student_account/enrollment_spec.js', - 'lms/include/js/spec/student_account/emailoptin_spec.js', - 'lms/include/js/spec/student_account/shoppingcart_spec.js', - 'lms/include/js/spec/student_account/account_settings_factory_spec.js', - 'lms/include/js/spec/student_account/account_settings_fields_spec.js', - 'lms/include/js/spec/student_account/account_settings_view_spec.js', - 'lms/include/js/spec/views/fields_spec.js', - 'lms/include/js/spec/student_profile/learner_profile_factory_spec.js', - 'lms/include/js/spec/student_profile/learner_profile_view_spec.js', - 'lms/include/js/spec/student_profile/learner_profile_fields_spec.js', - 'lms/include/js/spec/student_profile/share_modal_view_spec.js', - 'lms/include/js/spec/student_profile/badge_view_spec.js', - 'lms/include/js/spec/student_profile/section_two_tab_spec.js', - 'lms/include/js/spec/student_profile/badge_list_view_spec.js', - 'lms/include/js/spec/student_profile/badge_list_container_spec.js', - 'lms/include/js/spec/verify_student/pay_and_verify_view_spec.js', - 'lms/include/js/spec/verify_student/reverify_view_spec.js', - 'lms/include/js/spec/verify_student/webcam_photo_view_spec.js', - 'lms/include/js/spec/verify_student/image_input_spec.js', - 'lms/include/js/spec/verify_student/review_photos_step_view_spec.js', - 'lms/include/js/spec/verify_student/make_payment_step_view_spec.js', - 'lms/include/js/spec/verify_student/make_payment_step_view_ab_testing_spec.js', - 'lms/include/js/spec/edxnotes/utils/logger_spec.js', - 'lms/include/js/spec/edxnotes/views/notes_factory_spec.js', - 'lms/include/js/spec/edxnotes/views/shim_spec.js', - 'lms/include/js/spec/edxnotes/views/note_item_spec.js', - 'lms/include/js/spec/edxnotes/views/notes_page_spec.js', - 'lms/include/js/spec/edxnotes/views/search_box_spec.js', - 'lms/include/js/spec/edxnotes/views/tabs_list_spec.js', - 'lms/include/js/spec/edxnotes/views/tab_item_spec.js', - 'lms/include/js/spec/edxnotes/views/tab_view_spec.js', - 'lms/include/js/spec/edxnotes/views/tabs/search_results_spec.js', - 'lms/include/js/spec/edxnotes/views/tabs/recent_activity_spec.js', - 'lms/include/js/spec/edxnotes/views/tabs/course_structure_spec.js', - 'lms/include/js/spec/edxnotes/views/tabs/tags_spec.js', - 'lms/include/js/spec/edxnotes/views/visibility_decorator_spec.js', - 'lms/include/js/spec/edxnotes/views/notes_visibility_factory_spec.js', - 'lms/include/js/spec/edxnotes/models/tab_spec.js', - 'lms/include/js/spec/edxnotes/models/note_spec.js', - 'lms/include/js/spec/edxnotes/plugins/accessibility_spec.js', - 'lms/include/js/spec/edxnotes/plugins/events_spec.js', - 'lms/include/js/spec/edxnotes/plugins/scroller_spec.js', - 'lms/include/js/spec/edxnotes/plugins/caret_navigation_spec.js', - 'lms/include/js/spec/edxnotes/plugins/store_error_handler_spec.js', - 'lms/include/js/spec/edxnotes/collections/notes_spec.js', - 'lms/include/js/spec/search/search_spec.js', - 'lms/include/js/spec/navigation_spec.js', - 'lms/include/js/spec/courseware/updates_visibility.js', - 'lms/include/js/spec/discovery/collections/filters_spec.js', - 'lms/include/js/spec/discovery/models/course_card_spec.js', - 'lms/include/js/spec/discovery/models/course_directory_spec.js', - 'lms/include/js/spec/discovery/models/facet_option_spec.js', - 'lms/include/js/spec/discovery/models/filter_spec.js', - 'lms/include/js/spec/discovery/models/search_state_spec.js', - 'lms/include/js/spec/discovery/views/course_card_spec.js', - 'lms/include/js/spec/discovery/views/courses_listing_spec.js', - 'lms/include/js/spec/discovery/views/filter_bar_spec.js', - 'lms/include/js/spec/discovery/views/refine_sidebar_spec.js', - 'lms/include/js/spec/discovery/views/search_form_spec.js', - 'lms/include/js/spec/discovery/discovery_factory_spec.js', - 'lms/include/js/spec/ccx/schedule_spec.js', - 'lms/include/support/js/spec/collections/enrollment_spec.js', - 'lms/include/support/js/spec/models/enrollment_spec.js', - 'lms/include/support/js/spec/views/enrollment_modal_spec.js', - 'lms/include/support/js/spec/views/enrollment_spec.js', - 'lms/include/support/js/spec/views/certificates_spec.js', - 'lms/include/teams/js/spec/collections/topic_collection_spec.js', - 'lms/include/teams/js/spec/teams_tab_factory_spec.js', - 'lms/include/teams/js/spec/views/edit_team_spec.js', - 'lms/include/teams/js/spec/views/edit_team_members_spec.js', - 'lms/include/teams/js/spec/views/instructor_tools_spec.js', - 'lms/include/teams/js/spec/views/my_teams_spec.js', - 'lms/include/teams/js/spec/views/team_card_spec.js', - 'lms/include/teams/js/spec/views/team_discussion_spec.js', - 'lms/include/teams/js/spec/views/team_profile_spec.js', - 'lms/include/teams/js/spec/views/teams_spec.js', - 'lms/include/teams/js/spec/views/teams_tab_spec.js', - 'lms/include/teams/js/spec/views/topic_card_spec.js', - 'lms/include/teams/js/spec/views/topic_teams_spec.js', - 'lms/include/teams/js/spec/views/topics_spec.js', - 'lms/include/teams/js/spec/views/team_profile_header_actions_spec.js', - 'lms/include/js/spec/financial-assistance/financial_assistance_form_view_spec.js', - 'lms/include/js/spec/bookmarks/bookmarks_list_view_spec.js', - 'lms/include/js/spec/bookmarks/bookmark_button_view_spec.js', - 'lms/include/js/spec/views/message_banner_spec.js', - 'lms/include/js/spec/markdown_editor_spec.js', - 'lms/include/js/spec/learner_dashboard/collection_list_view_spec.js', - 'lms/include/js/spec/learner_dashboard/sidebar_view_spec.js', - 'lms/include/js/spec/learner_dashboard/program_card_view_spec.js' - ]); + var testFiles = [ + 'js/spec/components/header/header_spec.js', + 'js/spec/components/card/card_spec.js', + 'js/spec/staff_debug_actions_spec.js', + 'js/spec/views/notification_spec.js', + 'js/spec/views/file_uploader_spec.js', + 'js/spec/dashboard/donation.js', + 'js/spec/dashboard/dropdown_spec.js', + 'js/spec/dashboard/track_events_spec.js', + 'js/spec/groups/views/cohorts_spec.js', + 'js/spec/shoppingcart/shoppingcart_spec.js', + 'js/spec/instructor_dashboard/ecommerce_spec.js', + 'js/spec/instructor_dashboard/student_admin_spec.js', + 'js/spec/instructor_dashboard/certificates_exception_spec.js', + 'js/spec/instructor_dashboard/certificates_invalidation_spec.js', + 'js/spec/instructor_dashboard/certificates_bulk_exception_spec.js', + 'js/spec/instructor_dashboard/certificates_spec.js', + 'js/spec/student_account/account_spec.js', + 'js/spec/student_account/access_spec.js', + 'js/spec/student_account/logistration_factory_spec.js', + 'js/spec/student_account/finish_auth_spec.js', + 'js/spec/student_account/hinted_login_spec.js', + 'js/spec/student_account/login_spec.js', + 'js/spec/student_account/institution_login_spec.js', + 'js/spec/student_account/register_spec.js', + 'js/spec/student_account/password_reset_spec.js', + 'js/spec/student_account/enrollment_spec.js', + 'js/spec/student_account/emailoptin_spec.js', + 'js/spec/student_account/shoppingcart_spec.js', + 'js/spec/student_account/account_settings_factory_spec.js', + 'js/spec/student_account/account_settings_fields_spec.js', + 'js/spec/student_account/account_settings_view_spec.js', + 'js/spec/views/fields_spec.js', + 'js/spec/student_profile/learner_profile_factory_spec.js', + 'js/spec/student_profile/learner_profile_view_spec.js', + 'js/spec/student_profile/learner_profile_fields_spec.js', + 'js/spec/student_profile/share_modal_view_spec.js', + 'js/spec/student_profile/badge_view_spec.js', + 'js/spec/student_profile/section_two_tab_spec.js', + 'js/spec/student_profile/badge_list_view_spec.js', + 'js/spec/student_profile/badge_list_container_spec.js', + 'js/spec/verify_student/pay_and_verify_view_spec.js', + 'js/spec/verify_student/reverify_view_spec.js', + 'js/spec/verify_student/webcam_photo_view_spec.js', + 'js/spec/verify_student/image_input_spec.js', + 'js/spec/verify_student/review_photos_step_view_spec.js', + 'js/spec/verify_student/make_payment_step_view_spec.js', + 'js/spec/verify_student/make_payment_step_view_ab_testing_spec.js', + 'js/spec/edxnotes/utils/logger_spec.js', + 'js/spec/edxnotes/views/notes_factory_spec.js', + 'js/spec/edxnotes/views/shim_spec.js', + 'js/spec/edxnotes/views/note_item_spec.js', + 'js/spec/edxnotes/views/notes_page_spec.js', + 'js/spec/edxnotes/views/search_box_spec.js', + 'js/spec/edxnotes/views/tabs_list_spec.js', + 'js/spec/edxnotes/views/tab_item_spec.js', + 'js/spec/edxnotes/views/tab_view_spec.js', + 'js/spec/edxnotes/views/tabs/search_results_spec.js', + 'js/spec/edxnotes/views/tabs/recent_activity_spec.js', + 'js/spec/edxnotes/views/tabs/course_structure_spec.js', + 'js/spec/edxnotes/views/tabs/tags_spec.js', + 'js/spec/edxnotes/views/visibility_decorator_spec.js', + 'js/spec/edxnotes/views/notes_visibility_factory_spec.js', + 'js/spec/edxnotes/models/tab_spec.js', + 'js/spec/edxnotes/models/note_spec.js', + 'js/spec/edxnotes/plugins/accessibility_spec.js', + 'js/spec/edxnotes/plugins/events_spec.js', + 'js/spec/edxnotes/plugins/scroller_spec.js', + 'js/spec/edxnotes/plugins/caret_navigation_spec.js', + 'js/spec/edxnotes/plugins/store_error_handler_spec.js', + 'js/spec/edxnotes/collections/notes_spec.js', + 'js/spec/search/search_spec.js', + 'js/spec/navigation_spec.js', + 'js/spec/courseware/updates_visibility.js', + 'js/spec/discovery/collections/filters_spec.js', + 'js/spec/discovery/models/course_card_spec.js', + 'js/spec/discovery/models/course_directory_spec.js', + 'js/spec/discovery/models/facet_option_spec.js', + 'js/spec/discovery/models/filter_spec.js', + 'js/spec/discovery/models/search_state_spec.js', + 'js/spec/discovery/views/course_card_spec.js', + 'js/spec/discovery/views/courses_listing_spec.js', + 'js/spec/discovery/views/filter_bar_spec.js', + 'js/spec/discovery/views/refine_sidebar_spec.js', + 'js/spec/discovery/views/search_form_spec.js', + 'js/spec/discovery/discovery_factory_spec.js', + 'js/spec/ccx/schedule_spec.js', + 'support/js/spec/collections/enrollment_spec.js', + 'support/js/spec/models/enrollment_spec.js', + 'support/js/spec/views/enrollment_modal_spec.js', + 'support/js/spec/views/enrollment_spec.js', + 'support/js/spec/views/certificates_spec.js', + 'teams/js/spec/collections/topic_collection_spec.js', + 'teams/js/spec/teams_tab_factory_spec.js', + 'teams/js/spec/views/edit_team_spec.js', + 'teams/js/spec/views/edit_team_members_spec.js', + 'teams/js/spec/views/instructor_tools_spec.js', + 'teams/js/spec/views/my_teams_spec.js', + 'teams/js/spec/views/team_card_spec.js', + 'teams/js/spec/views/team_discussion_spec.js', + 'teams/js/spec/views/team_profile_spec.js', + 'teams/js/spec/views/teams_spec.js', + 'teams/js/spec/views/teams_tab_spec.js', + 'teams/js/spec/views/topic_card_spec.js', + 'teams/js/spec/views/topic_teams_spec.js', + 'teams/js/spec/views/topics_spec.js', + 'teams/js/spec/views/team_profile_header_actions_spec.js', + 'js/spec/financial-assistance/financial_assistance_form_view_spec.js', + 'js/spec/bookmarks/bookmarks_list_view_spec.js', + 'js/spec/bookmarks/bookmark_button_view_spec.js', + 'js/spec/views/message_banner_spec.js', + 'js/spec/markdown_editor_spec.js', + 'js/spec/learner_dashboard/collection_list_view_spec.js', + 'js/spec/learner_dashboard/sidebar_view_spec.js', + 'js/spec/learner_dashboard/program_card_view_spec.js', + 'js/spec/learner_dashboard/certificate_view_spec.js' + ]; + + for (var i = 0; i < testFiles.length; i++) { + testFiles[i] = '/base/' + testFiles[i]; + } + + require(testFiles, function () { + // start test run, once Require.js is done + window.__karma__.start(); + }); }).call(this, requirejs, define); diff --git a/lms/static/js/spec/main_requirejs_coffee.js b/lms/static/js/spec/main_requirejs_coffee.js new file mode 100644 index 0000000000..0597f1e952 --- /dev/null +++ b/lms/static/js/spec/main_requirejs_coffee.js @@ -0,0 +1,23 @@ +(function(requirejs) { + 'use strict'; + requirejs.config({ + baseUrl: '/base/', + paths: { + "moment": "xmodule_js/common_static/js/vendor/moment.min", + "modernizr": "xmodule_js/common_static/edx-pattern-library/js/modernizr-custom", + "afontgarde": "xmodule_js/common_static/edx-pattern-library/js/afontgarde", + "edxicons": "xmodule_js/common_static/edx-pattern-library/js/edx-icons", + "draggabilly": "xmodule_js/common_static/js/vendor/draggabilly" + }, + "moment": { + exports: "moment" + }, + "modernizr": { + exports: "Modernizr" + }, + "afontgarde": { + exports: "AFontGarde" + } + }); + +}).call(this, requirejs, define); // jshint ignore:line diff --git a/lms/static/js/spec/navigation_spec.js b/lms/static/js/spec/navigation_spec.js index d0c51cd049..12e2964c59 100644 --- a/lms/static/js/spec/navigation_spec.js +++ b/lms/static/js/spec/navigation_spec.js @@ -15,7 +15,7 @@ define(['jquery', 'js/utils/navigation'], function($) { chapterMenu = accordion.children('.chapter-content-container').children('.chapter-menu'); this.KEY = $.ui.keyCode; - spyOn($.fn, 'focus').andCallThrough(); + spyOn($.fn, 'focus').and.callThrough(); edx.util.navigation.init(); }); diff --git a/lms/static/js/spec/search/search_spec.js b/lms/static/js/spec/search/search_spec.js index dce93d0ea3..7e18043dc0 100644 --- a/lms/static/js/spec/search/search_spec.js +++ b/lms/static/js/spec/search/search_spec.js @@ -77,14 +77,14 @@ define([ var collection = new SearchCollection([]); spyOn($, 'ajax'); collection.performSearch('search string'); - expect($.ajax.mostRecentCall.args[0].url).toEqual('/search/'); + expect($.ajax.calls.mostRecent().args[0].url).toEqual('/search/'); }); it('sends a request with course ID', function () { var collection = new SearchCollection([], { courseId: 'edx101' }); spyOn($, 'ajax'); collection.performSearch('search string'); - expect($.ajax.mostRecentCall.args[0].url).toEqual('/search/edx101'); + expect($.ajax.calls.mostRecent().args[0].url).toEqual('/search/edx101'); }); it('sends a request and parses the json result', function () { @@ -139,10 +139,10 @@ define([ AjaxHelpers.respondWithJson(requests, response); spyOn($, 'ajax'); this.collection.loadNextPage(); - expect($.ajax.mostRecentCall.args[0].url).toEqual(this.collection.url); - expect($.ajax.mostRecentCall.args[0].data.search_string).toEqual(searchString); - expect($.ajax.mostRecentCall.args[0].data.page_size).toEqual(this.collection.pageSize); - expect($.ajax.mostRecentCall.args[0].data.page_index).toEqual(2); + expect($.ajax.calls.mostRecent().args[0].url).toEqual(this.collection.url); + expect($.ajax.calls.mostRecent().args[0].data.search_string).toEqual(searchString); + expect($.ajax.calls.mostRecent().args[0].data.page_size).toEqual(this.collection.pageSize); + expect($.ajax.calls.mostRecent().args[0].data.page_index).toEqual(2); }); it('has next page', function () { @@ -164,18 +164,18 @@ define([ this.collection.performSearch('new search'); AjaxHelpers.skipResetRequest(requests); AjaxHelpers.respondWithJson(requests, response); - expect(this.onSearch.calls.length).toEqual(1); + expect(this.onSearch.calls.count()).toEqual(1); this.collection.performSearch('old search'); this.collection.cancelSearch(); AjaxHelpers.skipResetRequest(requests); - expect(this.onSearch.calls.length).toEqual(1); + expect(this.onSearch.calls.count()).toEqual(1); this.collection.loadNextPage(); this.collection.loadNextPage(); AjaxHelpers.skipResetRequest(requests); AjaxHelpers.respondWithJson(requests, response); - expect(this.onNext.calls.length).toEqual(1); + expect(this.onNext.calls.count()).toEqual(1); }); describe('reset state', function () { @@ -261,7 +261,7 @@ define([ function rendersItem() { expect(this.item.$el).toHaveAttr('role', 'region'); expect(this.item.$el).toHaveAttr('aria-label', 'search result'); - expect(this.item.$el).toContain('a[href="' + this.model.get('url') + '"]'); + expect(this.item.$el).toContainElement('a[href="' + this.model.get('url') + '"]'); expect(this.item.$el.find('.result-type')).toContainHtml(this.model.get('content_type')); expect(this.item.$el.find('.result-excerpt')).toContainHtml(this.model.get('excerpt')); expect(this.item.$el.find('.result-location')).toContainHtml('section ▸ subsection ▸ unit'); @@ -270,7 +270,7 @@ define([ function rendersSequentialItem() { expect(this.seqItem.$el).toHaveAttr('role', 'region'); expect(this.seqItem.$el).toHaveAttr('aria-label', 'search result'); - expect(this.seqItem.$el).toContain('a[href="' + this.seqModel.get('url') + '"]'); + expect(this.seqItem.$el).toContainElement('a[href="' + this.seqModel.get('url') + '"]'); expect(this.seqItem.$el.find('.result-type')).toBeEmpty(); expect(this.seqItem.$el.find('.result-excerpt')).toBeEmpty(); expect(this.seqItem.$el.find('.result-location')).toContainHtml('section ▸ subsection'); @@ -280,8 +280,8 @@ define([ this.model.collection = new SearchCollection([this.model], { course_id: 'edx101' }); this.item.render(); // Mock the redirect call - spyOn(this.item, 'redirect').andCallFake( function() {} ); - spyOn(Logger, 'log').andReturn($.Deferred().resolve()); + spyOn(this.item, 'redirect').and.callFake( function() {} ); + spyOn(Logger, 'log').and.returnValue($.Deferred().resolve()); this.item.$el.find('a').trigger('click'); expect(this.item.redirect).toHaveBeenCalled(); this.item.$el.trigger('click'); @@ -498,7 +498,6 @@ define([ 'templates/search/dashboard_search_item', 'templates/search/course_search_results', 'templates/search/dashboard_search_results', - 'templates/search/search_list', 'templates/search/search_loading', 'templates/search/search_error' ]); @@ -601,9 +600,9 @@ define([ function updatesNavigationHistory () { $('.search-field').val('edx'); $('.search-button').trigger('click'); - expect(Backbone.history.navigate.calls[0].args).toContain('search/edx'); + expect(Backbone.history.navigate.calls.mostRecent().args[0]).toContain('search/edx'); $('.cancel-button').trigger('click'); - expect(Backbone.history.navigate.calls[1].args).toContain(''); + expect(Backbone.history.navigate.calls.argsFor(1)[0]).toBe(''); } function cancelsSearchRequest () { diff --git a/lms/static/js/spec/shoppingcart/shoppingcart_spec.js b/lms/static/js/spec/shoppingcart/shoppingcart_spec.js index 1f0da04fc8..0f86f7c9f8 100644 --- a/lms/static/js/spec/shoppingcart/shoppingcart_spec.js +++ b/lms/static/js/spec/shoppingcart/shoppingcart_spec.js @@ -13,7 +13,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/shoppingcart/shoppingcart'], el: $('.confirm-enrollment.cart-view form') }); - spyOn(view, 'responseFromServer').andCallFake(function() {}); + spyOn(view, 'responseFromServer').and.callFake(function() {}); // Spy on AJAX requests requests = AjaxHelpers.requests(this); @@ -36,7 +36,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/shoppingcart/shoppingcart'], }); expect(view.responseFromServer).toHaveBeenCalled(); - var data = view.responseFromServer.mostRecentCall.args[0] + var data = view.responseFromServer.calls.mostRecent().args[0]; expect(data.is_course_enrollment_closed).toBe(true); }); @@ -50,7 +50,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/shoppingcart/shoppingcart'], }); expect(view.responseFromServer).toHaveBeenCalled(); - var data = view.responseFromServer.mostRecentCall.args[0] + var data = view.responseFromServer.calls.mostRecent().args[0]; expect(data.is_course_enrollment_closed).toBe(false); }); diff --git a/lms/static/js/spec/staff_debug_actions_spec.js b/lms/static/js/spec/staff_debug_actions_spec.js index af92c098d6..3486fd7267 100644 --- a/lms/static/js/spec/staff_debug_actions_spec.js +++ b/lms/static/js/spec/staff_debug_actions_spec.js @@ -5,6 +5,8 @@ define([ 'common/js/spec_helpers/ajax_helpers' ], function (Backbone, $, tmp, AjaxHelpers) { + 'use strict'; + var StaffDebug = window.StaffDebug; describe('StaffDebugActions', function () { var location = 'i4x://edX/Open_DemoX/edx_demo_course/problem/test_loc'; @@ -20,8 +22,10 @@ define([ describe('get_url ', function () { it('defines url to courseware ajax entry point', function () { - spyOn(StaffDebug, "get_current_url").andReturn("/courses/edX/Open_DemoX/edx_demo_course/courseware/stuff"); - expect(StaffDebug.get_url('rescore_problem')).toBe('/courses/edX/Open_DemoX/edx_demo_course/instructor/api/rescore_problem'); + spyOn(StaffDebug, "get_current_url") + .and.returnValue("/courses/edX/Open_DemoX/edx_demo_course/courseware/stuff"); + expect(StaffDebug.get_url('rescore_problem')) + .toBe('/courses/edX/Open_DemoX/edx_demo_course/instructor/api/rescore_problem'); }); }); @@ -87,13 +91,13 @@ define([ spyOn($, 'ajax'); StaffDebug.reset(locationName, location); - expect($.ajax.mostRecentCall.args[0]['type']).toEqual('GET'); - expect($.ajax.mostRecentCall.args[0]['data']).toEqual({ + expect($.ajax.calls.mostRecent().args[0].type).toEqual('GET'); + expect($.ajax.calls.mostRecent().args[0].data).toEqual({ 'problem_to_reset': location, 'unique_student_identifier': 'userman', 'delete_module': false }); - expect($.ajax.mostRecentCall.args[0]['url']).toEqual( + expect($.ajax.calls.mostRecent().args[0].url).toEqual( '/instructor/api/reset_student_attempts' ); $('#' + fixture_id).remove(); @@ -106,13 +110,13 @@ define([ spyOn($, 'ajax'); StaffDebug.sdelete(locationName, location); - expect($.ajax.mostRecentCall.args[0]['type']).toEqual('GET'); - expect($.ajax.mostRecentCall.args[0]['data']).toEqual({ + expect($.ajax.calls.mostRecent().args[0].type).toEqual('GET'); + expect($.ajax.calls.mostRecent().args[0].data).toEqual({ 'problem_to_reset': location, 'unique_student_identifier': 'userman', 'delete_module': true }); - expect($.ajax.mostRecentCall.args[0]['url']).toEqual( + expect($.ajax.calls.mostRecent().args[0].url).toEqual( '/instructor/api/reset_student_attempts' ); @@ -126,13 +130,13 @@ define([ spyOn($, 'ajax'); StaffDebug.rescore(locationName, location); - expect($.ajax.mostRecentCall.args[0]['type']).toEqual('GET'); - expect($.ajax.mostRecentCall.args[0]['data']).toEqual({ + expect($.ajax.calls.mostRecent().args[0].type).toEqual('GET'); + expect($.ajax.calls.mostRecent().args[0].data).toEqual({ 'problem_to_reset': location, 'unique_student_identifier': 'userman', 'delete_module': false }); - expect($.ajax.mostRecentCall.args[0]['url']).toEqual( + expect($.ajax.calls.mostRecent().args[0].url).toEqual( '/instructor/api/rescore_problem' ); $('#' + fixture_id).remove(); diff --git a/lms/static/js/spec/student_account/access_spec.js b/lms/static/js/spec/student_account/access_spec.js index 88a73c1506..1f6c334984 100644 --- a/lms/static/js/spec/student_account/access_spec.js +++ b/lms/static/js/spec/student_account/access_spec.js @@ -78,11 +78,11 @@ view = new AccessView(_.extend(options, {el: $logistrationElement})); // Mock the redirect call - spyOn( view, 'redirect' ).andCallFake( function() {} ); + spyOn( view, 'redirect' ).and.callFake( function() {} ); // Mock the enrollment and shopping cart interfaces - spyOn( EnrollmentInterface, 'enroll' ).andCallFake( function() {} ); - spyOn( ShoppingCartInterface, 'addCourseToCart' ).andCallFake( function() {} ); + spyOn( EnrollmentInterface, 'enroll' ).and.callFake( function() {} ); + spyOn( ShoppingCartInterface, 'addCourseToCart' ).and.callFake( function() {} ); }; var assertForms = function(visibleType, hiddenType) { @@ -101,6 +101,7 @@ }; beforeEach(function() { + spyOn(window.history, 'pushState'); setFixtures('
'); TemplateHelpers.installTemplate('templates/student_account/access'); TemplateHelpers.installTemplate('templates/student_account/login'); @@ -139,9 +140,6 @@ it('toggles between the login and registration forms', function() { ajaxSpyAndInitialize(this, 'login'); - // Prevent URL from updating - spyOn(history, 'pushState').andCallFake( function() {} ); - // Simulate selection of the registration form selectForm('register'); assertForms('#register-form', '#login-form'); diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js index 9d5b95616b..626c5b2f83 100644 --- a/lms/static/js/spec/student_account/account_settings_fields_spec.js +++ b/lms/static/js/spec/student_account/account_settings_fields_spec.js @@ -16,7 +16,11 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers beforeEach(function () { timerCallback = jasmine.createSpy('timerCallback'); - jasmine.Clock.useMock(); + jasmine.clock().install(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); }); it("sends request to reset password on clicking link in PasswordFieldView", function() { diff --git a/lms/static/js/spec/student_account/account_spec.js b/lms/static/js/spec/student_account/account_spec.js index 3262192baf..bbca8f8ee2 100644 --- a/lms/static/js/spec/student_account/account_spec.js +++ b/lms/static/js/spec/student_account/account_spec.js @@ -104,7 +104,7 @@ define(['js/student_account/account'], var assertAjax = function(url, method, data) { expect($.ajax).toHaveBeenCalled(); - var ajaxArgs = $.ajax.mostRecentCall.args[0]; + var ajaxArgs = $.ajax.calls.mostRecent().args[0]; expect(ajaxArgs.url).toEqual(url); expect(ajaxArgs.type).toEqual(method); expect(ajaxArgs.data).toEqual(data); @@ -127,7 +127,7 @@ define(['js/student_account/account'], view = new edx.student.account.AccountView().render(); // Stub Ajax calls to return success/failure - spyOn($, "ajax").andCallFake(function() { + spyOn($, "ajax").and.callFake(function() { return $.Deferred(function(defer) { if (ajaxSuccess) { defer.resolve(); diff --git a/lms/static/js/spec/student_account/enrollment_spec.js b/lms/static/js/spec/student_account/enrollment_spec.js index 27cfdc9106..ac36790367 100644 --- a/lms/static/js/spec/student_account/enrollment_spec.js +++ b/lms/static/js/spec/student_account/enrollment_spec.js @@ -11,7 +11,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/student_account/enrollment'], beforeEach(function() { // Mock the redirect call - spyOn(EnrollmentInterface, 'redirect').andCallFake(function() {}); + spyOn(EnrollmentInterface, 'redirect').and.callFake(function() {}); }); it('enrolls a user in a course', function() { diff --git a/lms/static/js/spec/student_account/finish_auth_spec.js b/lms/static/js/spec/student_account/finish_auth_spec.js index 2086526631..53565ef157 100644 --- a/lms/static/js/spec/student_account/finish_auth_spec.js +++ b/lms/static/js/spec/student_account/finish_auth_spec.js @@ -27,13 +27,13 @@ view = new FinishAuthView({}); // Mock the redirect call - spyOn( view, 'redirect' ).andCallFake( function() {} ); + spyOn( view, 'redirect' ).and.callFake( function() {} ); // Mock the enrollment and shopping cart interfaces - spyOn( EnrollmentInterface, 'enroll' ).andCallFake( function() {} ); - spyOn( ShoppingCartInterface, 'addCourseToCart' ).andCallFake( function() {} ); + spyOn( EnrollmentInterface, 'enroll' ).and.callFake( function() {} ); + spyOn( ShoppingCartInterface, 'addCourseToCart' ).and.callFake( function() {} ); spyOn( EmailOptInInterface, 'setPreference' ) - .andCallFake( function() { return {'always': function(r) { r(); }}; } ); + .and.callFake( function() { return {'always': function(r) { r(); }}; } ); view.render(); }; @@ -45,7 +45,7 @@ * should be prefixed with '?' */ var setFakeQueryParams = function( params ) { - spyOn( $, 'url' ).andCallFake(function( requestedParam ) { + spyOn( $, 'url' ).and.callFake(function( requestedParam ) { if ( params.hasOwnProperty(requestedParam) ) { return params[requestedParam]; } diff --git a/lms/static/js/spec/student_account/hinted_login_spec.js b/lms/static/js/spec/student_account/hinted_login_spec.js index 2a751b32d2..f21f1a8129 100644 --- a/lms/static/js/spec/student_account/hinted_login_spec.js +++ b/lms/static/js/spec/student_account/hinted_login_spec.js @@ -51,7 +51,7 @@ }); // Mock the redirect call - spyOn( view, 'redirect' ).andCallFake( function() {} ); + spyOn( view, 'redirect' ).and.callFake( function() {} ); view.render(); }; diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js index a94ada61a0..9bd5d9ba33 100644 --- a/lms/static/js/spec/student_account/login_spec.js +++ b/lms/static/js/spec/student_account/login_spec.js @@ -127,7 +127,7 @@ // spying on `view.validate` twice if ( !_.isUndefined(validationSuccess) ) { // Force validation to return as expected - spyOn(view, 'validate').andReturn({ + spyOn(view, 'validate').and.returnValue({ isValid: validationSuccess, message: 'Submission was validated.' }); @@ -173,7 +173,7 @@ // Simulate that the user is attempting to enroll in a course // by setting the course_id query string param. - spyOn($, 'url').andCallFake(function( param ) { + spyOn($, 'url').and.callFake(function( param ) { if (param === '?course_id') { return encodeURIComponent( COURSE_ID ); } diff --git a/lms/static/js/spec/student_account/password_reset_spec.js b/lms/static/js/spec/student_account/password_reset_spec.js index b3ebbc4e69..35aa6fb228 100644 --- a/lms/static/js/spec/student_account/password_reset_spec.js +++ b/lms/static/js/spec/student_account/password_reset_spec.js @@ -58,7 +58,7 @@ // spying on `view.validate` twice if ( !_.isUndefined(validationSuccess) ) { // Force validation to return as expected - spyOn(view, 'validate').andReturn({ + spyOn(view, 'validate').and.returnValue({ isValid: validationSuccess, message: 'Submission was validated.' }); diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js index f827e13e9d..5ba2e68bfa 100644 --- a/lms/static/js/spec/student_account/register_spec.js +++ b/lms/static/js/spec/student_account/register_spec.js @@ -220,7 +220,7 @@ // spying on `view.validate` twice if ( !_.isUndefined(validationSuccess) ) { // Force validation to return as expected - spyOn(view, 'validate').andReturn({ + spyOn(view, 'validate').and.returnValue({ isValid: validationSuccess, message: 'Submission was validated.' }); @@ -265,7 +265,7 @@ // Simulate that the user is attempting to enroll in a course // by setting the course_id query string param. - spyOn($, 'url').andCallFake(function( param ) { + spyOn($, 'url').and.callFake(function( param ) { if (param === '?course_id') { return encodeURIComponent( COURSE_ID ); } diff --git a/lms/static/js/spec/student_account/shoppingcart_spec.js b/lms/static/js/spec/student_account/shoppingcart_spec.js index 9048951dd2..8102781352 100644 --- a/lms/static/js/spec/student_account/shoppingcart_spec.js +++ b/lms/static/js/spec/student_account/shoppingcart_spec.js @@ -10,7 +10,7 @@ define(['common/js/spec_helpers/ajax_helpers', 'js/student_account/shoppingcart' beforeEach(function() { // Mock the redirect call - spyOn(ShoppingCartInterface, 'redirect').andCallFake(function() {}); + spyOn(ShoppingCartInterface, 'redirect').and.callFake(function() {}); }); it('adds a course to the cart', function() { diff --git a/lms/static/js/spec/student_profile/badge_view_spec.js b/lms/static/js/spec/student_profile/badge_view_spec.js index 41159423d8..d40258fcd7 100644 --- a/lms/static/js/spec/student_profile/badge_view_spec.js +++ b/lms/static/js/spec/student_profile/badge_view_spec.js @@ -52,7 +52,7 @@ define(['backbone', 'jquery', 'underscore', expect(view.createModal).toHaveBeenCalled(); }); - it("click on share button calls shows the dialog", function () { + it("click on share button calls shows the dialog", function (done) { view = createView(true); expect(view.context.ownProfile).toBeTruthy(); var shareButton = view.$el.find('button.share-button'); @@ -63,9 +63,9 @@ define(['backbone', 'jquery', 'underscore', shareButton.click(); // Note: this element should have appeared in the dom during: shareButton.click(); modalElement = $('.badges-modal'); - waitsFor(function () { + jasmine.waitUntil(function () { return modalElement.is(":visible"); - }, '', 1000); + }).always(done); }); var testBadgeNameIsDisplayed = function (ownProfile) { diff --git a/lms/static/js/spec/student_profile/section_two_tab_spec.js b/lms/static/js/spec/student_profile/section_two_tab_spec.js index b553a0735f..a0236b885a 100644 --- a/lms/static/js/spec/student_profile/section_two_tab_spec.js +++ b/lms/static/js/spec/student_profile/section_two_tab_spec.js @@ -50,7 +50,7 @@ define(['backbone', 'jquery', 'underscore', it("profile field parts are actually rendered for public profile", function () { var view = createSectionTwoView(false, true); _.each(view.options.viewList, function (fieldView) { - spyOn(fieldView, "render").andCallThrough(); + spyOn(fieldView, "render").and.callThrough(); }); view.render(); _.each(view.options.viewList, function (fieldView) { diff --git a/lms/static/js/spec/verify_student/image_input_spec.js b/lms/static/js/spec/verify_student/image_input_spec.js index 233159a859..81af419611 100644 --- a/lms/static/js/spec/verify_student/image_input_spec.js +++ b/lms/static/js/spec/verify_student/image_input_spec.js @@ -22,51 +22,41 @@ define([ }).render(); }; - var uploadImage = function( view, fileType, callback ) { - var imageCapturedEvent = false, - errorEvent = false; + var uploadImage = function( view, fileType ) { + var deferred = $.Deferred(); // Since image upload is an asynchronous process, // we need to wait for the upload to complete // before checking the outcome. - runs(function() { - var fakeFile, - fakeEvent = { target: { files: [] } }; + var fakeFile, + fakeEvent = { target: { files: [] } }; - // If no file type is specified, don't add any files. - // This simulates what happens when the user clicks - // "cancel" after clicking the input. - if ( fileType !== null) { - fakeFile = new Blob( - [ IMAGE_DATA ], - { type: 'image/' + fileType } - ); - fakeEvent.target.files = [ fakeFile ]; - } + // If no file type is specified, don't add any files. + // This simulates what happens when the user clicks + // "cancel" after clicking the input. + if ( fileType !== null) { + fakeFile = new Blob( + [ IMAGE_DATA ], + { type: 'image/' + fileType } + ); + fakeEvent.target.files = [ fakeFile ]; + } - // Wait for either a successful upload or an error - view.on( 'imageCaptured', function() { - imageCapturedEvent = true; - }); - view.on( 'error', function() { - errorEvent = true; - }); - - // Trigger the file input change - // It's impossible to trigger this directly due - // to browser security restrictions, so we call - // the handler instead. - view.handleInputChange( fakeEvent ); + // Wait for either a successful upload or an error + view.on( 'imageCaptured', function() { + deferred.resolve(); + }); + view.on( 'error', function() { + deferred.resolve(); }); - // Check that the image upload has completed, - // either successfully or with an error. - waitsFor(function() { - return ( imageCapturedEvent || errorEvent ); - }); + // Trigger the file input change + // It's impossible to trigger this directly due + // to browser security restrictions, so we call + // the handler instead. + view.handleInputChange( fakeEvent ); - // Execute the callback to check expectations. - runs( callback ); + return deferred.promise(); }; var expectPreview = function( view, fileType ) { @@ -112,45 +102,45 @@ define([ expectSubmitEnabled( false ); }); - it( 'uploads a png image', function() { + it( 'uploads a png image', function(done) { var view = createView(); - uploadImage( view, 'png', function() { + uploadImage( view, 'png').then(function() { expectPreview( view, 'png' ); expectSubmitEnabled( true ); expectImageData( view, 'png' ); - }); + }).always(done); }); - it( 'uploads a jpeg image', function() { + it( 'uploads a jpeg image', function(done) { var view = createView(); - uploadImage( view, 'jpeg', function() { + uploadImage( view, 'jpeg').then(function() { expectPreview( view, 'jpeg' ); expectSubmitEnabled( true ); expectImageData( view, 'jpeg' ); - } ); + }).always(done); }); - it( 'hides the preview when the user cancels the upload', function() { + it( 'hides the preview when the user cancels the upload', function(done) { var view = createView(); - uploadImage( view, null, function() { + uploadImage( view, null).then(function() { expectPreview( view, null ); expectSubmitEnabled( false ); expectImageData( view, null ); - } ); + }).always(done); }); - it( 'shows an error if the file type is not supported', function() { + it( 'shows an error if the file type is not supported', function(done) { var view = createView(); - uploadImage( view, 'txt', function() { + uploadImage( view, 'txt').then(function() { expectPreview( view, null ); expectError( view ); expectSubmitEnabled( false ); expectImageData( view, null ); - } ); + }).always(done); }); }); }); diff --git a/lms/static/js/spec/verify_student/make_payment_step_view_ab_testing_spec.js b/lms/static/js/spec/verify_student/make_payment_step_view_ab_testing_spec.js index 27fa12573f..dbb5cf6e0e 100644 --- a/lms/static/js/spec/verify_student/make_payment_step_view_ab_testing_spec.js +++ b/lms/static/js/spec/verify_student/make_payment_step_view_ab_testing_spec.js @@ -37,7 +37,7 @@ define([ }).render(); // Stub the payment form submission - spyOn( view, 'submitForm' ).andCallFake( function() {} ); + spyOn( view, 'submitForm' ).and.callFake( function() {} ); return view; }; @@ -97,7 +97,7 @@ define([ var form; expect(view.submitForm).toHaveBeenCalled(); - form = view.submitForm.mostRecentCall.args[0]; + form = view.submitForm.calls.mostRecent().args[0]; expect(form.serialize()).toEqual($.param(params)); expect(form.attr('method')).toEqual('POST'); diff --git a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js index cda80071fe..31389ed5f3 100644 --- a/lms/static/js/spec/verify_student/make_payment_step_view_spec.js +++ b/lms/static/js/spec/verify_student/make_payment_step_view_spec.js @@ -34,7 +34,7 @@ define([ }).render(); // Stub the payment form submission - spyOn( view, 'submitForm' ).andCallFake( function() {} ); + spyOn( view, 'submitForm' ).and.callFake( function() {} ); return view; }; @@ -91,7 +91,7 @@ define([ var form; expect(view.submitForm).toHaveBeenCalled(); - form = view.submitForm.mostRecentCall.args[0]; + form = view.submitForm.calls.mostRecent().args[0]; expect(form.serialize()).toEqual($.param(params)); expect(form.attr('method')).toEqual("POST"); diff --git a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js index 8fb53b910f..53f4b1fd8b 100644 --- a/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js +++ b/lms/static/js/spec/verify_student/pay_and_verify_view_spec.js @@ -68,6 +68,7 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ beforeEach(function() { window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + navigator.getUserMedia = jasmine.createSpy(); setFixtures('
'); $.each( TEMPLATES, function( index, templateName ) { diff --git a/lms/static/js/spec/verify_student/reverify_view_spec.js b/lms/static/js/spec/verify_student/reverify_view_spec.js index b22236fb2b..e932ea116f 100644 --- a/lms/static/js/spec/verify_student/reverify_view_spec.js +++ b/lms/static/js/spec/verify_student/reverify_view_spec.js @@ -1,8 +1,9 @@ /** * Tests for the reverification view. **/ -define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/views/reverify_view'], - function( $, TemplateHelpers, ReverifyView ) { +define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/views/review_photos_step_view', + 'js/verify_student/views/reverify_view'], + function( $, TemplateHelpers, ReviewPhotosStepView, ReverifyView ) { 'use strict'; describe( 'edx.verify_student.ReverifyView', function() { @@ -45,6 +46,7 @@ define(['jquery', 'common/js/spec_helpers/template_helpers', 'js/verify_student/ beforeEach(function() { window.analytics = jasmine.createSpyObj('analytics', ['track', 'page', 'trackLink']); + navigator.getUserMedia = jasmine.createSpy(); setFixtures('
'); $.each( TEMPLATES, function( index, templateName ) { diff --git a/lms/static/js/spec/verify_student/review_photos_step_view_spec.js b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js index 59f07447f1..6ae60b7c01 100644 --- a/lms/static/js/spec/verify_student/review_photos_step_view_spec.js +++ b/lms/static/js/spec/verify_student/review_photos_step_view_spec.js @@ -42,7 +42,7 @@ define([ // Simulate the server response if ( succeeds ) { - AjaxHelpers.respondWithJson( requests ); + AjaxHelpers.respondWithJson( requests, {} ); } else { AjaxHelpers.respondWithTextError( requests, 400, SERVER_ERROR_MSG ); } diff --git a/lms/static/js/spec/verify_student/webcam_photo_view_spec.js b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js index 8d734dfda4..1ef1322095 100644 --- a/lms/static/js/spec/verify_student/webcam_photo_view_spec.js +++ b/lms/static/js/spec/verify_student/webcam_photo_view_spec.js @@ -94,7 +94,7 @@ define([ var view = createView( new StubBackend( "html5" ) ); // Spy on the backend - spyOn( view.backend, 'snapshot' ).andCallThrough(); + spyOn( view.backend, 'snapshot' ).and.callThrough(); // Initially, only the snapshot button is shown expectButtonShown({ @@ -125,7 +125,7 @@ define([ var view = createView( new StubBackend( "html5" ) ); // Spy on the backend - spyOn( view.backend, 'reset' ).andCallThrough(); + spyOn( view.backend, 'reset' ).and.callThrough(); // Take the snapshot, then reset takeSnapshot(); diff --git a/lms/static/js/spec/views/fields_helpers.js b/lms/static/js/spec/views/fields_helpers.js index 6248656d33..fc089d579f 100644 --- a/lms/static/js/spec/views/fields_helpers.js +++ b/lms/static/js/spec/views/fields_helpers.js @@ -118,7 +118,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers expectMessageContains(view, view.helpMessage); view.showSuccessMessage(); expectMessageContains(view, view.indicators.success); - jasmine.Clock.tick(7000); + jasmine.clock().tick(7000); // Message gets reset expectMessageContains(view, view.helpMessage); @@ -126,7 +126,7 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers expectMessageContains(view, view.indicators.success); // But if we change the message, it should not get reset. view.showHelpMessage("Do not reset this!"); - jasmine.Clock.tick(7000); + jasmine.clock().tick(7000); expectMessageContains(view, "Do not reset this!"); }; diff --git a/lms/static/js/spec/views/fields_spec.js b/lms/static/js/spec/views/fields_spec.js index 3dd02806b8..0fd4810b21 100644 --- a/lms/static/js/spec/views/fields_spec.js +++ b/lms/static/js/spec/views/fields_spec.js @@ -25,7 +25,11 @@ define(['backbone', 'jquery', 'underscore', 'common/js/spec_helpers/ajax_helpers beforeEach(function () { timerCallback = jasmine.createSpy('timerCallback'); - jasmine.Clock.useMock(); + jasmine.clock().install(); + }); + + afterEach(function() { + jasmine.clock().uninstall(); }); it("updates messages correctly for all fields", function() { diff --git a/lms/static/js/student_account/account.js b/lms/static/js/student_account/account.js index f8ad63d5e3..e05339663d 100644 --- a/lms/static/js/student_account/account.js +++ b/lms/static/js/student_account/account.js @@ -178,11 +178,15 @@ var edx = edx || {}; this.$passwordResetStatus .removeClass('error') .text(""); - }, + } }); - return new edx.student.account.AccountView({ - el: $('#account-container') - }).render(); + try { + new edx.student.account.AccountView({ + el: $('#account-container') + }).render(); + } catch (e) { + // TODO: handle exception + } })(jQuery, _, Backbone, gettext); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index 63b1aec2c2..e38db0e302 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -31,7 +31,6 @@ lib_paths: - xmodule_js/common_static/js/test/i18n.js - xmodule_js/common_static/coffee/src/ajax_prefix.js - xmodule_js/common_static/js/src/logger.js - - xmodule_js/common_static/js/vendor/jasmine-jquery.js - xmodule_js/common_static/js/vendor/jasmine-imagediff.js - xmodule_js/common_static/js/vendor/requirejs/require.js - js/RequireJS-namespace-undefine.js diff --git a/lms/static/js_test_coffee.yml b/lms/static/js_test_coffee.yml index 34013670ca..82e1f58195 100644 --- a/lms/static/js_test_coffee.yml +++ b/lms/static/js_test_coffee.yml @@ -31,19 +31,21 @@ lib_paths: - xmodule_js/common_static/js/test/i18n.js - xmodule_js/common_static/coffee/src/ajax_prefix.js - xmodule_js/common_static/js/src/logger.js - - xmodule_js/common_static/js/vendor/jasmine-jquery.js + - xmodule_js/common_static/js/vendor/underscore-min.js - xmodule_js/common_static/js/vendor/jasmine-imagediff.js - xmodule_js/common_static/js/vendor/requirejs/require.js - js/RequireJS-namespace-undefine.js - - xmodule_js/common_static/js/vendor/jquery.min.js - xmodule_js/common_static/js/vendor/jquery-ui.min.js - xmodule_js/common_static/js/vendor/jquery.cookie.js - xmodule_js/common_static/js/vendor/flot/jquery.flot.js + - xmodule_js/common_static/js/vendor/moment.min.js + - xmodule_js/common_static/js/vendor/moment-with-locales.min.js - xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js - xmodule_js/common_static/js/vendor/URI.min.js - xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js - xmodule_js/common_static/js/xblock - xmodule_js/common_static/coffee/src/xblock + - moment_requirejs.js - xmodule_js/src/capa/ - xmodule_js/src/video/ - xmodule_js/src/xmodule.js @@ -75,7 +77,7 @@ fixture_paths: # When loading many files, this can be slow, so # exclude any files you don't need. #exclude_from_page: -# - path/to/lib/exclude/* +# - coffee/spec/helper.js # Regular expression used to guarantee that a *.js file # is included in the test runner page. diff --git a/lms/static/karma_lms.conf.js b/lms/static/karma_lms.conf.js new file mode 100644 index 0000000000..63bd43bb95 --- /dev/null +++ b/lms/static/karma_lms.conf.js @@ -0,0 +1,154 @@ +// LMS JavaScript tests, using RequireJS. +// +// To run all the tests and print results to the console: +// +// karma start lms/static/karma_lms.conf.js +// +// +// To run the tests for debugging: Debugging can be done in any browser but +// Chrome's developer console debugging experience is best. +// +// karma start lms/static/karma_lms.conf.js --browsers=BROWSER --single-run=false +// +// +// To run the tests with coverage and junit reports: +// +// karma start lms/static/karma_lms.conf.js --browsers=BROWSER +// --coverage --junitreportpath= --coveragereportpath= +// +// where `BROWSER` could be Chrome or Firefox. +// + +/* jshint node: true */ +/*jshint -W079 */ +'use strict'; +var path = require('path'); +var _ = require('underscore'); +var configModule = require(path.join(__dirname, '../../common/static/common/js/karma.common.conf.js')); + +var libraryFiles = [ + {pattern: 'xmodule_js/common_static/js/test/i18n.js', included: false}, + {pattern: 'xmodule_js/common_static/coffee/src/ajax_prefix.js', included: false}, + {pattern: 'xmodule_js/common_static/js/src/logger.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/requirejs/require.js', included: false}, + {pattern: 'js/RequireJS-namespace-undefine.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/requirejs/text.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery-ui.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.simulate.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.cookie.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.timeago.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/flot/jquery.flot.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/URI.min.js', included: false}, + { + pattern: 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js', + included: false + }, + { + pattern: 'xmodule_js/common_static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js', + included: false + }, + {pattern: 'xmodule_js/common_static/js/vendor/url.min.js', included: false}, + {pattern: 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js', included: false}, + {pattern: 'xmodule_js/common_static/js/xblock/**/*.js', included: false}, + {pattern: 'xmodule_js/common_static/coffee/src/xblock/**/*.js', included: false}, + {pattern: 'coffee/src/instructor_dashboard/**/*.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/sinon-1.17.0.js', included: false}, + {pattern: 'xmodule_js/src/capa/**/*.js', included: false}, + {pattern: 'xmodule_js/src/video/**/*.js', included: false}, + {pattern: 'xmodule_js/src/xmodule.js', included: false}, + {pattern: 'xmodule_js/common_static/js/src/**/*.js', included: false}, + {pattern: 'xmodule_js/common_static/common/js/vendor/underscore.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/underscore.string.min.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/backbone-min.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/backbone.paginator.min.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/edxnotes/annotator-full.min.js', included: false}, + {pattern: 'xmodule_js/common_static/js/test/i18n.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/date.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/moment.min.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/moment-with-locales.min.js', included: false}, + {pattern: 'xmodule_js/common_static/common/js/utils/edx.utils.validate.js', included: false}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.event.drag-2.2.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/slick.core.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/slick.grid.js', included: true}, + {pattern: 'xmodule_js/common_static/js/libs/jasmine-waituntil.js', included: true}, + {pattern: 'xmodule_js/common_static/js/libs/jasmine-extensions.js', included: true} +]; + +// Paths to source JavaScript files +var sourceFiles = [ + {pattern: 'js/**/!(*spec).js', included: false}, + {pattern: 'coffee/src/**/*.js', included: false}, + {pattern: 'common/js/**/*.js', included: false}, + {pattern: 'edx-pattern-library/js/**/*.js', included: false}, + {pattern: 'edx-ui-toolkit/js/**/*.js', included: false}, + {pattern: 'support/js/**/!(*spec).js', included: false}, + {pattern: 'teams/js/**/!(*spec).js', included: false}, + {pattern: 'xmodule_js/common_static/coffee/**/*.js', included: false} +]; + +// Paths to spec (test) JavaScript files +var specFiles = [ + {pattern: 'js/spec/**/*spec.js', included: false}, + {pattern: 'teams/js/spec/**/*spec.js', included: false}, + {pattern: 'support/js/spec/**/*spec.js', included: false} +]; + +// Paths to fixture files +var fixtureFiles = [ + {pattern: 'js/fixtures/**/*.html', included: false}, + {pattern: 'templates/instructor/instructor_dashboard_2/**/*.*', included: false}, + {pattern: 'templates/dashboard/**/*.*', included: false}, + {pattern: 'templates/edxnotes/**/*.*', included: false}, + {pattern: 'templates/fields/**/*.*', included: false}, + {pattern: 'templates/student_account/**/*.*', included: false}, + {pattern: 'templates/student_profile/**/*.*', included: false}, + {pattern: 'templates/verify_student/**/*.*', included: false}, + {pattern: 'templates/file-upload.underscore', included: false}, + {pattern: 'templates/components/header/**/*.*', included: false}, + {pattern: 'templates/components/tabbed/**/*.*', included: false}, + {pattern: 'templates/components/card/**/*.*', included: false}, + {pattern: 'templates/financial-assistance/**/*.*', included: false}, + {pattern: 'templates/search/**/*.*', included: false}, + {pattern: 'templates/discovery/**/*.*', included: false}, + {pattern: 'common/templates/**/*.*', included: false}, + {pattern: 'teams/templates/**/*.*', included: false}, + {pattern: 'support/templates/**/*.*', included: false}, + {pattern: 'templates/bookmarks/**/*.*', included: false}, + {pattern: 'templates/learner_dashboard/**/*.*', included: false}, + {pattern: 'templates/ccx/**/*.*', included: false} +]; + +// override fixture path and other config. +var runAndConfigFiles = [ + {pattern: path.join(configModule.appRoot, 'common/static/common/js/jasmine.common.conf.js'), included: true}, + {pattern: 'js/spec/main.js', included: true} +]; + +// do not include tests or libraries +// (these files will be instrumented by Istanbul) +var preprocessors = configModule.getPreprocessorObject(_.flatten([sourceFiles, specFiles])); + +module.exports = function (config) { + var commonConfig = configModule.getConfig(config), + files = _.flatten([libraryFiles, sourceFiles, specFiles, fixtureFiles, runAndConfigFiles]), + localConfig; + + // add nocache in files if coverage is not set + if (!config.coverage) { + files.forEach(function (f) { + if (_.isObject(f)) { + f.nocache = true; + } + }); + } + + localConfig = { + files: files, + preprocessors: preprocessors + }; + + config.set(_.extend(commonConfig, localConfig)); +}; + diff --git a/lms/static/karma_lms_coffee.conf.js b/lms/static/karma_lms_coffee.conf.js new file mode 100644 index 0000000000..1f4f0d9ae2 --- /dev/null +++ b/lms/static/karma_lms_coffee.conf.js @@ -0,0 +1,105 @@ +// LMS Coffee Script Tests. +// +// To run all the tests and print results to the console: +// +// karma start lms/static/karma_lms_coffee.conf.js +// +// +// To run the tests for debugging: Debugging can be done in any browser +// but Chrome's developer console debugging experience is best. +// +// karma start lms/static/karma_lms_coffee.conf.js --browsers=BROWSER --single-run=false +// +// +// To run the tests with coverage and junit reports: +// +// karma start lms/static/karma_lms_coffee.conf.js --browsers=BROWSER +// --coverage --junitreportpath= --coveragereportpath= +// +// where `BROWSER` could be Chrome or Firefox. +// + +/* jshint node: true */ +/*jshint -W079 */ + +'use strict'; +var path = require('path'); +var _ = require('underscore'); +var configModule = require(path.join(__dirname, '../../common/static/common/js/karma.common.conf.js')); + +var libraryFiles = [ + // override fixture path and other config. + {pattern: path.join(configModule.appRoot, 'common/static/common/js/jasmine.common.conf.js'), included: true}, + + // vendor files + {pattern: 'xmodule_js/common_static/js/vendor/jquery.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/test/i18n.js', included: true}, + {pattern: 'xmodule_js/common_static/coffee/src/ajax_prefix.js', included: true}, + {pattern: 'xmodule_js/common_static/js/src/logger.js', included: true}, + {pattern: 'xmodule_js/common_static/common/js/vendor/underscore.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/jasmine-imagediff.js', included: true}, + {pattern: 'xmodule_js/common_static/js/libs/jasmine-extensions.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/requirejs/require.js', included: true}, + {pattern: 'js/spec/main_requirejs_coffee.js', included: true}, + {pattern: 'js/RequireJS-namespace-undefine.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery-ui.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/jquery.cookie.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/flot/jquery.flot.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/moment.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/moment-with-locales.min.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/URI.min.js', included: true}, + {pattern: 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js', included: true}, + {pattern: 'xmodule_js/common_static/js/xblock/*.js', included: true}, + {pattern: 'xmodule_js/common_static/coffee/src/xblock/*.js', included: true}, + {pattern: 'moment_requirejs.js', included: true}, + {pattern: 'xmodule_js/src/capa/*.js', included: true}, + {pattern: 'xmodule_js/src/video/*.js', included: true}, + {pattern: 'xmodule_js/src/xmodule.js', included: true}, + {pattern: 'xmodule_js/common_static/js/vendor/draggabilly.js', included: false}, + {pattern: 'xmodule_js/common_static/edx-ui-toolkit/js/utils/global-loader.js', included: true}, + {pattern: 'xmodule_js/common_static/edx-pattern-library/js/modernizr-custom.js', included: false}, + {pattern: 'xmodule_js/common_static/edx-pattern-library/js/afontgarde.js', included: false}, + {pattern: 'xmodule_js/common_static/edx-pattern-library/js/edx-icons.js', included: false} +]; + +// source files +var sourceFiles = [ + {pattern: 'coffee/src/**/*.js', included: true} +]; + +// spec files +var specFiles = [ + {pattern: 'coffee/spec/**/*.js', included: true} +]; + +// Fixtures +var fixtureFiles = [ + {pattern: 'coffee/fixtures/**/*.*', included: true} +]; + +// do not include tests or libraries +// (these files will be instrumented by Istanbul) +var preprocessors = configModule.getPreprocessorObject(_.flatten([sourceFiles, specFiles])); + +module.exports = function (config) { + var commonConfig = configModule.getConfig(config, false), + files = _.flatten([libraryFiles, sourceFiles, specFiles, fixtureFiles]), + localConfig; + + // add nocache in files if coverage is not set + if (!config.coverage) { + files.forEach(function (f) { + if (_.isObject(f)) { + f.nocache = true; + } + }); + } + + localConfig = { + files: files, + preprocessors: preprocessors + }; + + config.set(_.extend(commonConfig, localConfig)); +}; diff --git a/lms/static/moment_requirejs.js b/lms/static/moment_requirejs.js new file mode 100644 index 0000000000..745cd0b5c1 --- /dev/null +++ b/lms/static/moment_requirejs.js @@ -0,0 +1,15 @@ +(function(requirejs) { + 'use strict'; + + requirejs.config({ + paths: { + "moment": "/base/xmodule_js/common_static/js/vendor/moment.min" + }, + shim:{ + "moment": { + exports: "moment" + } + } + }); + +}).call(this, RequireJS.requirejs); diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index ae3dbb281f..bfa3ea5859 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -59,6 +59,7 @@ @import "views/financial-assistance"; @import 'views/bookmarks'; @import 'course/auto-cert'; +@import 'xseries_certificates'; @import 'views/program-list'; @import 'views/api-access'; diff --git a/lms/static/sass/_xseries_certificates.scss b/lms/static/sass/_xseries_certificates.scss new file mode 100644 index 0000000000..7cc9dab924 --- /dev/null +++ b/lms/static/sass/_xseries_certificates.scss @@ -0,0 +1,17 @@ +@mixin xseries-certificate-container { + border: 1px solid $gray-l3; + box-sizing: border-box; + padding: $baseline; + background: $gray-l6; + margin-top: $baseline; + .title{ + @extend %t-title6; + @extend %t-weight3; + margin-bottom:$baseline; + color: $gray; + } + .certificate-box{ + padding-top: $baseline; + display: block; + } +} diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index d181151b44..b502bb3712 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -212,9 +212,44 @@ } } +// CASE: empty dashboard + .empty-dashboard-message { + border: 3px solid $gray-l4; + background: $gray-l6; + padding: ($baseline*2) 0; + text-align: center; + + p { + @include font-size(24); + color: $lighter-base-font-color; + margin-bottom: $baseline; + text-shadow: 0 1px rgba(255,255,255, 0.6); + } + + a { + background-color: $blue; + border: 1px solid $blue; + box-shadow: 0 1px 8px 0 $shadow-l1; + @include box-sizing(border-box); + color: $white; + font-family: $sans-serif; + display: inline-block; + letter-spacing: 1px; + margin-top: ($baseline/4); + margin-left: ($baseline/4); + padding: 15px 20px; + + &:hover, &:focus { + background: $blue-l2; + text-decoration: none; + } + } + } + // +Dashboard - Course Listing // ==================== .dashboard { + .my-courses { @include float(left); margin: 0; @@ -231,43 +266,6 @@ } } - // CASE: empty dashboard - .empty-dashboard-message { - padding: ($baseline*2) 0; - text-align: center; - - p { - color: $lighter-base-font-color; - font-style: italic; - margin-bottom: $baseline; - text-shadow: 0 1px rgba(255,255,255, 0.6); - } - - a { - background: rgb(240,240,240); - @include background-image($button-bg-image); - background-color: $button-bg-color; - border: 1px solid $border-color-2; - border-radius: 4px; - box-shadow: 0 1px 8px 0 $shadow-l1; - @include box-sizing(border-box); - color: $base-font-color; - font-family: $sans-serif; - display: inline-block; - letter-spacing: 1px; - @include margin-left($baseline/4); - padding: 5px 10px; - text-shadow: 0 1px rgba(255,255,255, 0.6); - - &:hover, &:focus { - color: $link-color; - text-decoration: none; - } - } - } - - // ==================== - // UI: course list .listing-courses { @extend %ui-no-list; diff --git a/lms/static/sass/views/_program-list.scss b/lms/static/sass/views/_program-list.scss index b555e13ca8..f098e435bb 100644 --- a/lms/static/sass/views/_program-list.scss +++ b/lms/static/sass/views/_program-list.scss @@ -19,6 +19,11 @@ $pl-button-color: #0079bc; .sidebar{ @include outer-container; @include span-columns(12); + @include float(right); + margin-bottom: $baseline; + .certificate-container{ + @include xseries-certificate-container(); + } .program-advertise{ padding: $baseline; background-color: $body-bg; @@ -86,3 +91,67 @@ $pl-button-color: #0079bc; @include span-columns(3); } } + +// CASE: empty list of programs + .empty-programs-message { + border: 3px solid $gray-l4; + background: $gray-l6; + padding: ($baseline*2) 0; + text-align: center; + + p { + @include font-size(24); + color: $lighter-base-font-color; + margin-bottom: $baseline; + text-shadow: 0 1px rgba(255,255,255, 0.6); + } + + a { + @include box-sizing(border-box); + background-color: $blue; + border: 1px solid $blue; + box-shadow: 0 1px 8px 0 $shadow-l1; + color: $white; + font-family: $sans-serif; + display: inline-block; + letter-spacing: 1px; + margin-top: ($baseline/4); + margin-left: ($baseline/4); + padding: 15px 20px; + + &:hover, &:focus { + background: $blue-l2; + text-decoration: none; + } + } + + .find-xseries-programs { + @extend %btn-pl-black-base; + .action-xseries-icon { + @include float(left); + @include margin-right($baseline*0.4); + + display: inline; + background: url('#{$static-path}/images/icon-sm-xseries-white.png') no-repeat; + background-color: transparent; + + width: ($baseline*1.1); + height: ($baseline*1.1); + } + &:hover, + &:focus { + + .action-xseries-icon { + @include float(left); + @include margin-right($baseline*0.4); + + display: inline; + background: url('#{$static-path}/images/icon-sm-xseries-black.png') no-repeat; + background-color: transparent; + + width: ($baseline*1.1); + height: ($baseline*1.1); + } + } + } + } diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index fa54c3e540..be5a13baa9 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -104,12 +104,13 @@ from openedx.core.djangolib.markup import Text, HTML % else:
-

${_("Looks like you haven't enrolled in any courses yet.")}

+

${_("You are not enrolled in any courses yet.")}

% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): - ${_("Find courses now!")} + ${_("Explore courses")} + %endif
% endif diff --git a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore index 0e97cc3af9..6c608b4908 100644 --- a/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore +++ b/lms/templates/instructor/instructor_dashboard_2/certificate-white-list.underscore @@ -2,7 +2,7 @@


diff --git a/lms/templates/learner_dashboard/certificate.underscore b/lms/templates/learner_dashboard/certificate.underscore new file mode 100644 index 0000000000..b1b6a9c3fc --- /dev/null +++ b/lms/templates/learner_dashboard/certificate.underscore @@ -0,0 +1,7 @@ +

+

<%- gettext('XSeries Program Certificates') %>:

+ + <% _.each(certificatesData, function(certificate){ %> + <%- gettext(certificate.display_name) %> + <% }); %> +
diff --git a/lms/templates/learner_dashboard/empty_programs_list.underscore b/lms/templates/learner_dashboard/empty_programs_list.underscore new file mode 100644 index 0000000000..0fa80cf0f2 --- /dev/null +++ b/lms/templates/learner_dashboard/empty_programs_list.underscore @@ -0,0 +1,9 @@ + +
+

<%- gettext('You are not enrolled in any XSeries Programs yet.') %>

+ + + <%- gettext('Explore XSeries Programs') %> + +
+ diff --git a/lms/templates/learner_dashboard/programs.html b/lms/templates/learner_dashboard/programs.html index d563522aa2..3a7221e95d 100644 --- a/lms/templates/learner_dashboard/programs.html +++ b/lms/templates/learner_dashboard/programs.html @@ -12,7 +12,9 @@ from openedx.core.djangolib.js_utils import ( <%static:require_module module_name="js/learner_dashboard/program_list_factory" class_name="ProgramListFactory"> ProgramListFactory({ programsData: ${programs | n, dump_js_escaped_json}, - xseriesUrl: '${xseries_url | n, js_escaped_string}' + certificatesData: ${credentials | n, dump_js_escaped_json}, + xseriesUrl: '${xseries_url | n, js_escaped_string}', + xseriesImage: '${static.url('images/xseries-certificate-visual.png')}' }); diff --git a/lms/templates/learner_dashboard/sidebar.underscore b/lms/templates/learner_dashboard/sidebar.underscore index 6ddfcc18a5..c93b1b2f32 100644 --- a/lms/templates/learner_dashboard/sidebar.underscore +++ b/lms/templates/learner_dashboard/sidebar.underscore @@ -1,2 +1,2 @@
-
+
diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index e2fafa9e94..d87aedd9e4 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -67,7 +67,7 @@ site_status_msg = get_site_status_msg(course_id) <%block name="navigation_global_links_authenticated"> % if settings.FEATURES.get('COURSES_ARE_BROWSABLE') and not show_program_listing: % endif % if show_program_listing: @@ -145,7 +145,7 @@ site_status_msg = get_site_status_msg(course_id) % if not settings.FEATURES['DISABLE_LOGIN_BUTTON']: % if settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): %endif % if course and settings.FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD') and course.enrollment_domain: diff --git a/lms/templates/problem_ajax.html b/lms/templates/problem_ajax.html index 05c3c2ada8..664b7567c0 100644 --- a/lms/templates/problem_ajax.html +++ b/lms/templates/problem_ajax.html @@ -1 +1 @@ -
+
diff --git a/lms/templates/seq_module.html b/lms/templates/seq_module.html index 2bce3a658b..9629c67859 100644 --- a/lms/templates/seq_module.html +++ b/lms/templates/seq_module.html @@ -23,7 +23,7 @@ id="tab_${idx}"> -
${item['type']} ${item['title']} ${_("Bookmarked") if item['bookmarked'] else ""}
+
${item['type']} ${item['page_title']} ${_("Bookmarked") if item['bookmarked'] else ""}
% endfor diff --git a/lms/templates/verify_student/webcam_photo.underscore b/lms/templates/verify_student/webcam_photo.underscore index 30ceb58687..982c698f4c 100644 --- a/lms/templates/verify_student/webcam_photo.underscore +++ b/lms/templates/verify_student/webcam_photo.underscore @@ -10,7 +10,7 @@ diff --git a/lms/urls.py b/lms/urls.py index a4e18e3433..e5c64346b5 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -925,6 +925,10 @@ urlpatterns += ( url(r'^update_certificate$', 'certificates.views.update_certificate'), url(r'^update_example_certificate$', 'certificates.views.update_example_certificate'), url(r'^request_certificate$', 'certificates.views.request_certificate'), + + # REST APIs + url(r'^api/certificates/', + include('lms.djangoapps.certificates.apis.urls', namespace='certificates_api')), ) # XDomain proxy diff --git a/openedx/core/djangoapps/programs/tests/factories.py b/openedx/core/djangoapps/programs/tests/factories.py new file mode 100644 index 0000000000..10b97de2f3 --- /dev/null +++ b/openedx/core/djangoapps/programs/tests/factories.py @@ -0,0 +1,54 @@ +"""Factories for generating fake program-related data.""" +import factory +from factory.fuzzy import FuzzyText + + +class Program(factory.Factory): + """ + Factory for stubbing program resources from the Programs API (v1). + """ + class Meta(object): + model = dict + + id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name + name = FuzzyText(prefix='Program ') + subtitle = FuzzyText(prefix='Subtitle ') + category = 'xseries' + status = 'unpublished' + marketing_slug = FuzzyText(prefix='slug_') + organizations = [] + course_codes = [] + banner_image_urls = {} + + +class Organization(factory.Factory): + """ + Factory for stubbing nested organization resources from the Programs API (v1). + """ + class Meta(object): + model = dict + + key = FuzzyText(prefix='org_') + display_name = FuzzyText(prefix='Display Name ') + + +class CourseCode(factory.Factory): + """ + Factory for stubbing nested course code resources from the Programs API (v1). + """ + class Meta(object): + model = dict + + display_name = FuzzyText(prefix='Display Name ') + run_modes = [] + + +class RunMode(factory.Factory): + """ + Factory for stubbing nested run mode resources from the Programs API (v1). + """ + class Meta(object): + model = dict + + course_key = FuzzyText(prefix='org/', suffix='/run') + mode_slug = 'verified' diff --git a/openedx/core/djangoapps/programs/tests/mixins.py b/openedx/core/djangoapps/programs/tests/mixins.py index 3c1f710c95..8e62886f89 100644 --- a/openedx/core/djangoapps/programs/tests/mixins.py +++ b/openedx/core/djangoapps/programs/tests/mixins.py @@ -4,6 +4,7 @@ import json import httpretty from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.tests import factories class ProgramsApiConfigMixin(object): @@ -50,176 +51,48 @@ class ProgramsDataMixin(object): 'organization-b/course-d/winter', ] - # TODO: Use factory-boy. PROGRAMS_API_RESPONSE = { 'results': [ - { - 'id': 1, - 'name': PROGRAM_NAMES[0], - 'subtitle': 'A program used for testing purposes', - 'category': 'xseries', - 'status': 'unpublished', - 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[0].replace(' ', '_')), - 'organizations': [ - { - 'display_name': 'Test Organization A', - 'key': 'organization-a' - } - ], - 'course_codes': [ - { - 'display_name': 'Test Course A', - 'key': 'course-a', - 'organization': { - 'display_name': 'Test Organization A', - 'key': 'organization-a' - }, - 'run_modes': [ - { - 'course_key': COURSE_KEYS[0], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'fall' - }, - { - 'course_key': COURSE_KEYS[1], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'winter' - } - ] - }, - { - 'display_name': 'Test Course B', - 'key': 'course-b', - 'organization': { - 'display_name': 'Test Organization A', - 'key': 'organization-a' - }, - 'run_modes': [ - { - 'course_key': COURSE_KEYS[2], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'fall' - }, - { - 'course_key': COURSE_KEYS[3], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'winter' - } - ] - } - ], - 'created': '2015-10-26T17:52:32.861000Z', - 'modified': '2015-11-18T22:21:30.826365Z' - }, - { - 'id': 2, - 'name': PROGRAM_NAMES[1], - 'subtitle': 'Another program used for testing purposes', - 'category': 'xseries', - 'status': 'unpublished', - 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[1].replace(' ', '_')), - 'organizations': [ - { - 'display_name': 'Test Organization B', - 'key': 'organization-b' - } - ], - 'course_codes': [ - { - 'display_name': 'Test Course C', - 'key': 'course-c', - 'organization': { - 'display_name': 'Test Organization B', - 'key': 'organization-b' - }, - 'run_modes': [ - { - 'course_key': COURSE_KEYS[4], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'fall' - }, - { - 'course_key': COURSE_KEYS[5], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'winter' - } - ] - }, - { - 'display_name': 'Test Course D', - 'key': 'course-d', - 'organization': { - 'display_name': 'Test Organization B', - 'key': 'organization-b' - }, - 'run_modes': [ - { - 'course_key': COURSE_KEYS[6], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'fall' - }, - { - 'course_key': COURSE_KEYS[7], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'winter' - } - ] - } - ], - 'created': '2015-10-26T19:59:03.064000Z', - 'modified': '2015-10-26T19:59:18.536000Z' - }, - { - 'id': 3, - 'name': PROGRAM_NAMES[2], - 'subtitle': 'A third program used for testing purposes', - 'category': 'xseries', - 'status': 'unpublished', - 'marketing_slug': '{}_test_url'.format(PROGRAM_NAMES[2].replace(' ', '_')), - 'organizations': [ - { - 'display_name': 'Test Organization B', - 'key': 'organization-b' - } - ], - 'course_codes': [ - { - 'display_name': 'Test Course D', - 'key': 'course-d', - 'organization': { - 'display_name': 'Test Organization B', - 'key': 'organization-b' - }, - 'run_modes': [ - { - 'course_key': COURSE_KEYS[7], - 'mode_slug': 'verified', - 'sku': '', - 'start_date': '2015-11-05T07:39:02.791741Z', - 'run_key': 'winter' - } - ] - } - ], - 'created': '2015-10-26T19:59:03.064000Z', - 'modified': '2015-10-26T19:59:18.536000Z' - } + factories.Program( + id=1, + name=PROGRAM_NAMES[0], + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=COURSE_KEYS[0]), + factories.RunMode(course_key=COURSE_KEYS[1]), + ]), + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=COURSE_KEYS[2]), + factories.RunMode(course_key=COURSE_KEYS[3]), + ]), + ] + ), + factories.Program( + id=2, + name=PROGRAM_NAMES[1], + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=COURSE_KEYS[4]), + factories.RunMode(course_key=COURSE_KEYS[5]), + ]), + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=COURSE_KEYS[6]), + factories.RunMode(course_key=COURSE_KEYS[7]), + ]), + ] + ), + factories.Program( + id=3, + name=PROGRAM_NAMES[2], + organizations=[factories.Organization()], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(course_key=COURSE_KEYS[7]), + ]), + ] + ), ] } diff --git a/openedx/core/lib/api/jwt_decode_handler.py b/openedx/core/lib/api/jwt_decode_handler.py deleted file mode 100644 index 88d85619f8..0000000000 --- a/openedx/core/lib/api/jwt_decode_handler.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Custom JWT decoding function for django_rest_framework jwt package. - -Adds logging to facilitate debugging of InvalidTokenErrors. Also -requires "exp" and "iat" claims to be present - the base package -doesn't expose settings to enforce this. -""" -import logging - -from django.conf import settings -import jwt -from rest_framework import exceptions -from rest_framework_jwt.settings import api_settings - - -log = logging.getLogger(__name__) - - -def decode(token): - """ - Ensure InvalidTokenErrors are logged for diagnostic purposes, before - failing authentication. - """ - if not settings.FEATURES.get('ENABLE_JWT_AUTH', False): - msg = 'JWT auth not supported.' - log.error(msg) - raise exceptions.AuthenticationFailed(msg) - - options = { - 'verify_exp': api_settings.JWT_VERIFY_EXPIRATION, - 'require_exp': True, - 'require_iat': True, - } - - try: - return jwt.decode( - token, - api_settings.JWT_SECRET_KEY, - api_settings.JWT_VERIFY, - options=options, - leeway=api_settings.JWT_LEEWAY, - audience=api_settings.JWT_AUDIENCE, - issuer=api_settings.JWT_ISSUER, - algorithms=[api_settings.JWT_ALGORITHM] - ) - except jwt.InvalidTokenError as exc: - exc_type = u'{}.{}'.format(exc.__class__.__module__, exc.__class__.__name__) - log.exception("raised_invalid_token: exc_type=%r, exc_detail=%r", exc_type, exc.message) - raise diff --git a/openedx/core/lib/api/tests/test_authentication.py b/openedx/core/lib/api/tests/test_authentication.py index ab3f077a27..0b29898a9a 100644 --- a/openedx/core/lib/api/tests/test_authentication.py +++ b/openedx/core/lib/api/tests/test_authentication.py @@ -4,40 +4,33 @@ Tests for OAuth2. This module is copied from django-rest-framework-oauth """ from __future__ import unicode_literals -from collections import namedtuple -from datetime import datetime, timedelta + import itertools import json +from collections import namedtuple import ddt -from django.conf import settings +from datetime import datetime, timedelta from django.conf.urls import patterns, url, include from django.contrib.auth.models import User from django.http import HttpResponse from django.test import TestCase from django.utils import unittest from django.utils.http import urlencode -from mock import patch from nose.plugins.attrib import attr from oauth2_provider import models as dot_models -from rest_framework import exceptions +from provider import constants, scope from rest_framework import status from rest_framework.permissions import IsAuthenticated -from rest_framework_oauth import permissions -from rest_framework_oauth.compat import oauth2_provider, oauth2_provider_scope from rest_framework.test import APIRequestFactory, APIClient from rest_framework.views import APIView -from rest_framework_jwt.settings import api_settings +from rest_framework_oauth import permissions +from rest_framework_oauth.compat import oauth2_provider, oauth2_provider_scope from lms.djangoapps.oauth_dispatch import adapters from openedx.core.lib.api import authentication -from openedx.core.lib.api.tests.mixins import JwtMixin -from provider import constants, scope -from student.tests.factories import UserFactory - factory = APIRequestFactory() # pylint: disable=invalid-name -jwt_decode_handler = api_settings.JWT_DECODE_HANDLER # pylint: disable=invalid-name class MockView(APIView): # pylint: disable=missing-docstring @@ -311,32 +304,3 @@ class OAuth2Tests(TestCase): self.assertEqual(response.status_code, scope_statuses.read_status) response = self.post_with_bearer_token('/oauth2-with-scope-test/', token=self.access_token.token) self.assertEqual(response.status_code, scope_statuses.write_status) - - -@attr('shard_2') -@ddt.ddt -@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class TestJWTAuthToggle(JwtMixin, TestCase): - """ Test JWT authentication toggling with feature flag 'ENABLE_JWT_AUTH'.""" - - USERNAME = 'test-username' - - def setUp(self): - self.user = UserFactory.create(username=self.USERNAME) - self.jwt_token = self.generate_id_token(user=self.user) - super(TestJWTAuthToggle, self).setUp() - - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_JWT_AUTH': True}) - def test_enabled_jwt_auth(self): - """ Ensure that the JWT auth works fine when its feature flag - 'ENABLE_JWT_AUTH' is set. - """ - jwt_decode_handler(self.jwt_token) - - @patch.dict('django.conf.settings.FEATURES', {'ENABLE_JWT_AUTH': False}) - def test_disabled_jwt_auth(self): - """ Ensure that the JWT auth raises exception when its feature flag - 'ENABLE_JWT_AUTH' is not set. - """ - with self.assertRaises(exceptions.AuthenticationFailed): - jwt_decode_handler(self.jwt_token) diff --git a/openedx/core/lib/block_structure/cache.py b/openedx/core/lib/block_structure/cache.py index cbce9729e3..87cdfc4287 100644 --- a/openedx/core/lib/block_structure/cache.py +++ b/openedx/core/lib/block_structure/cache.py @@ -48,7 +48,7 @@ class BlockStructureCache(object): self._encode_root_cache_key(block_structure.root_block_usage_key), zp_data_to_cache ) - logger.debug( + logger.info( "Wrote BlockStructure %s to cache, size: %s", block_structure.root_block_usage_key, len(zp_data_to_cache), @@ -77,13 +77,13 @@ class BlockStructureCache(object): # Find root_block_usage_key in the cache. zp_data_from_cache = self._cache.get(self._encode_root_cache_key(root_block_usage_key)) if not zp_data_from_cache: - logger.debug( + logger.info( "Did not find BlockStructure %r in the cache.", root_block_usage_key, ) return None else: - logger.debug( + logger.info( "Read BlockStructure %r from cache, size: %s", root_block_usage_key, len(zp_data_from_cache), @@ -109,7 +109,7 @@ class BlockStructureCache(object): the cache. """ self._cache.delete(self._encode_root_cache_key(root_block_usage_key)) - logger.debug( + logger.info( "Deleted BlockStructure %r from the cache.", root_block_usage_key, ) diff --git a/openedx/core/lib/block_structure/transformers.py b/openedx/core/lib/block_structure/transformers.py index 493dcebde2..d306ea18b0 100644 --- a/openedx/core/lib/block_structure/transformers.py +++ b/openedx/core/lib/block_structure/transformers.py @@ -99,7 +99,7 @@ class BlockStructureTransformers(object): outdated_transformers.append(transformer) if outdated_transformers: - logger.debug( + logger.info( "Collected Block Structure data for the following transformers is outdated: '%s'.", [(transformer.name(), transformer.VERSION) for transformer in outdated_transformers], ) diff --git a/package.json b/package.json index bd881d9530..a8850a25b5 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,22 @@ "picturefill": "~3.0.2" }, "devDependencies": { - "jshint": "^2.7.0", "edx-custom-a11y-rules": "edx/edx-custom-a11y-rules", + "pa11y": "3.6.0", + "pa11y-reporter-1.0-json": "1.0.2", + "jasmine-core": "^2.4.1", + "jasmine-jquery": "^2.1.1", + "jquery": "^2.1.4", + "jshint": "^2.7.0", + "karma": "^0.13.22", + "karma-chrome-launcher": "^0.2.3", + "karma-coverage": "^0.5.5", + "karma-firefox-launcher": "^0.1.7", + "karma-jasmine": "^0.3.8", + "karma-jasmine-html-reporter": "^0.2.0", + "karma-junit-reporter": "^0.4.1", + "karma-spec-reporter": "^0.0.20", + "karma-requirejs": "^0.2.6", "plato": "1.2.2" } } diff --git a/pavelib/bok_choy.py b/pavelib/bok_choy.py index af76e7512a..1a206c1aa5 100644 --- a/pavelib/bok_choy.py +++ b/pavelib/bok_choy.py @@ -3,7 +3,7 @@ Run acceptance tests that use the bok-choy framework http://bok-choy.readthedocs.org/en/latest/ """ from paver.easy import task, needs, cmdopts, sh -from pavelib.utils.test.suites.bokchoy_suite import BokChoyTestSuite +from pavelib.utils.test.suites.bokchoy_suite import BokChoyTestSuite, Pa11yCrawler from pavelib.utils.envs import Env from pavelib.utils.test.utils import check_firefox_version from optparse import make_option @@ -24,6 +24,7 @@ BOKCHOY_OPTS = [ ('extra_args=', 'e', 'adds as extra args to the test command'), ('default_store=', 's', 'Default modulestore'), ('test_dir=', 'd', 'Directory for finding tests (relative to common/test/acceptance)'), + ('imports_dir=', 'i', 'Directory containing (un-archived) courses to be imported'), ('num_processes=', 'n', 'Number of test threads (for multiprocessing)'), ('verify_xss', 'x', 'Run XSS vulnerability tests'), make_option("--verbose", action="store_const", const=2, dest="verbosity"), @@ -53,6 +54,7 @@ def parse_bokchoy_opts(options): 'extra_args': getattr(options, 'extra_args', ''), 'pdb': getattr(options, 'pdb', False), 'test_dir': getattr(options, 'test_dir', 'tests'), + 'imports_dir': getattr(options, 'imports_dir', None), 'save_screenshots': getattr(options, 'save_screenshots', False), } @@ -115,30 +117,50 @@ def test_a11y(options): @task @needs('pavelib.prereqs.install_prereqs') -@cmdopts([ - ('test_spec=', 't', 'Specific test to run'), - ('fasttest', 'a', 'Skip some setup'), - ('imports_dir=', 'd', 'Directory containing (un-archived) courses to be imported'), - ('default_store=', 's', 'Default modulestore'), - 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"), -]) +@cmdopts(BOKCHOY_OPTS) def perf_report_bokchoy(options): """ Generates a har file for with page performance info. """ - opts = { - 'test_spec': getattr(options, 'test_spec', None), - 'fasttest': getattr(options, 'fasttest', False), - 'default_store': getattr(options, 'default_store', os.environ.get('DEFAULT_STORE', 'split')), - 'imports_dir': getattr(options, 'imports_dir', None), - 'verbosity': getattr(options, 'verbosity', 2), - 'test_dir': 'performance', - } + opts = parse_bokchoy_opts(options) + opts['test_dir'] = 'performance' + run_bokchoy(**opts) +@task +@needs('pavelib.prereqs.install_prereqs') +@cmdopts(BOKCHOY_OPTS + [ + ('with-html', 'w', 'Include html reports'), + make_option('--course-key', help='Course key for test course'), + make_option( + "--skip-fetch", + action="store_false", + dest="should_fetch_course", + help='Course key for test course', + ), +]) +def pa11ycrawler(options): + """ + Runs pa11ycrawler against the demo-test-course to generates accessibility + reports. (See https://github.com/edx/demo-test-course) + + Note: Like the bok-choy tests, this can be used with the `serversonly` + flag to get an environment running. The setup for this is the same as + for bok-choy tests, only test course is imported as well. + """ + opts = parse_bokchoy_opts(options) + opts['report_dir'] = Env.PA11YCRAWLER_REPORT_DIR + opts['coveragerc'] = Env.PA11YCRAWLER_COVERAGERC + opts['should_fetch_course'] = getattr(options, 'should_fetch_course', None) + opts['course_key'] = getattr(options, 'course-key', None) + test_suite = Pa11yCrawler('a11y_crawler', **opts) + test_suite.run() + + if getattr(options, 'with_html', False): + test_suite.generate_html_reports() + + def run_bokchoy(**opts): """ Runs BokChoyTestSuite with the given options. @@ -197,3 +219,14 @@ def a11y_coverage(): Env.BOK_CHOY_A11Y_REPORT_DIR, Env.BOK_CHOY_A11Y_COVERAGERC ) + + +@task +def pa11ycrawler_coverage(): + """ + Generate coverage reports for bok-choy tests + """ + parse_coverage( + Env.PA11YCRAWLER_REPORT_DIR, + Env.PA11YCRAWLER_COVERAGERC + ) diff --git a/pavelib/paver_tests/test_js_test.py b/pavelib/paver_tests/test_js_test.py index b14752574c..8689776908 100644 --- a/pavelib/paver_tests/test_js_test.py +++ b/pavelib/paver_tests/test_js_test.py @@ -6,6 +6,7 @@ from paver.easy import call_task import pavelib.js_test from .utils import PaverTestCase +from pavelib.utils.envs import Env @ddt.ddt @@ -20,21 +21,15 @@ class TestPaverJavaScriptTestTasks(PaverTestCase): u'node_modules/.bin/coffee --compile `find {platform_root}/lms {platform_root}/cms ' u'{platform_root}/common -type f -name "*.coffee"`' ) - EXPECTED_JS_TEST_TOOL_OPTIONS = ( - u"{platform_root}/lms/static/js_test.yml " - u"{platform_root}/lms/static/js_test_coffee.yml " - u"{platform_root}/cms/static/js_test.yml " - u"{platform_root}/cms/static/js_test_squire.yml " - u"{platform_root}/common/lib/xmodule/xmodule/js/js_test.yml " - u"{platform_root}/common/static/js_test.yml " - u"{platform_root}/common/static/js_test_requirejs.yml " - u"--use-firefox " - u"--timeout-sec 600 " - u"--xunit-report " - u"{platform_root}/reports/javascript/javascript_xunit.xml" + EXPECTED_KARMA_OPTIONS = ( + u"{config_file} " + u"--single-run={single_run} " + u"--capture-timeout=60000 " + u"--junitreportpath=" + u"{platform_root}/reports/javascript/javascript_xunit-{suite}.xml" ) EXPECTED_COVERAGE_OPTIONS = ( - u' --coverage-xml {platform_root}/reports/javascript/coverage.xml' + u' --coverage --coveragereportpath={platform_root}/reports/javascript/coverage-{suite}.xml' ) EXPECTED_COMMANDS = [ @@ -115,6 +110,8 @@ class TestPaverJavaScriptTestTasks(PaverTestCase): is_coverage = options['coverage'] port = options['port'] expected_messages = [] + suites = Env.JS_TEST_ID_KEYS if options['suite'] == 'all' else [options['suite']] + expected_messages.extend(self.EXPECTED_COMMANDS) if not dev_mode and not is_coverage: expected_messages.append(self.EXPECTED_DELETE_JAVASCRIPT_REPORT_COMMAND.format( @@ -122,13 +119,25 @@ class TestPaverJavaScriptTestTasks(PaverTestCase): )) expected_messages.append(self.EXPECTED_INSTALL_NPM_ASSETS_COMMAND) expected_messages.append(self.EXPECTED_COFFEE_COMMAND.format(platform_root=self.platform_root)) - expected_test_tool_command = u'js-test-tool {command} {options}'.format( - command='dev' if dev_mode else 'run', - options=self.EXPECTED_JS_TEST_TOOL_OPTIONS.format(platform_root=self.platform_root), - ) - if is_coverage: - expected_test_tool_command += self.EXPECTED_COVERAGE_OPTIONS.format(platform_root=self.platform_root) - if port: - expected_test_tool_command += u" -p {port}".format(port=port) - expected_messages.append(expected_test_tool_command) + + for suite in suites: + # Karma test command + karma_config_file = Env.KARMA_CONFIG_FILES[Env.JS_TEST_ID_KEYS.index(suite)] + expected_test_tool_command = u'karma start {options}'.format( + options=self.EXPECTED_KARMA_OPTIONS.format( + config_file=karma_config_file, + single_run='false' if dev_mode else 'true', + suite=suite, + platform_root=self.platform_root, + ), + ) + if is_coverage: + expected_test_tool_command += self.EXPECTED_COVERAGE_OPTIONS.format( + platform_root=self.platform_root, + suite=suite + ) + if port: + expected_test_tool_command += u" --port {port}".format(port=port) + expected_messages.append(expected_test_tool_command) + self.assertEquals(self.task_messages, expected_messages) diff --git a/pavelib/paver_tests/test_paver_bok_choy_cmds.py b/pavelib/paver_tests/test_paver_bok_choy_cmds.py index 849133ccb2..d9290c017f 100644 --- a/pavelib/paver_tests/test_paver_bok_choy_cmds.py +++ b/pavelib/paver_tests/test_paver_bok_choy_cmds.py @@ -4,9 +4,11 @@ Run just this test with: paver test_lib -t pavelib/paver_tests/test_paver_bok_ch """ import os import unittest + +from mock import patch, call from test.test_support import EnvironmentVarGuard from paver.easy import BuildFailure -from pavelib.utils.test.suites import BokChoyTestSuite +from pavelib.utils.test.suites import BokChoyTestSuite, Pa11yCrawler REPO_DIR = os.getcwd() @@ -167,3 +169,63 @@ class TestPaverBokChoyCmd(unittest.TestCase): suite = BokChoyTestSuite('', num_processes=2, verbosity=3) with self.assertRaises(BuildFailure): BokChoyTestSuite.verbosity_processes_string(suite) + + +class TestPaverPa11yCrawlerCmd(unittest.TestCase): + + """ + Paver pa11ycrawler command test cases. Most of the functionality is + inherited from BokChoyTestSuite, so those tests aren't duplicated. + """ + + def setUp(self): + super(TestPaverPa11yCrawlerCmd, self).setUp() + + # Mock shell commands + mock_sh = patch('pavelib.utils.test.suites.bokchoy_suite.sh') + self._mock_sh = mock_sh.start() + + # Cleanup mocks + self.addCleanup(mock_sh.stop) + + def _expected_command(self, report_dir, start_urls): + """ + Returns the expected command to run pa11ycrawler. + """ + expected_statement = ( + 'pa11ycrawler run {start_urls} ' + '--pa11ycrawler-allowed-domains=localhost ' + '--pa11ycrawler-reports-dir={report_dir} ' + '--pa11ycrawler-deny-url-matcher=logout ' + '--pa11y-reporter="1.0-json" ' + '--depth-limit=6 ' + ).format( + start_urls=start_urls, + report_dir=report_dir, + ) + return expected_statement + + def test_default(self): + suite = Pa11yCrawler('') + self.assertEqual( + suite.cmd, + self._expected_command(suite.pa11y_report_dir, suite.start_urls) + ) + + def test_get_test_course(self): + suite = Pa11yCrawler('') + suite.get_test_course() + self._mock_sh.assert_has_calls([ + call( + 'wget {targz} -O {dir}demo_course.tar.gz'.format(targz=suite.tar_gz_file, dir=suite.imports_dir)), + call( + 'tar zxf {dir}demo_course.tar.gz -C {dir}'.format(dir=suite.imports_dir)), + ]) + + def test_generate_html_reports(self): + suite = Pa11yCrawler('') + suite.generate_html_reports() + self._mock_sh.assert_has_calls([ + call( + 'pa11ycrawler json-to-html --pa11ycrawler-reports-dir={}'.format(suite.pa11y_report_dir)), + ]) diff --git a/pavelib/tests.py b/pavelib/tests.py index 9bce602df4..61ca58f5f0 100644 --- a/pavelib/tests.py +++ b/pavelib/tests.py @@ -1,6 +1,7 @@ """ Unit test tasks """ +import re import os import sys from paver.easy import sh, task, cmdopts, needs, call_task @@ -250,7 +251,7 @@ def diff_coverage(options): xml_reports = [] for filepath in Env.REPORT_DIR.walk(): - if filepath.basename() == 'coverage.xml': + if bool(re.match(r'^coverage.*\.xml$', filepath.basename())): xml_reports.append(filepath) if not xml_reports: diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index 6dc2f6ad45..4fa56bdf57 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -37,6 +37,9 @@ class Env(object): "lib" / "custom_a11y_rules.js" ) + PA11YCRAWLER_REPORT_DIR = REPORT_DIR / "pa11ycrawler" + PA11YCRAWLER_COVERAGERC = BOK_CHOY_DIR / ".pa11ycrawlercoveragerc" + # If set, put reports for run in "unique" directories. # The main purpose of this is to ensure that the reports can be 'slurped' # in the main jenkins flow job without overwriting the reports from other @@ -116,21 +119,21 @@ class Env(object): # Files used to run each of the js test suites # TODO: Store this as a dict. Order seems to matter for some # reason. See issue TE-415. - JS_TEST_ID_FILES = [ - REPO_ROOT / 'lms/static/js_test.yml', - REPO_ROOT / 'lms/static/js_test_coffee.yml', - REPO_ROOT / 'cms/static/js_test.yml', - REPO_ROOT / 'cms/static/js_test_squire.yml', - REPO_ROOT / 'common/lib/xmodule/xmodule/js/js_test.yml', - REPO_ROOT / 'common/static/js_test.yml', - REPO_ROOT / 'common/static/js_test_requirejs.yml', + KARMA_CONFIG_FILES = [ + REPO_ROOT / 'cms/static/karma_cms.conf.js', + REPO_ROOT / 'cms/static/karma_cms_squire.conf.js', + REPO_ROOT / 'lms/static/karma_lms.conf.js', + REPO_ROOT / 'lms/static/karma_lms_coffee.conf.js', + REPO_ROOT / 'common/lib/xmodule/xmodule/js/karma_xmodule.conf.js', + REPO_ROOT / 'common/static/karma_common.conf.js', + REPO_ROOT / 'common/static/karma_common_requirejs.conf.js', ] JS_TEST_ID_KEYS = [ - 'lms', - 'lms-coffee', 'cms', 'cms-squire', + 'lms', + 'lms-coffee', 'xmodule', 'common', 'common-requirejs' diff --git a/pavelib/utils/test/suites/__init__.py b/pavelib/utils/test/suites/__init__.py index afc24bf7db..5617a12900 100644 --- a/pavelib/utils/test/suites/__init__.py +++ b/pavelib/utils/test/suites/__init__.py @@ -6,4 +6,4 @@ from .nose_suite import NoseTestSuite, SystemTestSuite, LibTestSuite from .python_suite import PythonTestSuite from .js_suite import JsTestSuite from .acceptance_suite import AcceptanceTestSuite -from .bokchoy_suite import BokChoyTestSuite +from .bokchoy_suite import BokChoyTestSuite, Pa11yCrawler diff --git a/pavelib/utils/test/suites/bokchoy_suite.py b/pavelib/utils/test/suites/bokchoy_suite.py index 38461a311a..fc6a66a407 100644 --- a/pavelib/utils/test/suites/bokchoy_suite.py +++ b/pavelib/utils/test/suites/bokchoy_suite.py @@ -2,9 +2,11 @@ Class used for defining and running Bok Choy acceptance test suite """ from time import sleep +from urllib import urlencode from common.test.acceptance.fixtures.course import CourseFixture, FixtureError +from path import Path as path from paver.easy import sh, BuildFailure from pavelib.utils.test.suites.suite import TestSuite from pavelib.utils.envs import Env @@ -162,6 +164,26 @@ class BokChoyTestSuite(TestSuite): # load data in db_fixtures self.load_data() + # load courses if self.imports_dir is set + self.load_courses() + + # Ensure the test servers are available + msg = colorize('green', "Confirming servers are running...") + print msg + bokchoy_utils.start_servers(self.default_store, self.coveragerc) + + def load_courses(self): + """ + Loads courses from self.imports_dir. + + Note: self.imports_dir is the directory that contains the directories + that have courses in them. For example, if the course is located in + `test_root/courses/test-example-course/`, self.imports_dir should be + `test_root/courses/`. + """ + msg = colorize('green', "Importing courses from {}...".format(self.imports_dir)) + print msg + if self.imports_dir: sh( "DEFAULT_STORE={default_store}" @@ -171,11 +193,6 @@ class BokChoyTestSuite(TestSuite): ) ) - # Ensure the test servers are available - msg = colorize('green', "Confirming servers are running...") - print msg - bokchoy_utils.start_servers(self.default_store, self.coveragerc) - def load_data(self): """ Loads data into database from db_fixtures @@ -241,3 +258,113 @@ class BokChoyTestSuite(TestSuite): cmd = (" ").join(cmd) return cmd + + +class Pa11yCrawler(BokChoyTestSuite): + """ + Sets up test environment with mega-course loaded, and runs pa11ycralwer + against it. + """ + + def __init__(self, *args, **kwargs): + super(Pa11yCrawler, self).__init__(*args, **kwargs) + self.course_key = kwargs.get('course_key', "course-v1:edX+Test101+course") + if self.imports_dir: + # If imports_dir has been specified, assume the files are + # already there -- no need to fetch them from github. This + # allows someome to crawl a different course. They are responsible + # for putting it, un-archived, in the directory. + self.should_fetch_course = False + else: + # Otherwise, obey `--skip-fetch` command and use the default + # test course. Note that the fetch will also be skipped when + # using `--fast`. + self.should_fetch_course = kwargs.get('should_fetch_course', not self.fasttest) + self.imports_dir = path('test_root/courses/') + + self.pa11y_report_dir = os.path.join(self.report_dir, 'pa11ycrawler_reports') + self.tar_gz_file = "https://github.com/edx/demo-test-course/archive/master.tar.gz" + + self.start_urls = [] + auto_auth_params = { + "redirect": 'true', + "staff": 'true', + "course_id": self.course_key, + } + cms_params = urlencode(auto_auth_params) + self.start_urls.append("\"http://localhost:8031/auto_auth?{}\"".format(cms_params)) + + sequence_url = "/api/courses/v1/blocks/?{}".format( + urlencode({ + "course_id": self.course_key, + "depth": "all", + "all_blocks": "true", + }) + ) + auto_auth_params.update({'redirect_to': sequence_url}) + lms_params = urlencode(auto_auth_params) + self.start_urls.append("\"http://localhost:8003/auto_auth?{}\"".format(lms_params)) + + def __enter__(self): + if self.should_fetch_course: + self.get_test_course() + super(Pa11yCrawler, self).__enter__() + + def get_test_course(self): + """ + Fetches the test course. + """ + self.imports_dir.makedirs_p() + zipped_course = self.imports_dir + 'demo_course.tar.gz' + + msg = colorize('green', "Fetching the test course from github...") + print msg + + sh( + 'wget {tar_gz_file} -O {zipped_course}'.format( + tar_gz_file=self.tar_gz_file, + zipped_course=zipped_course, + ) + ) + + msg = colorize('green', "Uncompressing the test course...") + print msg + + sh( + 'tar zxf {zipped_course} -C {courses_dir}'.format( + zipped_course=zipped_course, + courses_dir=self.imports_dir, + ) + ) + + def generate_html_reports(self): + """ + Runs pa11ycrawler json-to-html + """ + cmd_str = ( + 'pa11ycrawler json-to-html --pa11ycrawler-reports-dir={report_dir}' + ).format(report_dir=self.pa11y_report_dir) + + sh(cmd_str) + + @property + def cmd(self): + """ + Runs pa11ycrawler as staff user against the test course. + """ + cmd_str = ( + 'pa11ycrawler run {start_urls} ' + '--pa11ycrawler-allowed-domains={allowed_domains} ' + '--pa11ycrawler-reports-dir={report_dir} ' + '--pa11ycrawler-deny-url-matcher={dont_go_here} ' + '--pa11y-reporter="{reporter}" ' + '--depth-limit={depth} ' + ).format( + start_urls=self.start_urls, + allowed_domains='localhost', + report_dir=self.pa11y_report_dir, + reporter="1.0-json", + dont_go_here="logout", + depth="6", + ) + return cmd_str diff --git a/pavelib/utils/test/suites/js_suite.py b/pavelib/utils/test/suites/js_suite.py index 6aa5b16096..6d810c4ca6 100644 --- a/pavelib/utils/test/suites/js_suite.py +++ b/pavelib/utils/test/suites/js_suite.py @@ -20,17 +20,11 @@ class JsTestSuite(TestSuite): super(JsTestSuite, self).__init__(*args, **kwargs) self.run_under_coverage = kwargs.get('with_coverage', True) self.mode = kwargs.get('mode', 'run') - self.port = kwargs.get('port') - - try: - self.test_id = (Env.JS_TEST_ID_FILES[Env.JS_TEST_ID_KEYS.index(self.root)]) - except ValueError: - self.test_id = ' '.join(Env.JS_TEST_ID_FILES) - - self.root = self.root + ' javascript' self.report_dir = Env.JS_REPORT_DIR - self.coverage_report = self.report_dir / 'coverage.xml' - self.xunit_report = self.report_dir / 'javascript_xunit.xml' + self.opts = kwargs + + suite = args[0] + self.subsuites = self._default_subsuites if suite == 'all' else [JsTestSubSuite(*args, **kwargs)] def __enter__(self): super(JsTestSuite, self).__enter__() @@ -47,27 +41,56 @@ class JsTestSuite(TestSuite): assets.process_npm_assets() assets.compile_coffeescript("`find lms cms common -type f -name \"*.coffee\"`") + @property + def _default_subsuites(self): + """ + Returns all JS test suites + """ + return [JsTestSubSuite(test_id, **self.opts) for test_id in Env.JS_TEST_ID_KEYS] + + +class JsTestSubSuite(TestSuite): + """ + Class for JS suites like cms, cms-squire, lms, lms-coffee, common, + common-requirejs and xmodule + """ + def __init__(self, *args, **kwargs): + super(JsTestSubSuite, self).__init__(*args, **kwargs) + self.test_id = args[0] + self.run_under_coverage = kwargs.get('with_coverage', True) + self.mode = kwargs.get('mode', 'run') + self.port = kwargs.get('port') + self.root = self.root + ' javascript' + self.report_dir = Env.JS_REPORT_DIR + + try: + self.test_conf_file = Env.KARMA_CONFIG_FILES[Env.JS_TEST_ID_KEYS.index(self.test_id)] + except ValueError: + self.test_conf_file = Env.KARMA_CONFIG_FILES[0] + + self.coverage_report = self.report_dir / 'coverage-{suite}.xml'.format(suite=self.test_id) + self.xunit_report = self.report_dir / 'javascript_xunit-{suite}.xml'.format(suite=self.test_id) + @property def cmd(self): """ - Run the tests using js-test-tool. See js-test-tool docs for - description of different command line arguments. + Run the tests using karma runner. """ cmd = ( - "js-test-tool {mode} {test_id} --use-firefox --timeout-sec " - "600 --xunit-report {xunit_report}".format( - mode=self.mode, - test_id=self.test_id, + "karma start {test_conf_file} --single-run={single_run} --capture-timeout=60000 " + "--junitreportpath={xunit_report}".format( + single_run='false' if self.mode == 'dev' else 'true', + test_conf_file=self.test_conf_file, xunit_report=self.xunit_report, ) ) if self.port: - cmd += " -p {port}".format(port=self.port) + cmd += " --port {port}".format(port=self.port) if self.run_under_coverage: - cmd += " --coverage-xml {report_dir}".format( - report_dir=self.coverage_report + cmd += " --coverage --coveragereportpath={report_path}".format( + report_path=self.coverage_report ) return cmd diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9f523ea145..871d04e725 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -34,11 +34,12 @@ django-method-override==0.1.0 #djangorestframework>=3.1,<3.2 git+https://github.com/edx/django-rest-framework.git@3c72cb5ee5baebc4328947371195eae2077197b0#egg=djangorestframework==3.2.3 django==1.8.12 -djangorestframework-jwt==1.7.2 +djangorestframework-jwt==1.8.0 djangorestframework-oauth==1.1.0 edx-ccx-keys==0.1.2 +edx-drf-extensions==0.5.0 edx-lint==0.4.3 -edx-management-commands==0.0.1 +edx-management-commands==0.1.1 edx-django-oauth2-provider==1.0.3 edx-oauth2-provider==1.0.1 edx-opaque-keys==0.2.1 diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index bf5b98c622..8c27b921b6 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -67,15 +67,15 @@ git+https://github.com/edx/rfc6266.git@v0.0.5-edx#egg=rfc6266==0.0.5-edx # Used for testing git+https://github.com/edx/lettuce.git@0.2.20.002#egg=lettuce==0.2.20.002 +git+https://github.com/edx/pa11ycrawler.git@0.0.1#egg=pa11ycrawler # Our libraries: -git+https://github.com/edx/XBlock.git@xblock-0.4.7#egg=XBlock==0.4.7 +git+https://github.com/edx/XBlock.git@xblock-0.4.8#egg=XBlock==0.4.8 -e git+https://github.com/edx/codejail.git@6b17c33a89bef0ac510926b1d7fea2748b73aadd#egg=codejail --e git+https://github.com/edx/js-test-tool.git@v0.1.6#egg=js_test_tool -e git+https://github.com/edx/event-tracking.git@0.2.1#egg=event-tracking==0.2.1 -e git+https://github.com/edx/django-splash.git@v0.2#egg=django-splash==0.2 -e git+https://github.com/edx/acid-block.git@e46f9cda8a03e121a00c7e347084d142d22ebfb7#egg=acid-xblock -git+https://github.com/edx/edx-ora2.git@1.1.3#egg=ora2==1.1.3 +git+https://github.com/edx/edx-ora2.git@1.1.4#egg=ora2==1.1.4 -e git+https://github.com/edx/edx-submissions.git@1.1.0#egg=edx-submissions==1.1.0 git+https://github.com/edx/ease.git@release-2015-07-14#egg=ease==0.1.3 git+https://github.com/edx/i18n-tools.git@v0.2#egg=i18n-tools==v0.2 diff --git a/scripts/accessibility-crawler.sh b/scripts/accessibility-crawler.sh new file mode 100644 index 0000000000..00b517938c --- /dev/null +++ b/scripts/accessibility-crawler.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -e + +echo "Setting up for accessibility tests..." +source scripts/jenkins-common.sh + +echo "Running pa11ycrawler against test course..." +paver pa11ycrawler + +echo "Generating coverage report..." +paver pa11ycrawler_coverage diff --git a/scripts/safe_template_linter.py b/scripts/safe_template_linter.py index e543adb684..23cf001a1d 100755 --- a/scripts/safe_template_linter.py +++ b/scripts/safe_template_linter.py @@ -40,7 +40,7 @@ def _is_skip_dir(skip_dirs, directory): return False -def _load_file(self, file_full_path): +def _load_file(file_full_path): """ Loads a file into a string. @@ -56,6 +56,77 @@ def _load_file(self, file_full_path): return file_contents.decode(encoding='utf-8') +def _find_closing_char_index(start_delim, open_char, close_char, template, start_index, num_open_chars=0, strings=None): + """ + Finds the index of the closing char that matches the opening char. + + For example, this could be used to find the end of a Mako expression, + where the open and close characters would be '{' and '}'. + + Arguments: + start_delim: If provided (e.g. '${' for Mako expressions), the + closing character must be found before the next start_delim. + open_char: The opening character to be matched (e.g '{') + close_char: The closing character to be matched (e.g '}') + template: The template to be searched. + start_index: The start index of the last open char. + num_open_chars: The current number of open chars. + strings: A list of ParseStrings already parsed + + Returns: + A dict containing the following, or None if unparseable: + close_char_index: The index of the closing character + strings: a list of ParseStrings + + """ + strings = [] if strings is None else strings + close_char_index = template.find(close_char, start_index) + if close_char_index < 0: + # if we can't find an end_char, let's just quit + return None + open_char_index = template.find(open_char, start_index, close_char_index) + parse_string = ParseString(template, start_index, close_char_index) + + valid_index_list = [close_char_index] + if 0 <= open_char_index: + valid_index_list.append(open_char_index) + if parse_string.start_index is not None: + valid_index_list.append(parse_string.start_index) + min_valid_index = min(valid_index_list) + + if parse_string.start_index == min_valid_index: + strings.append(parse_string) + if parse_string.end_index is None: + return None + else: + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=parse_string.end_index, + num_open_chars=num_open_chars, strings=strings + ) + + if open_char_index == min_valid_index: + if start_delim is not None: + # if we find another starting delim, consider this unparseable + start_delim_index = template.find(start_delim, start_index, close_char_index) + if 0 <= start_delim_index < open_char_index: + return None + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=open_char_index + 1, + num_open_chars=num_open_chars + 1, strings=strings + ) + + if num_open_chars == 0: + return { + 'close_char_index': close_char_index, + 'strings': strings, + } + else: + return _find_closing_char_index( + start_delim, open_char, close_char, template, start_index=close_char_index + 1, + num_open_chars=num_open_chars - 1, strings=strings + ) + + class StringLines(object): """ StringLines provides utility methods to work with a string in terms of @@ -72,7 +143,10 @@ class StringLines(object): """ self._string = string - self._line_breaks = self._process_line_breaks(string) + self._line_start_indexes = self._process_line_breaks(string) + # this is an exclusive index used in the case that the template doesn't + # end with a new line + self.eof_index = len(string) def _process_line_breaks(self, string): """ @@ -83,19 +157,18 @@ class StringLines(object): string: The string in which to find line breaks. Returns: - A list of indices into the string at which each line break can be - found. + A list of indices into the string at which each line begins. """ - line_breaks = [0] + line_start_indexes = [0] index = 0 while True: index = string.find('\n', index) if index < 0: break index += 1 - line_breaks.append(index) - return line_breaks + line_start_indexes.append(index) + return line_start_indexes def get_string(self): """ @@ -116,7 +189,7 @@ class StringLines(object): """ current_line_number = 0 - for line_break_index in self._line_breaks: + for line_break_index in self._line_start_indexes: if line_break_index <= index: current_line_number += 1 else: @@ -154,6 +227,20 @@ class StringLines(object): line_number = self.index_to_line_number(index) return self.line_number_to_start_index(line_number) + def index_to_line_end_index(self, index): + """ + Gets the index of the end of the line of the given index. + + Arguments: + index: The index into the original string. + + Returns: + The index of the end of the line of the given index. + + """ + line_number = self.index_to_line_number(index) + return self.line_number_to_end_index(line_number) + def line_number_to_start_index(self, line_number): """ Gets the starting index for the provided line number. @@ -166,7 +253,26 @@ class StringLines(object): The starting index for the provided line number. """ - return self._line_breaks[line_number - 1] + return self._line_start_indexes[line_number - 1] + + def line_number_to_end_index(self, line_number): + """ + Gets the ending index for the provided line number. + + Arguments: + line_number: The line number of the line for which we want to find + the end index. + + Returns: + The ending index for the provided line number. + + """ + if line_number < len(self._line_start_indexes): + return self._line_start_indexes[line_number] + else: + # an exclusive index in the case that the file didn't end with a + # newline. + return self.eof_index def line_number_to_line(self, line_number): """ @@ -179,11 +285,11 @@ class StringLines(object): The line of text designated by the provided line number. """ - start_index = self._line_breaks[line_number - 1] - if len(self._line_breaks) == line_number: + start_index = self._line_start_indexes[line_number - 1] + if len(self._line_start_indexes) == line_number: line = self._string[start_index:] else: - end_index = self._line_breaks[line_number] + end_index = self._line_start_indexes[line_number] line = self._string[start_index:end_index - 1] return line @@ -191,7 +297,7 @@ class StringLines(object): """ Gets the number of lines in the string. """ - return len(self._line_breaks) + return len(self._line_start_indexes) class Rules(Enum): @@ -222,6 +328,14 @@ class Rules(Enum): 'mako-invalid-js-filter', 'The expression is using an invalid filter in a JavaScript context.' ) + mako_js_missing_quotes = ( + 'mako-js-missing-quotes', + 'An expression using js_escaped_string must be wrapped in quotes.' + ) + mako_js_html_string = ( + 'mako-js-html-string', + 'A JavaScript string containing HTML should not have an embedded Mako expression.' + ) mako_deprecated_display_name = ( 'mako-deprecated-display-name', 'Replace deprecated display_name_with_default_escaped with display_name_with_default.' @@ -246,16 +360,103 @@ class Rules(Enum): 'mako-wrap-html', "String containing HTML should be wrapped with call to HTML()." ) + mako_html_entities = ( + 'mako-html-entities', + "HTML entities should be plain text or wrapped with HTML()." + ) underscore_not_escaped = ( 'underscore-not-escaped', 'Expressions should be escaped using <%- expression %>.' ) + javascript_jquery_append = ( + 'javascript-jquery-append', + 'Use HtmlUtils.append() or .append(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_prepend = ( + 'javascript-jquery-prepend', + 'Use HtmlUtils.prepend() or .prepend(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_insertion = ( + 'javascript-jquery-insertion', + 'JQuery DOM insertion calls that take content must use HtmlUtils (e.g. $el.after(HtmlUtils.xxx().toString()).' + ) + javascript_jquery_insert_into_target = ( + 'javascript-jquery-insert-into-target', + 'JQuery DOM insertion calls that take a target can only be called from elements (e.g. .$el.appendTo()).' + ) + javascript_jquery_html = ( + 'javascript-jquery-html', + "Use HtmlUtils.setHtml(), .html(HtmlUtils.xxx().toString()), or JQuery's text() function." + ) + javascript_concat_html = ( + 'javascript-concat-html', + 'Use HtmlUtils functions rather than concatenating strings with HTML.' + ) + javascript_escape = ( + 'javascript-escape', + "Avoid calls to escape(), especially in Backbone. Use templates, HtmlUtils, or JQuery's text() function." + ) + javascript_interpolate = ( + 'javascript-interpolate', + 'Use StringUtils.interpolate() or HtmlUtils.interpolateHtml() as appropriate.' + ) def __init__(self, rule_id, rule_summary): self.rule_id = rule_id self.rule_summary = rule_summary +class Expression(object): + """ + Represents an arbitrary expression. + + An expression can be any type of code snippet. It will sometimes have a + starting and ending delimiter, but not always. + + Here are some example expressions:: + + ${x | n, decode.utf8} + <%= x %> + function(x) + "

" + message + "

" + + Other details of note: + - Only a start_index is required for a valid expression. + - If end_index is None, it means we couldn't parse the rest of the + expression. + - All other details of the expression are optional, and are only added if + and when supplied and needed for additional checks. They are not necessary + for the final results output. + + """ + + def __init__(self, start_index, end_index=None, template=None, start_delim="", end_delim="", strings=None): + """ + Init method. + + Arguments: + start_index: the starting index of the expression + end_index: the index immediately following the expression, or None + if the expression was unparseable + template: optional template code in which the expression was found + start_delim: optional starting delimiter of the expression + end_delim: optional ending delimeter of the expression + strings: optional list of ParseStrings + + """ + self.start_index = start_index + self.end_index = end_index + self.start_delim = start_delim + self.end_delim = end_delim + self.strings = strings + if template is not None and self.end_index is not None: + self.expression = template[start_index:end_index] + self.expression_inner = self.expression[len(start_delim):-len(end_delim)].strip() + else: + self.expression = None + self.expression_inner = None + + class RuleViolation(object): """ Base class representing a rule violation which can be used for reporting. @@ -308,6 +509,18 @@ class RuleViolation(object): self.is_disabled = True return + def sort_key(self): + """ + Returns a key that can be sorted on + """ + return 0 + + def first_line(self): + """ + Since a file level rule has no first line, returns empty string. + """ + return '' + def prepare_results(self, full_path, string_lines): """ Preps this instance for results reporting. @@ -345,7 +558,7 @@ class ExpressionRuleViolation(RuleViolation): Arguments: rule: The Rule which was violated. - expression: The expression that was in violation. + expression: The Expression that was in violation. """ super(ExpressionRuleViolation, self).__init__(rule) @@ -396,6 +609,18 @@ class ExpressionRuleViolation(RuleViolation): line_to_check = string_lines.line_number_to_line(self.start_line) self._mark_disabled(line_to_check, scope_start_string=False) + def sort_key(self): + """ + Returns a key that can be sorted on + """ + return (self.start_line, self.start_column) + + def first_line(self): + """ + Returns the initial line of code of the violation. + """ + return self.lines[0] + def prepare_results(self, full_path, string_lines): """ Preps this instance for results reporting. @@ -407,11 +632,11 @@ class ExpressionRuleViolation(RuleViolation): """ self.full_path = full_path - start_index = self.expression['start_index'] + start_index = self.expression.start_index self.start_line = string_lines.index_to_line_number(start_index) self.start_column = string_lines.index_to_column_number(start_index) - end_index = self.expression['end_index'] - if end_index > 0: + end_index = self.expression.end_index + if end_index is not None: self.end_line = string_lines.index_to_line_number(end_index) self.end_column = string_lines.index_to_column_number(end_index) else: @@ -463,17 +688,21 @@ class FileResults(object): self.is_file = os.path.isfile(full_path) self.violations = [] - def prepare_results(self, file_string): + def prepare_results(self, file_string, line_comment_delim=None): """ Prepares the results for output for this file. Arguments: file_string: The string of content for this file. + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. """ string_lines = StringLines(file_string) for violation in self.violations: violation.prepare_results(self.full_path, string_lines) + if line_comment_delim is not None: + self._filter_commented_code(line_comment_delim) def print_results(self, options, out): """ @@ -485,14 +714,49 @@ class FileResults(object): all violations. out: output file + Returns: + The number of violations. When using --quiet, returns number of + files with violations. """ + num_violations = 0 if options['is_quiet']: - print(self.full_path, file=out) + if self.violations is not None and 0 < len(self.violations): + num_violations += 1 + print(self.full_path, file=out) else: + self.violations.sort(key=lambda violation: violation.sort_key()) for violation in self.violations: if not violation.is_disabled: + num_violations += 1 violation.print_results(out) + return num_violations + + def _filter_commented_code(self, line_comment_delim): + """ + Remove any violations that were found in commented out code. + + Arguments: + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. + + """ + self.violations = [v for v in self.violations if not self._is_commented(v, line_comment_delim)] + + def _is_commented(self, violation, line_comment_delim): + """ + Checks if violation line is commented out. + + Arguments: + violation: The violation to check + line_comment_delim: A string representing the start of a line + comment. For example "##" for Mako and "//" for JavaScript. + + Returns: + True if the first line of the violation is actually commented out, + False otherwise. + """ + return violation.first_line().lstrip().startswith(line_comment_delim) class ParseString(object): @@ -500,8 +764,8 @@ class ParseString(object): ParseString is the result of parsing a string out of a template. A ParseString has the following attributes: - start_index: The index of the first quote, or -1 if none found - end_index: The index following the closing quote, or -1 if + start_index: The index of the first quote, or None if none found + end_index: The index following the closing quote, or None if unparseable quote_length: The length of the quote. Could be 3 for a Python triple quote. Or None if none found. @@ -521,12 +785,12 @@ class ParseString(object): end_index: The end index to search before. """ - self.end_index = -1 + self.end_index = None self.quote_length = None self.string = None self.string_inner = None self.start_index = self._find_string_start(template, start_index, end_index) - if 0 <= self.start_index: + if self.start_index is not None: result = self._parse_string(template, self.start_index) if result is not None: self.end_index = result['end_index'] @@ -545,13 +809,13 @@ class ParseString(object): end_index: The end index to search before. Returns: - The start index of the first single or double quote, or -1 if - no quote was found. + The start index of the first single or double quote, or None if no + quote was found. """ quote_regex = re.compile(r"""['"]""") start_match = quote_regex.search(template, start_index, end_index) if start_match is None: - return -1 + return None else: return start_match.start() @@ -599,12 +863,540 @@ class ParseString(object): } +class UnderscoreTemplateLinter(object): + """ + The linter for Underscore.js template files. + """ + + _skip_underscore_dirs = _skip_dirs + ('test',) + + def process_file(self, directory, file_name): + """ + Process file to determine if it is an Underscore template file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential underscore file + + Returns: + The file results containing any violations. + + """ + full_path = os.path.normpath(directory + '/' + file_name) + results = FileResults(full_path) + + if not self._is_valid_directory(directory): + return results + + if not file_name.lower().endswith('.underscore'): + return results + + return self._load_and_check_underscore_file_is_safe(full_path, results) + + def _is_valid_directory(self, directory): + """ + Determines if the provided directory is a directory that could contain + Underscore.js template files that need to be linted. + + Arguments: + directory: The directory to be linted. + + Returns: + True if this directory should be linted for Underscore.js template + violations and False otherwise. + """ + if _is_skip_dir(self._skip_underscore_dirs, directory): + return False + + return True + + def _load_and_check_underscore_file_is_safe(self, file_full_path, results): + """ + Loads the Underscore.js template file and checks if it is in violation. + + Arguments: + file_full_path: The file to be loaded and linted + + Returns: + The file results containing any violations. + + """ + underscore_template = _load_file(file_full_path) + self.check_underscore_file_is_safe(underscore_template, results) + return results + + def check_underscore_file_is_safe(self, underscore_template, results): + """ + Checks for violations in an Underscore.js template. + + Arguments: + underscore_template: The contents of the Underscore.js template. + results: A file results objects to which violations will be added. + + """ + self._check_underscore_expressions(underscore_template, results) + results.prepare_results(underscore_template) + + def _check_underscore_expressions(self, underscore_template, results): + """ + Searches for Underscore.js expressions that contain violations. + + Arguments: + underscore_template: The contents of the Underscore.js template. + results: A list of results into which violations will be added. + + """ + expressions = self._find_unescaped_expressions(underscore_template) + for expression in expressions: + if not self._is_safe_unescaped_expression(expression): + results.violations.append(ExpressionRuleViolation( + Rules.underscore_not_escaped, expression + )) + + def _is_safe_unescaped_expression(self, expression): + """ + Determines whether an expression is safely escaped, even though it is + using the expression syntax that doesn't itself escape (i.e. <%= ). + + In some cases it is ok to not use the Underscore.js template escape + (i.e. <%- ) because the escaping is happening inside the expression. + + Safe examples:: + + <%= HtmlUtils.ensureHtml(message) %> + <%= _.escape(message) %> + + Arguments: + expression: The Expression being checked. + + Returns: + True if the Expression has been safely escaped, and False otherwise. + + """ + if expression.expression_inner.startswith('HtmlUtils.'): + return True + if expression.expression_inner.startswith('_.escape('): + return True + return False + + def _find_unescaped_expressions(self, underscore_template): + """ + Returns a list of unsafe expressions. + + At this time all expressions that are unescaped are considered unsafe. + + Arguments: + underscore_template: The contents of the Underscore.js template. + + Returns: + A list of Expressions. + """ + unescaped_expression_regex = re.compile("<%=.*?%>", re.DOTALL) + + expressions = [] + for match in unescaped_expression_regex.finditer(underscore_template): + expression = Expression( + match.start(), match.end(), template=underscore_template, start_delim="<%=", end_delim="%>" + ) + expressions.append(expression) + return expressions + + +class JavaScriptLinter(object): + """ + The linter for JavaScript and CoffeeScript files. + """ + + _skip_javascript_dirs = _skip_dirs + ('i18n', 'static/coffee') + _skip_coffeescript_dirs = _skip_dirs + underScoreLinter = UnderscoreTemplateLinter() + + def process_file(self, directory, file_name): + """ + Process file to determine if it is a JavaScript file and + if it is safe. + + Arguments: + directory (string): The directory of the file to be checked + file_name (string): A filename for a potential JavaScript file + + Returns: + The file results containing any violations. + + """ + file_full_path = os.path.normpath(directory + '/' + file_name) + results = FileResults(file_full_path) + + if not results.is_file: + return results + + if file_name.lower().endswith('.js') and not file_name.lower().endswith('.min.js'): + skip_dirs = self._skip_javascript_dirs + elif file_name.lower().endswith('.coffee'): + skip_dirs = self._skip_coffeescript_dirs + else: + return results + + if not self._is_valid_directory(skip_dirs, directory): + return results + + return self._load_and_check_javascript_file_is_safe(file_full_path, results) + + def _is_valid_directory(self, skip_dirs, directory): + """ + Determines if the provided directory is a directory that could contain + a JavaScript file that needs to be linted. + + Arguments: + skip_dirs: The directories to be skipped. + directory: The directory to be linted. + + Returns: + True if this directory should be linted for JavaScript violations + and False otherwise. + """ + if _is_skip_dir(skip_dirs, directory): + return False + + return True + + def _load_and_check_javascript_file_is_safe(self, file_full_path, results): + """ + Loads the JavaScript file and checks if it is in violation. + + Arguments: + file_full_path: The file to be loaded and linted. + + Returns: + The file results containing any violations. + + """ + file_contents = _load_file(file_full_path) + self.check_javascript_file_is_safe(file_contents, results) + return results + + def check_javascript_file_is_safe(self, file_contents, results): + """ + Checks for violations in a JavaScript file. + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + no_caller_check = None + no_argument_check = None + self._check_jquery_function( + file_contents, "append", Rules.javascript_jquery_append, no_caller_check, + self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "prepend", Rules.javascript_jquery_prepend, no_caller_check, + self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "unwrap|wrap|wrapAll|wrapInner|after|before|replaceAll|replaceWith", + Rules.javascript_jquery_insertion, no_caller_check, self._is_jquery_argument_safe, results + ) + self._check_jquery_function( + file_contents, "appendTo|prependTo|insertAfter|insertBefore", + Rules.javascript_jquery_insert_into_target, self._is_jquery_insert_caller_safe, no_argument_check, results + ) + self._check_jquery_function( + file_contents, "html", Rules.javascript_jquery_html, no_caller_check, + self._is_jquery_html_argument_safe, results + ) + self._check_javascript_interpolate(file_contents, results) + self._check_javascript_escape(file_contents, results) + self._check_concat_with_html(file_contents, results) + self.underScoreLinter.check_underscore_file_is_safe(file_contents, results) + results.prepare_results(file_contents, line_comment_delim='//') + + def _get_expression_for_function(self, file_contents, function_start_match): + """ + Returns an expression that matches the function call opened with + function_start_match. + + Arguments: + file_contents: The contents of the JavaScript file. + function_start_match: A regex match representing the start of the function + call (e.g. ".escape("). + + Returns: + An Expression that best matches the function. + + """ + start_index = function_start_match.start() + inner_start_index = function_start_match.end() + result = _find_closing_char_index( + None, "(", ")", file_contents, start_index=inner_start_index + ) + if result is not None: + end_index = result['close_char_index'] + 1 + expression = Expression( + start_index, end_index, template=file_contents, start_delim=function_start_match.group(), end_delim=")" + ) + else: + expression = Expression(start_index) + return expression + + def _check_javascript_interpolate(self, file_contents, results): + """ + Checks that interpolate() calls are safe. + + Only use of StringUtils.interpolate() or HtmlUtils.interpolateText() + are safe. + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + # Ignores calls starting with "StringUtils.", because those are safe + regex = re.compile(r"(?'))" + or ".append($('
'))". + - the argument can be a call to HtmlUtils.xxx(html).toString() + + Arguments: + argument: The argument sent to the jQuery function (e.g. + append(argument)). + + Returns: + True if the argument is safe, and False otherwise. + + """ + match_variable_name = re.search("[_$a-zA-Z]+[_$a-zA-Z0-9]*", argument) + if match_variable_name is not None and match_variable_name.group() == argument: + if argument.endswith('El') or argument.startswith('$'): + return True + elif argument.startswith('"') or argument.startswith("'"): + # a single literal string with no HTML is ok + # 1. it gets rid of false negatives for non-jquery calls (e.g. graph.append("g")) + # 2. JQuery will treat this as a plain text string and will escape any & if needed. + string = ParseString(argument, 0, len(argument)) + if string.string == argument and "<" not in argument: + return True + elif argument.startswith('$('): + # match on JQuery calls with single string and single HTML tag + # Examples: + # $("") + # $("
") + # $("
", {...}) + match = re.search(r"""\$\(\s*['"]<[a-zA-Z0-9]+\s*[/]?>['"]\s*[,)]""", argument) + if match is not None: + return True + elif self._is_jquery_argument_safe_html_utils_call(argument): + return True + # check rules that shouldn't use concatenation + elif "+" not in argument: + if argument.endswith('.el') or argument.endswith('.$el'): + return True + return False + + def _is_jquery_html_argument_safe(self, argument): + """ + Check the argument sent to the jQuery html() function to check if it is + safe. + + Safe arguments to html(): + - no argument (i.e. getter rather than setter) + - empty string is safe + - the argument can be a call to HtmlUtils.xxx(html).toString() + + Arguments: + argument: The argument sent to html() in code (i.e. html(argument)). + + Returns: + True if the argument is safe, and False otherwise. + + """ + if argument == "" or argument == "''" or argument == '""': + return True + elif self._is_jquery_argument_safe_html_utils_call(argument): + return True + return False + + def _is_jquery_insert_caller_safe(self, caller_line_start): + """ + Check that the caller of a jQuery DOM insertion function that takes a + target is safe (e.g. thisEl.appendTo(target)). + + If original line was:: + + draggableObj.iconEl.appendTo(draggableObj.containerEl); + + Parameter caller_line_start would be: + + draggableObj.iconEl + + Safe callers include: + - the caller can be ".el", ".$el" + - the caller can be a single variable ending in "El" or starting with + "$". For example, "testEl" or "$test". + + Arguments: + caller_line_start: The line leading up to the jQuery function call. + + Returns: + True if the caller is safe, and False otherwise. + + """ + # matches end of line for caller, which can't itself be a function + caller_match = re.search(r"(?:\s*|[.])([_$a-zA-Z]+[_$a-zA-Z0-9])*$", caller_line_start) + if caller_match is None: + return False + caller = caller_match.group(1) + if caller is None: + return False + elif caller.endswith('El') or caller.startswith('$'): + return True + elif caller == 'el' or caller == 'parentNode': + return True + return False + + def _check_concat_with_html(self, file_contents, results): + """ + Checks that strings with HTML are not concatenated + + Arguments: + file_contents: The contents of the JavaScript file. + results: A file results objects to which violations will be added. + + """ + lines = StringLines(file_contents) + last_expression = None + # attempt to match a string that starts with '<' or ends with '>' + regex_string_with_html = r"""["'](?:\s*<.*|.*>\s*)["']""" + regex_concat_with_html = r"(\+\s*{}|{}\s*\+)".format(regex_string_with_html, regex_string_with_html) + for match in re.finditer(regex_concat_with_html, file_contents): + found_new_violation = False + if last_expression is not None: + last_line = lines.index_to_line_number(last_expression.start_index) + # check if violation should be expanded to more of the same line + if last_line == lines.index_to_line_number(match.start()): + last_expression = Expression( + last_expression.start_index, match.end(), template=file_contents + ) + else: + results.violations.append(ExpressionRuleViolation( + Rules.javascript_concat_html, last_expression + )) + found_new_violation = True + else: + found_new_violation = True + if found_new_violation: + last_expression = Expression( + match.start(), match.end(), template=file_contents + ) + + # add final expression + if last_expression is not None: + results.violations.append(ExpressionRuleViolation( + Rules.javascript_concat_html, last_expression + )) + + class MakoTemplateLinter(object): """ The linter for Mako template files. """ _skip_mako_dirs = _skip_dirs + javaScriptLinter = JavaScriptLinter() def process_file(self, directory, file_name): """ @@ -673,7 +1465,7 @@ class MakoTemplateLinter(object): The file results containing any violations. """ - mako_template = _load_file(self, mako_file_full_path) + mako_template = _load_file(mako_file_full_path) self._check_mako_file_is_safe(mako_template, results) return results @@ -688,15 +1480,9 @@ class MakoTemplateLinter(object): """ if self._is_django_template(mako_template): return - has_page_default = False - if self._has_multiple_page_tags(mako_template): - results.violations.append(RuleViolation(Rules.mako_multiple_page_tags)) - else: - has_page_default = self._has_page_default(mako_template) - if not has_page_default: - results.violations.append(RuleViolation(Rules.mako_missing_default)) + has_page_default = self._has_page_default(mako_template, results) self._check_mako_expressions(mako_template, has_page_default, results) - results.prepare_results(mako_template) + results.prepare_results(mako_template, line_comment_delim='##') def _is_django_template(self, mako_template): """ @@ -713,33 +1499,58 @@ class MakoTemplateLinter(object): return True return False - def _has_multiple_page_tags(self, mako_template): + def _get_page_tag_count(self, mako_template): """ - Checks if the Mako template contains more than one page expression. + Determines the number of page expressions in the Mako template. Ignores + page expressions that are commented out. Arguments: mako_template: The contents of the Mako template. + Returns: + The number of page expressions """ count = len(re.findall('<%page ', mako_template, re.IGNORECASE)) - return count > 1 + count_commented = len(re.findall(r'##\s+<%page ', mako_template, re.IGNORECASE)) + return max(0, count - count_commented) - def _has_page_default(self, mako_template): + def _has_page_default(self, mako_template, results): """ Checks if the Mako template contains the page expression marking it as safe by default. Arguments: mako_template: The contents of the Mako template. + results: A list of results into which violations will be added. + + Side effect: + Adds violations regarding page default if necessary + + Returns: + True if the template has the page default, and False otherwise. """ + page_tag_count = self._get_page_tag_count(mako_template) + # check if there are too many page expressions + if 2 <= page_tag_count: + results.violations.append(RuleViolation(Rules.mako_multiple_page_tags)) + return False + # make sure there is exactly 1 page expression, excluding commented out + # page expressions, before proceeding + elif page_tag_count != 1: + results.violations.append(RuleViolation(Rules.mako_missing_default)) + return False + # check that safe by default (h filter) is turned on page_h_filter_regex = re.compile('<%page[^>]*expression_filter=(?:"h"|\'h\')[^>]*/>') page_match = page_h_filter_regex.search(mako_template) + if not page_match: + results.violations.append(RuleViolation(Rules.mako_missing_default)) return page_match def _check_mako_expressions(self, mako_template, has_page_default, results): """ Searches for Mako expressions and then checks if they contain + violations, including checking JavaScript contexts for JavaScript violations. Arguments: @@ -751,17 +1562,72 @@ class MakoTemplateLinter(object): """ expressions = self._find_mako_expressions(mako_template) contexts = self._get_contexts(mako_template) + self._check_javascript_contexts(mako_template, contexts, results) for expression in expressions: - if expression['expression'] is None: + if expression.end_index is None: results.violations.append(ExpressionRuleViolation( Rules.mako_unparseable_expression, expression )) continue - context = self._get_context(contexts, expression['start_index']) + context = self._get_context(contexts, expression.start_index) self._check_filters(mako_template, expression, context, has_page_default, results) self._check_deprecated_display_name(expression, results) - self._check_html_and_text(expression, results) + self._check_html_and_text(expression, has_page_default, results) + + def _check_javascript_contexts(self, mako_template, contexts, results): + """ + Lint the JavaScript contexts for JavaScript violations inside a Mako + template. + + Arguments: + mako_template: The contents of the Mako template. + contexts: A list of context dicts with 'type' and 'index'. + results: A list of results into which violations will be added. + + Side effect: + Adds JavaScript violations to results. + """ + javascript_start_index = None + for context in contexts: + if context['type'] == 'javascript': + if javascript_start_index < 0: + javascript_start_index = context['index'] + else: + if javascript_start_index is not None: + javascript_end_index = context['index'] + javascript_code = mako_template[javascript_start_index:javascript_end_index] + self._check_javascript_context(javascript_code, javascript_start_index, results) + javascript_start_index = None + if javascript_start_index is not None: + javascript_code = mako_template[javascript_start_index:] + self._check_javascript_context(javascript_code, javascript_start_index, results) + + def _check_javascript_context(self, javascript_code, start_offset, results): + """ + Lint a single JavaScript context for JavaScript violations inside a Mako + template. + + Arguments: + javascript_code: The template contents of the JavaScript context. + start_offset: The offset of the JavaScript context inside the + original Mako template. + results: A list of results into which violations will be added. + + Side effect: + Adds JavaScript violations to results. + + """ + javascript_results = FileResults("") + self.javaScriptLinter.check_javascript_file_is_safe(javascript_code, javascript_results) + # translate the violations into the location within the original + # Mako template + for violation in javascript_results.violations: + expression = violation.expression + expression.start_index += start_offset + if expression.end_index is not None: + expression.end_index += start_offset + results.violations.append(ExpressionRuleViolation(violation.rule, expression)) def _check_deprecated_display_name(self, expression, results): """ @@ -769,35 +1635,35 @@ class MakoTemplateLinter(object): used. Adds violation to results if there is a problem. Arguments: - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: An Expression results: A list of results into which violations will be added. """ - if '.display_name_with_default_escaped' in expression['expression']: + if '.display_name_with_default_escaped' in expression.expression: results.violations.append(ExpressionRuleViolation( Rules.mako_deprecated_display_name, expression )) - def _check_html_and_text(self, expression, results): + def _check_html_and_text(self, expression, has_page_default, results): """ Checks rules related to proper use of HTML() and Text(). Arguments: - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: A Mako Expression. + has_page_default: True if the page is marked as default, False + otherwise. results: A list of results into which violations will be added. """ - # strip '${' and '}' and whitespace from ends - expression_inner = expression['expression'][2:-1].strip() - # find the template relative inner expression start index - # - find used to take into account above strip() - template_inner_start_index = expression['start_index'] + expression['expression'].find(expression_inner) + expression_inner = expression.expression_inner + # use find to get the template relative inner expression start index + # due to possible skipped white space + template_inner_start_index = expression.start_index + template_inner_start_index += expression.expression.find(expression_inner) if 'HTML(' in expression_inner: if expression_inner.startswith('HTML('): - close_paren_index = self._find_closing_char_index( - None, "(", ")", expression_inner, start_index=len('HTML('), num_open_chars=0, strings=[] + close_paren_index = _find_closing_char_index( + None, "(", ")", expression_inner, start_index=len('HTML(') )['close_char_index'] # check that the close paren is at the end of the stripped expression. if close_paren_index != len(expression_inner) - 1: @@ -815,13 +1681,11 @@ class MakoTemplateLinter(object): )) # strings to be checked for HTML - unwrapped_html_strings = expression['strings'] + unwrapped_html_strings = expression.strings for match in re.finditer(r"(HTML\(|Text\()", expression_inner): - result = self._find_closing_char_index( - None, "(", ")", expression_inner, start_index=match.end(), num_open_chars=0, strings=[] - ) - close_paren_index = result['close_char_index'] - if 0 <= close_paren_index: + result = _find_closing_char_index(None, "(", ")", expression_inner, start_index=match.end()) + if result is not None: + close_paren_index = result['close_char_index'] # the argument sent to HTML() or Text() argument = expression_inner[match.end():close_paren_index] if ".format(" in argument: @@ -836,13 +1700,21 @@ class MakoTemplateLinter(object): if html_inner_start_index <= string.start_index and string.end_index <= html_inner_end_index: unwrapped_html_strings.remove(string) - # check strings not wrapped in HTML() + # check strings not wrapped in HTML() for '<' for string in unwrapped_html_strings: if '<' in string.string_inner: results.violations.append(ExpressionRuleViolation( Rules.mako_wrap_html, expression )) break + # check strings not wrapped in HTML() for HTML entities + if has_page_default: + for string in unwrapped_html_strings: + if re.search(r"&[#]?[a-zA-Z0-9]+;", string.string_inner): + results.violations.append(ExpressionRuleViolation( + Rules.mako_html_entities, expression + )) + break def _check_filters(self, mako_template, expression, context, has_page_default, results): """ @@ -851,8 +1723,7 @@ class MakoTemplateLinter(object): Arguments: mako_template: The contents of the Mako template. - expression: A dict containing the start_index, end_index, and - expression (text) of the expression. + expression: A Mako Expression. context: The context of the page in which the expression was found (e.g. javascript, html). has_page_default: True if the page is marked as default, False @@ -860,9 +1731,9 @@ class MakoTemplateLinter(object): results: A list of results into which violations will be added. """ - # finds "| n, h}" when given "${x | n, h}" - filters_regex = re.compile('\|[a-zA-Z_,\s]*\}') - filters_match = filters_regex.search(expression['expression']) + # Example: finds "| n, h}" when given "${x | n, h}" + filters_regex = re.compile(r'\|([.,\w\s]*)\}') + filters_match = filters_regex.search(expression.expression) if filters_match is None: if context == 'javascript': results.violations.append(ExpressionRuleViolation( @@ -870,12 +1741,12 @@ class MakoTemplateLinter(object): )) return - filters = filters_match.group()[1:-1].replace(" ", "").split(",") - if (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'unicode'): - # {x | n, unicode} is valid in any context + filters = filters_match.group(1).replace(" ", "").split(",") + if filters == ['n', 'decode.utf8']: + # {x | n, decode.utf8} is valid in any context pass elif context == 'html': - if (len(filters) == 1) and (filters[0] == 'h'): + if filters == ['h']: if has_page_default: # suppress this violation if the page default hasn't been set, # otherwise the template might get less safe @@ -886,27 +1757,103 @@ class MakoTemplateLinter(object): results.violations.append(ExpressionRuleViolation( Rules.mako_invalid_html_filter, expression )) - - else: - if (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'dump_js_escaped_json'): + elif context == 'javascript': + self._check_js_expression_not_with_html(mako_template, expression, results) + if filters == ['n', 'dump_js_escaped_json']: # {x | n, dump_js_escaped_json} is valid pass - elif (len(filters) == 2) and (filters[0] == 'n') and (filters[1] == 'js_escaped_string'): + elif filters == ['n', 'js_escaped_string']: # {x | n, js_escaped_string} is valid, if surrounded by quotes - pass + self._check_js_string_expression_in_quotes(mako_template, expression, results) else: results.violations.append(ExpressionRuleViolation( Rules.mako_invalid_js_filter, expression )) + def _check_js_string_expression_in_quotes(self, mako_template, expression, results): + """ + Checks that a Mako expression using js_escaped_string is surrounded by + quotes. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + results: A list of results into which violations will be added. + """ + parse_string = self._find_string_wrapping_expression(mako_template, expression) + if parse_string is None: + results.violations.append(ExpressionRuleViolation( + Rules.mako_js_missing_quotes, expression + )) + + def _check_js_expression_not_with_html(self, mako_template, expression, results): + """ + Checks that a Mako expression in a JavaScript context does not appear in + a string that also contains HTML. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + results: A list of results into which violations will be added. + """ + parse_string = self._find_string_wrapping_expression(mako_template, expression) + if parse_string is not None and re.search('[<>]', parse_string.string) is not None: + results.violations.append(ExpressionRuleViolation( + Rules.mako_js_html_string, expression + )) + + def _find_string_wrapping_expression(self, mako_template, expression): + """ + Finds the string wrapping the Mako expression if there is one. + + Arguments: + mako_template: The contents of the Mako template. + expression: A Mako Expression. + + Returns: + ParseString representing a scrubbed version of the wrapped string, + where the Mako expression was replaced with "${...}", if a wrapped + string was found. Otherwise, returns None if none found. + """ + lines = StringLines(mako_template) + start_index = lines.index_to_line_start_index(expression.start_index) + if expression.end_index is not None: + end_index = lines.index_to_line_end_index(expression.end_index) + else: + return None + # scrub out the actual expression so any code inside the expression + # doesn't interfere with rules applied to the surrounding code (i.e. + # checking JavaScript). + scrubbed_lines = "".join(( + mako_template[start_index:expression.start_index], + "${...}", + mako_template[expression.end_index:end_index] + )) + adjusted_start_index = expression.start_index - start_index + start_index = 0 + while True: + parse_string = ParseString(scrubbed_lines, start_index, len(scrubbed_lines)) + # check for validly parsed string + if 0 <= parse_string.start_index < parse_string.end_index: + # check if expression is contained in the given string + if parse_string.start_index < adjusted_start_index < parse_string.end_index: + return parse_string + else: + # move to check next string + start_index = parse_string.end_index + else: + break + return None + def _get_contexts(self, mako_template): """ Returns a data structure that represents the indices at which the template changes from HTML context to JavaScript and back. Return: - A list of dicts where each dict contains the 'index' of the context - and the context 'type' (e.g. 'html' or 'javascript'). + A list of dicts where each dict contains: + - index: the index of the context. + - type: the context type (e.g. 'html' or 'javascript'). """ contexts_re = re.compile(r""" | # script tag start @@ -916,6 +1863,7 @@ class MakoTemplateLinter(object): media_type_re = re.compile(r"""type=['"].*?['"]""", re.IGNORECASE) contexts = [{'index': 0, 'type': 'html'}] + javascript_types = ['text/javascript', 'text/ecmascript', 'application/ecmascript', 'application/javascript'] for context in contexts_re.finditer(mako_template): match_string = context.group().lower() if match_string.startswith(" - <%= _.escape(message) %> - - Arguments: - expression: The expression being checked. - - Returns: - True if the expression has been safely escaped, and False otherwise. - - """ - if expression['expression_inner'].startswith('HtmlUtils.'): - return True - if expression['expression_inner'].startswith('_.escape('): - return True - return False - - def _find_unescaped_expressions(self, underscore_template): - """ - Returns a list of unsafe expressions. - - At this time all expressions that are unescaped are considered unsafe. - - Arguments: - underscore_template: The contents of the Underscore.js template. - - Returns: - A list of dicts for each expression, where the dict contains the - following: - - start_index: The index of the start of the expression. - end_index: The index of the end of the expression. - expression: The text of the expression. - """ - unescaped_expression_regex = re.compile("<%=(.*?)%>", re.DOTALL) - - expressions = [] - for match in unescaped_expression_regex.finditer(underscore_template): - expression = { - 'start_index': match.start(), - 'end_index': match.end(), - 'expression': match.group(), - 'expression_inner': match.group(1).strip() - } + # restart search after the current expression + start_index = expression.end_index expressions.append(expression) - return expressions @@ -1251,12 +1966,17 @@ def _process_file(full_path, template_linters, options, out): options: A list of the options. out: output file + Returns: + The number of violations. + """ + num_violations = 0 directory = os.path.dirname(full_path) - file = os.path.basename(full_path) + file_name = os.path.basename(full_path) for template_linter in template_linters: - results = template_linter.process_file(directory, file) - results.print_results(options, out) + results = template_linter.process_file(directory, file_name) + num_violations += results.print_results(options, out) + return num_violations def _process_current_walk(current_walk, template_linters, options, out): @@ -1270,12 +1990,17 @@ def _process_current_walk(current_walk, template_linters, options, out): options: A list of the options. out: output file + Returns: + The number of violations. + """ + num_violations = 0 walk_directory = os.path.normpath(current_walk[0]) walk_files = current_walk[2] for walk_file in walk_files: full_path = os.path.join(walk_directory, walk_file) - _process_file(full_path, template_linters, options, out) + num_violations += _process_file(full_path, template_linters, options, out) + return num_violations def _process_os_walk(starting_dir, template_linters, options, out): @@ -1288,29 +2013,14 @@ def _process_os_walk(starting_dir, template_linters, options, out): options: A list of the options. out: output file - """ - for current_walk in os.walk(starting_dir): - _process_current_walk(current_walk, template_linters, options, out) - - -def _parse_arg(arg, option): - """ - Parses an argument searching for --[option]=[OPTION_VALUE] - - Arguments: - arg: The system argument - option: The specific option to be searched for (e.g. "file") - Returns: - The option value for a match, or None if arg is not for this option + The number of violations. + """ - if arg.startswith('--{}='.format(option)): - option_value = arg.split('=')[1] - if option_value.startswith("'") or option_value.startswith('"'): - option_value = option_value[1:-1] - return option_value - else: - return None + num_violations = 0 + for current_walk in os.walk(starting_dir): + num_violations += _process_current_walk(current_walk, template_linters, options, out) + return num_violations def main(): @@ -1338,15 +2048,20 @@ def main(): 'is_quiet': args.quiet, } - template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()] + template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter(), JavaScriptLinter()] if args.file is not None: if os.path.isfile(args.file[0]) is False: raise ValueError("File [{}] is not a valid file.".format(args.file[0])) - _process_file(args.file[0], template_linters, options, out=sys.stdout) + num_violations = _process_file(args.file[0], template_linters, options, out=sys.stdout) else: if os.path.exists(args.directory[0]) is False or os.path.isfile(args.directory[0]) is True: raise ValueError("Directory [{}] is not a valid directory.".format(args.directory[0])) - _process_os_walk(args.directory[0], template_linters, options, out=sys.stdout) + num_violations = _process_os_walk(args.directory[0], template_linters, options, out=sys.stdout) + + if options['is_quiet'] is False: + # matches output of jshint for simplicity + print("") + print("{} violations found".format(num_violations)) if __name__ == "__main__": diff --git a/scripts/tests/templates/test.coffee b/scripts/tests/templates/test.coffee new file mode 100644 index 0000000000..53c25c9340 --- /dev/null +++ b/scripts/tests/templates/test.coffee @@ -0,0 +1,2 @@ +var x = "" + message + "" +var template = "<%= invalid %>" diff --git a/scripts/tests/templates/test.js b/scripts/tests/templates/test.js new file mode 100644 index 0000000000..71c425e0af --- /dev/null +++ b/scripts/tests/templates/test.js @@ -0,0 +1,6 @@ +var message = "Rock & Roll"; +var x = "" + message + ""; +var template = "<%= invalid %>"; +// quiet the linter +alert(x); +alert(template); diff --git a/scripts/tests/templates/test.underscore b/scripts/tests/templates/test.underscore new file mode 100644 index 0000000000..57fedb860a --- /dev/null +++ b/scripts/tests/templates/test.underscore @@ -0,0 +1 @@ +<%= invalid %> diff --git a/scripts/tests/test_safe_template_linter.py b/scripts/tests/test_safe_template_linter.py index da68230101..c85210838f 100644 --- a/scripts/tests/test_safe_template_linter.py +++ b/scripts/tests/test_safe_template_linter.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- """ Tests for safe_template_linter.py """ @@ -9,16 +10,68 @@ import textwrap from unittest import TestCase from ..safe_template_linter import ( - _process_os_walk, FileResults, MakoTemplateLinter, ParseString, UnderscoreTemplateLinter, Rules + _process_os_walk, FileResults, JavaScriptLinter, MakoTemplateLinter, ParseString, StringLines, + UnderscoreTemplateLinter, Rules ) +@ddt +class TestStringLines(TestCase): + """ + Test StringLines class. + """ + @data( + {'string': 'test', 'index': 0, 'line_start_index': 0, 'line_end_index': 4}, + {'string': 'test', 'index': 2, 'line_start_index': 0, 'line_end_index': 4}, + {'string': 'test', 'index': 3, 'line_start_index': 0, 'line_end_index': 4}, + {'string': '\ntest', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, + {'string': '\ntest', 'index': 2, 'line_start_index': 1, 'line_end_index': 5}, + {'string': '\ntest\n', 'index': 0, 'line_start_index': 0, 'line_end_index': 1}, + {'string': '\ntest\n', 'index': 2, 'line_start_index': 1, 'line_end_index': 6}, + {'string': '\ntest\n', 'index': 6, 'line_start_index': 6, 'line_end_index': 6}, + ) + def test_string_lines_start_end_index(self, data): + """ + Test StringLines index_to_line_start_index and index_to_line_end_index. + """ + lines = StringLines(data['string']) + self.assertEqual(lines.index_to_line_start_index(data['index']), data['line_start_index']) + self.assertEqual(lines.index_to_line_end_index(data['index']), data['line_end_index']) + + @data( + {'string': 'test', 'line_number': 1, 'line': 'test'}, + {'string': '\ntest', 'line_number': 1, 'line': ''}, + {'string': '\ntest', 'line_number': 2, 'line': 'test'}, + {'string': '\ntest\n', 'line_number': 1, 'line': ''}, + {'string': '\ntest\n', 'line_number': 2, 'line': 'test'}, + {'string': '\ntest\n', 'line_number': 3, 'line': ''}, + ) + def test_string_lines_start_end_index(self, data): + """ + Test line_number_to_line. + """ + lines = StringLines(data['string']) + self.assertEqual(lines.line_number_to_line(data['line_number']), data['line']) + + +class TestLinter(TestCase): + """ + Test Linter base class + """ + def _validate_data_rule(self, data, results): + if data['rule'] is None: + self.assertEqual(len(results.violations), 0) + else: + self.assertEqual(len(results.violations), 1) + self.assertEqual(results.violations[0].rule, data['rule']) + + class TestSafeTemplateLinter(TestCase): """ Test some top-level linter functions """ - def test_process_os_walk_with_includes(self): + def test_process_os_walk(self): """ Tests the top-level processing of template files, including Mako includes. @@ -29,17 +82,25 @@ class TestSafeTemplateLinter(TestCase): 'is_quiet': False, } - template_linters = [MakoTemplateLinter(), UnderscoreTemplateLinter()] + template_linters = [MakoTemplateLinter(), JavaScriptLinter(), UnderscoreTemplateLinter()] - with mock.patch.object(MakoTemplateLinter, '_is_valid_directory', return_value=True) as mock_is_valid_directory: - _process_os_walk('scripts/tests/templates', template_linters, options, out) + with mock.patch.object(MakoTemplateLinter, '_is_valid_directory', return_value=True): + with mock.patch.object(JavaScriptLinter, '_is_valid_directory', return_value=True): + with mock.patch.object(UnderscoreTemplateLinter, '_is_valid_directory', return_value=True): + num_violations = _process_os_walk('scripts/tests/templates', template_linters, options, out) output = out.getvalue() - self.assertIsNotNone(re.search('test\.html.*mako-missing-default', out.getvalue())) + self.assertEqual(num_violations, 6) + self.assertIsNotNone(re.search('test\.html.*mako-missing-default', output)) + self.assertIsNotNone(re.search('test\.coffee.*javascript-concat-html', output)) + self.assertIsNotNone(re.search('test\.coffee.*underscore-not-escaped', output)) + self.assertIsNotNone(re.search('test\.js.*javascript-concat-html', output)) + self.assertIsNotNone(re.search('test\.js.*underscore-not-escaped', output)) + self.assertIsNotNone(re.search('test\.underscore.*underscore-not-escaped', output)) @ddt -class TestMakoTemplateLinter(TestCase): +class TestMakoTemplateLinter(TestLinter): """ Test MakoTemplateLinter """ @@ -63,36 +124,40 @@ class TestMakoTemplateLinter(TestCase): @data( { 'template': '\n <%page expression_filter="h"/>', - 'violations': 0, 'rule': None }, { 'template': '\n <%page args="section_data" expression_filter="h" /> ', - 'violations': 0, 'rule': None }, + { + 'template': '\n ## <%page expression_filter="h"/>', + 'rule': Rules.mako_missing_default + }, { 'template': '\n <%page expression_filter="h" /> ' '\n <%page args="section_data"/>', - 'violations': 1, 'rule': Rules.mako_multiple_page_tags }, + { + 'template': + '\n <%page expression_filter="h" /> ' + '\n ## <%page args="section_data"/>', + 'rule': None + }, { 'template': '\n <%page args="section_data" /> ', - 'violations': 1, 'rule': Rules.mako_missing_default }, { 'template': '\n <%page args="section_data"/> ', - 'violations': 1, 'rule': Rules.mako_missing_default }, { 'template': '\n', - 'violations': 1, 'rule': Rules.mako_missing_default }, ) @@ -105,16 +170,18 @@ class TestMakoTemplateLinter(TestCase): linter._check_mako_file_is_safe(data['template'], results) - self.assertEqual(len(results.violations), data['violations']) - if data['violations'] > 0: + num_violations = 0 if data['rule'] is None else 1 + self.assertEqual(len(results.violations), num_violations) + if num_violations > 0: self.assertEqual(results.violations[0].rule, data['rule']) @data( {'expression': '${x}', 'rule': None}, {'expression': '${{unbalanced}', 'rule': Rules.mako_unparseable_expression}, {'expression': '${x | n}', 'rule': Rules.mako_invalid_html_filter}, - {'expression': '${x | n, unicode}', 'rule': None}, + {'expression': '${x | n, decode.utf8}', 'rule': None}, {'expression': '${x | h}', 'rule': Rules.mako_unwanted_html_filter}, + {'expression': ' ## ${commented_out | h}', 'rule': None}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': Rules.mako_invalid_html_filter}, ) def test_check_mako_expressions_in_html(self, data): @@ -255,6 +322,14 @@ class TestMakoTemplateLinter(TestCase): 'expression': "${ HTML('') + 'some other text' }", 'rule': Rules.mako_html_alone }, + { + 'expression': "${'Rock & Roll'}", + 'rule': Rules.mako_html_entities + }, + { + 'expression': "${'Rock & Roll'}", + 'rule': Rules.mako_html_entities + }, ) def test_check_mako_with_text_and_html(self, data): """ @@ -350,7 +425,7 @@ class TestMakoTemplateLinter(TestCase): {'expression': '${x | n}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | h}', 'rule': Rules.mako_invalid_js_filter}, {'expression': '${x | n, dump_js_escaped_json}', 'rule': None}, - {'expression': '${x | n, unicode}', 'rule': None}, + {'expression': '${x | n, decode.utf8}', 'rule': None}, ) def test_check_mako_expressions_in_javascript(self, data): """ @@ -373,7 +448,7 @@ class TestMakoTemplateLinter(TestCase): @data( {'expression': '${x}', 'rule': Rules.mako_invalid_js_filter}, - {'expression': '${x | n, js_escaped_string}', 'rule': None}, + {'expression': '"${x | n, js_escaped_string}"', 'rule': None}, ) def test_check_mako_expressions_in_require_js(self, data): """ @@ -450,6 +525,63 @@ class TestMakoTemplateLinter(TestCase): self.assertEqual(results.violations[3].rule, Rules.mako_invalid_js_filter) self.assertEqual(results.violations[4].rule, Rules.mako_unwanted_html_filter) + def test_check_mako_expressions_javascript_strings(self): + """ + Test _check_mako_file_is_safe javascript string specific rules. + - mako_js_missing_quotes + - mako_js_html_string + """ + linter = MakoTemplateLinter() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """) + + linter._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.violations), 3) + self.assertEqual(results.violations[0].rule, Rules.mako_js_missing_quotes) + self.assertEqual(results.violations[1].rule, Rules.mako_js_html_string) + self.assertEqual(results.violations[2].rule, Rules.mako_js_html_string) + + def test_check_javascript_in_mako_javascript_context(self): + """ + Test _check_mako_file_is_safe with JavaScript error in JavaScript + context. + """ + linter = MakoTemplateLinter() + results = FileResults('') + + mako_template = textwrap.dedent(""" + <%page expression_filter="h"/> + + """) + + linter._check_mako_file_is_safe(mako_template, results) + + self.assertEqual(len(results.violations), 1) + self.assertEqual(results.violations[0].rule, Rules.javascript_concat_html) + self.assertEqual(results.violations[0].start_line, 4) + @data( {'template': "\n${x | n}", 'parseable': True}, { @@ -508,10 +640,10 @@ class TestMakoTemplateLinter(TestCase): expressions = linter._find_mako_expressions(data['template']) self.assertEqual(len(expressions), 1) - start_index = expressions[0]['start_index'] - end_index = expressions[0]['end_index'] + start_index = expressions[0].start_index + end_index = expressions[0].end_index self.assertEqual(data['template'][start_index:end_index], data['template'].strip()) - self.assertEqual(expressions[0]['expression'], data['template'].strip()) + self.assertEqual(expressions[0].expression, data['template'].strip()) @data( {'template': " ${{unparseable} ${}", 'start_index': 1}, @@ -525,8 +657,8 @@ class TestMakoTemplateLinter(TestCase): expressions = linter._find_mako_expressions(data['template']) self.assertTrue(2 <= len(expressions)) - self.assertEqual(expressions[0]['start_index'], data['start_index']) - self.assertIsNone(expressions[0]['expression']) + self.assertEqual(expressions[0].start_index, data['start_index']) + self.assertIsNone(expressions[0].expression) @data( { @@ -549,6 +681,10 @@ class TestMakoTemplateLinter(TestCase): 'template': r""" ${" \" \\"} """, 'result': {'start_index': 3, 'end_index': 11, 'quote_length': 1} }, + { + 'template': "${'broken string}", + 'result': {'start_index': 2, 'end_index': None, 'quote_length': None} + }, ) def test_parse_string(self, data): """ @@ -564,28 +700,22 @@ class TestMakoTemplateLinter(TestCase): } self.assertDictEqual(string_dict, data['result']) - self.assertEqual(data['template'][parse_string.start_index:parse_string.end_index], parse_string.string) - start_index = parse_string.start_index + parse_string.quote_length - end_index = parse_string.end_index - parse_string.quote_length - self.assertEqual(data['template'][start_index:end_index], parse_string.string_inner) - - def _validate_data_rule(self, data, results): - if data['rule'] is None: - self.assertEqual(len(results.violations), 0) - else: - self.assertEqual(len(results.violations), 1) - self.assertEqual(results.violations[0].rule, data['rule']) + if parse_string.end_index is not None: + self.assertEqual(data['template'][parse_string.start_index:parse_string.end_index], parse_string.string) + start_inner_index = parse_string.start_index + parse_string.quote_length + end_inner_index = parse_string.end_index - parse_string.quote_length + self.assertEqual(data['template'][start_inner_index:end_inner_index], parse_string.string_inner) @ddt -class TestUnderscoreTemplateLinter(TestCase): +class TestUnderscoreTemplateLinter(TestLinter): """ Test UnderscoreTemplateLinter """ def test_check_underscore_file_is_safe(self): """ - Test _check_underscore_file_is_safe with safe template + Test check_underscore_file_is_safe with safe template """ linter = UnderscoreTemplateLinter() results = FileResults('') @@ -598,13 +728,13 @@ class TestUnderscoreTemplateLinter(TestCase): %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 0) def test_check_underscore_file_is_not_safe(self): """ - Test _check_underscore_file_is_safe with unsafe template + Test check_underscore_file_is_safe with unsafe template """ linter = UnderscoreTemplateLinter() results = FileResults('') @@ -617,7 +747,7 @@ class TestUnderscoreTemplateLinter(TestCase): %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].rule, Rules.underscore_not_escaped) @@ -675,12 +805,12 @@ class TestUnderscoreTemplateLinter(TestCase): ) def test_check_underscore_file_disable_rule(self, data): """ - Test _check_underscore_file_is_safe with various disabled pragmas + Test check_underscore_file_is_safe with various disabled pragmas """ linter = UnderscoreTemplateLinter() results = FileResults('') - linter._check_underscore_file_is_safe(data['template'], results) + linter.check_underscore_file_is_safe(data['template'], results) violation_count = len(data['is_disabled']) self.assertEqual(len(results.violations), violation_count) @@ -689,7 +819,7 @@ class TestUnderscoreTemplateLinter(TestCase): def test_check_underscore_file_disables_one_violation(self): """ - Test _check_underscore_file_is_safe with disabled before a line only + Test check_underscore_file_is_safe with disabled before a line only disables for the violation following """ linter = UnderscoreTemplateLinter() @@ -701,7 +831,7 @@ class TestUnderscoreTemplateLinter(TestCase): <%= message %> """) - linter._check_underscore_file_is_safe(template, results) + linter.check_underscore_file_is_safe(template, results) self.assertEqual(len(results.violations), 2) self.assertEqual(results.violations[0].is_disabled, True) @@ -713,12 +843,212 @@ class TestUnderscoreTemplateLinter(TestCase): ) def test_check_underscore_no_escape_allowed(self, data): """ - Test _check_underscore_file_is_safe with expressions that are allowed + Test check_underscore_file_is_safe with expressions that are allowed without escaping because the internal calls properly escape. """ linter = UnderscoreTemplateLinter() results = FileResults('') - linter._check_underscore_file_is_safe(data['template'], results) + linter.check_underscore_file_is_safe(data['template'], results) self.assertEqual(len(results.violations), 0) + + +@ddt +class TestJavaScriptLinter(TestLinter): + """ + Test JavaScriptLinter + """ + @data( + {'template': 'var m = "Plain text " + message + "plain text"', 'rule': None}, + {'template': 'var m = "檌檒濦 " + message + "plain text"', 'rule': None}, + {'template': 'var m = "

" + message + "

"', 'rule': Rules.javascript_concat_html}, + {'template': ' // var m = "

" + commentedOutMessage + "

"', 'rule': None}, + {'template': 'var m = "

" + message + "

"', 'rule': Rules.javascript_concat_html}, + {'template': 'var m = "

" + message + " broken string', 'rule': Rules.javascript_concat_html}, + ) + def test_concat_with_html(self, data): + """ + Test check_javascript_file_is_safe with concatenating strings and HTML + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + self._validate_data_rule(data, results) + + @data( + {'template': 'test.append( test.render().el )', 'rule': None}, + {'template': 'test.append(test.render().el)', 'rule': None}, + {'template': 'test.append(test.render().$el)', 'rule': None}, + {'template': 'test.append(testEl)', 'rule': None}, + {'template': 'test.append($test)', 'rule': None}, + # plain text is ok because any & will be escaped, and it stops false + # negatives on some other objects with an append() method + {'template': 'test.append("plain text")', 'rule': None}, + {'template': 'test.append("

")', 'rule': Rules.javascript_jquery_append}, + {'template': 'graph.svg.append("g")', 'rule': None}, + {'template': 'test.append( $( "
" ) )', 'rule': None}, + {'template': 'test.append($("
"))', 'rule': None}, + {'template': 'test.append($("
"))', 'rule': None}, + {'template': 'test.append(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.append($el, someHtml)', 'rule': None}, + {'template': 'test.append("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_append}, + {'template': 'test.append("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_append}, + {'template': 'test.append(message)', 'rule': Rules.javascript_jquery_append}, + ) + def test_jquery_append(self, data): + """ + Test check_javascript_file_is_safe with JQuery append() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.prepend( test.render().el )', 'rule': None}, + {'template': 'test.prepend(test.render().el)', 'rule': None}, + {'template': 'test.prepend(test.render().$el)', 'rule': None}, + {'template': 'test.prepend(testEl)', 'rule': None}, + {'template': 'test.prepend($test)', 'rule': None}, + {'template': 'test.prepend("text")', 'rule': None}, + {'template': 'test.prepend( $( "
" ) )', 'rule': None}, + {'template': 'test.prepend($("
"))', 'rule': None}, + {'template': 'test.prepend($("
"))', 'rule': None}, + {'template': 'test.prepend(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.prepend($el, someHtml)', 'rule': None}, + {'template': 'test.prepend("broken string)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend("fail on concat" + test.render().el)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend("fail on concat" + testEl)', 'rule': Rules.javascript_jquery_prepend}, + {'template': 'test.prepend(message)', 'rule': Rules.javascript_jquery_prepend}, + ) + def test_jquery_prepend(self, data): + """ + Test check_javascript_file_is_safe with JQuery prepend() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.unwrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrap(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrapAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.wrapInner(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.after(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.before(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceAll(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceWith(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'test.replaceWith(edx.HtmlUtils.HTML(htmlString).toString())', 'rule': None}, + {'template': 'test.unwrap(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrap(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrapAll(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.wrapInner(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.after(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.before(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.replaceAll(anything)', 'rule': Rules.javascript_jquery_insertion}, + {'template': 'test.replaceWith(anything)', 'rule': Rules.javascript_jquery_insertion}, + ) + def test_jquery_insertion(self, data): + """ + Test check_javascript_file_is_safe with JQuery insertion functions + other than append(), prepend() and html() that take content as an + argument (e.g. before(), after()). + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': ' element.parentNode.appendTo(target);', 'rule': None}, + {'template': ' test.render().el.appendTo(target);', 'rule': None}, + {'template': ' test.render().$el.appendTo(target);', 'rule': None}, + {'template': ' test.$element.appendTo(target);', 'rule': None}, + {'template': ' test.testEl.appendTo(target);', 'rule': None}, + {'template': '$element.appendTo(target);', 'rule': None}, + {'template': 'el.appendTo(target);', 'rule': None}, + {'template': 'testEl.appendTo(target);', 'rule': None}, + {'template': 'testEl.prependTo(target);', 'rule': None}, + {'template': 'testEl.insertAfter(target);', 'rule': None}, + {'template': 'testEl.insertBefore(target);', 'rule': None}, + {'template': 'anycall().appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.appendTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.prependTo(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.insertAfter(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + {'template': 'anything.insertBefore(target)', 'rule': Rules.javascript_jquery_insert_into_target}, + ) + def test_jquery_insert_to_target(self, data): + """ + Test check_javascript_file_is_safe with JQuery insert to target + functions that take a target as an argument, like appendTo() and + prependTo(). + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': 'test.html()', 'rule': None}, + {'template': 'test.html( )', 'rule': None}, + {'template': "test.html( '' )", 'rule': None}, + {'template': "test.html('')", 'rule': None}, + {'template': 'test.html("")', 'rule': None}, + {'template': 'test.html(HtmlUtils.ensureHtml(htmlSnippet).toString())', 'rule': None}, + {'template': 'HtmlUtils.setHtml($el, someHtml)', 'rule': None}, + {'template': 'test.html("any string")', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html("broken string)', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html("檌檒濦")', 'rule': Rules.javascript_jquery_html}, + {'template': 'test.html(anything)', 'rule': Rules.javascript_jquery_html}, + ) + def test_jquery_html(self, data): + """ + Test check_javascript_file_is_safe with JQuery html() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + self._validate_data_rule(data, results) + + @data( + {'template': 'StringUtils.interpolate()', 'rule': None}, + {'template': 'HtmlUtils.interpolateHtml()', 'rule': None}, + {'template': 'interpolate(anything)', 'rule': Rules.javascript_interpolate}, + ) + def test_javascript_interpolate(self, data): + """ + Test check_javascript_file_is_safe with interpolate() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) + + @data( + {'template': '_.escape(message)', 'rule': None}, + {'template': 'anything.escape(message)', 'rule': Rules.javascript_escape}, + ) + def test_javascript_interpolate(self, data): + """ + Test check_javascript_file_is_safe with interpolate() + """ + linter = JavaScriptLinter() + results = FileResults('') + + linter.check_javascript_file_is_safe(data['template'], results) + + self._validate_data_rule(data, results) diff --git a/static/monitoring/package.json b/static/monitoring/package.json index cefb720d72..6276e84ffd 100644 --- a/static/monitoring/package.json +++ b/static/monitoring/package.json @@ -24,10 +24,6 @@ "url": "1.8.4" }, "devDependencies": { - "jasmine": "1.0.0", - "jasmine-async": "0.1.0", - "jasmine-jquery": "1.5.1", - "jasmine-stealth": "0.0.12", "sinon": "1.17.0", "squire": "1.0.0" } diff --git a/static/monitoring/package.txt b/static/monitoring/package.txt index 30f6a4e98b..8bcb3180d9 100644 --- a/static/monitoring/package.txt +++ b/static/monitoring/package.txt @@ -108,11 +108,7 @@ // "vjs.youtube": "unknown" }, "devDependencies": { - // "jasmine": "unknown", - "jasmine.async": "0.1.0", "jasmine-imagediff": "1.0.3", - "jasmine-jquery": "1.5.1", - "jasmine-stealth": "0.0.12", // "jquery.simulate": "unknown", // "mock-ajax": "unknown", "sinon": "1.17.0" diff --git a/themes/edx.org/lms/templates/dashboard.html b/themes/edx.org/lms/templates/dashboard.html index 5ea969d5a5..51476bc934 100644 --- a/themes/edx.org/lms/templates/dashboard.html +++ b/themes/edx.org/lms/templates/dashboard.html @@ -105,11 +105,11 @@ from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_str % else:
-

${_("Looks like you haven't enrolled in any courses yet.")}

+

${_("You are not enrolled in any courses yet.")}

% if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): - ${_("Find courses now!")} + ${_("Explore courses")} %endif