diff --git a/cms/djangoapps/contentstore/features/transcripts.feature b/cms/djangoapps/contentstore/features/transcripts.feature index bf5022dd8a..9ca8f349ab 100644 --- a/cms/djangoapps/contentstore/features/transcripts.feature +++ b/cms/djangoapps/contentstore/features/transcripts.feature @@ -1,3 +1,4 @@ +@shard_3 Feature: Video Component Editor As a course author, I want to be able to create video components. @@ -668,7 +669,7 @@ Feature: Video Component Editor And I edit the component And I open tab "Advanced" - And I revert the transcript field"HTML5 Transcript" + And I revert the transcript field "HTML5 Transcript" And I save changes Then when I view the video it does not show the captions diff --git a/cms/djangoapps/contentstore/features/transcripts.py b/cms/djangoapps/contentstore/features/transcripts.py index c6060803ba..58f0cd6067 100644 --- a/cms/djangoapps/contentstore/features/transcripts.py +++ b/cms/djangoapps/contentstore/features/transcripts.py @@ -49,25 +49,18 @@ TRANSCRIPTS_BUTTONS = { } -def _clear_field(index): - world.css_fill(SELECTORS['url_inputs'], '', index) - # In some reason chromeDriver doesn't trigger 'input' event after filling - # field by an empty value. That's why we trigger it manually via jQuery. - world.trigger_event(SELECTORS['url_inputs'], event='input', index=index) - - @step('I clear fields$') def clear_fields(_step): - js_str = ''' + + # Clear the input fields and trigger an 'input' event + script = """ $('{selector}') - .eq({index}) .prop('disabled', false) - .removeClass('is-disabled'); - ''' - for index in range(1, 4): - js = js_str.format(selector=SELECTORS['url_inputs'], index=index - 1) - world.browser.execute_script(js) - _clear_field(index) + .removeClass('is-disabled') + .val('') + .trigger('input'); + """.format(selector=SELECTORS['url_inputs']) + world.browser.execute_script(script) world.wait(DELAY) world.wait_for_ajax_complete() @@ -76,7 +69,12 @@ def clear_fields(_step): @step('I clear field number (.+)$') def clear_field(_step, index): index = int(index) - 1 - _clear_field(index) + world.css_fill(SELECTORS['url_inputs'], '', index) + + # For some reason ChromeDriver doesn't trigger an 'input' event after filling + # the field with an empty value. That's why we trigger it manually via jQuery. + world.trigger_event(SELECTORS['url_inputs'], event='input', index=index) + world.wait(DELAY) world.wait_for_ajax_complete() diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index c97dba10b9..a293b13727 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -34,7 +34,7 @@ def i_created_a_video_component(step): world.wait_for_present('.is-initialized') world.wait(DELAY) - assert not world.css_visible(SELECTORS['spinner']) + world.wait_for_invisible(SELECTORS['spinner']) @step('I have created a Video component with subtitles$') @@ -59,8 +59,7 @@ def i_created_a_video_with_subs_with_name(_step, sub_id): world.disable_jquery_animations() world.wait_for_present('.is-initialized') - world.wait(DELAY) - assert not world.css_visible(SELECTORS['spinner']) + world.wait_for_invisible(SELECTORS['spinner']) @step('I have uploaded subtitles "([^"]*)"$') diff --git a/cms/djangoapps/contentstore/features/youtube_setup.py b/cms/djangoapps/contentstore/features/youtube_setup.py deleted file mode 100644 index 875b9f2286..0000000000 --- a/cms/djangoapps/contentstore/features/youtube_setup.py +++ /dev/null @@ -1,45 +0,0 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 -from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer -from lettuce import before, after, world -from django.conf import settings -import threading - -from logging import getLogger -logger = getLogger(__name__) - - -@before.all -def setup_mock_youtube_server(): - server_host = '127.0.0.1' - - server_port = settings.VIDEO_PORT - - address = (server_host, server_port) - - # Create the mock server instance - server = MockYoutubeServer(address) - logger.debug("Youtube server started at {} port".format(str(server_port))) - - server.time_to_response = 0.1 # seconds - - server.address = address - - # Start the server running in a separate daemon thread - # Because the thread is a daemon, it will terminate - # when the main thread terminates. - server_thread = threading.Thread(target=server.serve_forever) - server_thread.daemon = True - server_thread.start() - - # Store the server instance in lettuce's world - # so that other steps can access it - # (and we can shut it down later) - world.youtube_server = server - - -@after.all -def teardown_mock_youtube_server(total): - - # Stop the LTI server and free up the port - world.youtube_server.shutdown() diff --git a/cms/envs/acceptance.py b/cms/envs/acceptance.py index 67ecfa5689..b47596c9c5 100644 --- a/cms/envs/acceptance.py +++ b/cms/envs/acceptance.py @@ -114,22 +114,6 @@ try: except ImportError: pass -# Because an override for where to run will affect which ports to use, -# set this up after the local overrides. -if LETTUCE_SELENIUM_CLIENT == 'saucelabs': - LETTUCE_SERVER_PORT = choice(PORTS) -else: - LETTUCE_SERVER_PORT = randint(1024, 65535) - - -# Set up Video information so that the cms will send -# requests to a mock Youtube server running locally -if LETTUCE_SELENIUM_CLIENT == 'saucelabs': - VIDEO_PORT = choice(PORTS) - PORTS.remove(VIDEO_PORT) -else: - VIDEO_PORT = randint(1024, 65535) - -# for testing Youtube -YOUTUBE_API['url'] = "http://127.0.0.1:" + str(VIDEO_PORT) + '/test_transcripts_youtube/' - +# Point the URL used to test YouTube availability to our stub YouTube server +YOUTUBE_TEST_URL = "http://127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT) +YOUTUBE_API['url'] = "http://127.0.0.1:{0}/test_transcripts_youtube/".format(YOUTUBE_PORT) diff --git a/cms/envs/common.py b/cms/envs/common.py index daea3a3f0e..d4513900e6 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -359,6 +359,13 @@ CELERY_QUEUES = { DEFAULT_PRIORITY_QUEUE: {} } + +############################## Video ########################################## + +# URL to test YouTube availability +YOUTUBE_TEST_URL = 'https://gdata.youtube.com/feeds/api/videos/' + + ############################ APPS ##################################### INSTALLED_APPS = ( diff --git a/cms/envs/test.py b/cms/envs/test.py index a35824ed52..24f9e08a0a 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -150,6 +150,17 @@ CELERY_ALWAYS_EAGER = True CELERY_RESULT_BACKEND = 'cache' BROKER_TRANSPORT = 'memory' + +########################### Server Ports ################################### + +# These ports are carefully chosen so that if the browser needs to +# access them, they will be available through the SauceLabs SSH tunnel +LETTUCE_SERVER_PORT = 8003 +XQUEUE_PORT = 8040 +YOUTUBE_PORT = 8031 +LTI_PORT = 8765 + + ################### Make tests faster # http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ PASSWORD_HASHERS = ( diff --git a/common/djangoapps/terrain/__init__.py b/common/djangoapps/terrain/__init__.py index 3445a01d17..2740ff326b 100644 --- a/common/djangoapps/terrain/__init__.py +++ b/common/djangoapps/terrain/__init__.py @@ -4,3 +4,4 @@ from terrain.browser import * from terrain.steps import * from terrain.factories import * +from terrain.start_stubs import * diff --git a/common/djangoapps/terrain/start_stubs.py b/common/djangoapps/terrain/start_stubs.py new file mode 100644 index 0000000000..aec88a1cc1 --- /dev/null +++ b/common/djangoapps/terrain/start_stubs.py @@ -0,0 +1,37 @@ +""" +Initialize and teardown fake HTTP services for use in acceptance tests. +""" + +from lettuce import before, after, world +from django.conf import settings +from terrain.stubs.youtube import StubYouTubeService +from terrain.stubs.xqueue import StubXQueueService + + +USAGE = "USAGE: python -m fakes.start SERVICE_NAME PORT_NUM" + +SERVICES = { + "youtube": {"port": settings.YOUTUBE_PORT, "class": StubYouTubeService}, + "xqueue": {"port": settings.XQUEUE_PORT, "class": StubXQueueService}, +} + + +@before.all +def start_stubs(): + """ + Start each stub service running on a local port. + """ + for name, service in SERVICES.iteritems(): + fake_server = service['class'](port_num=service['port']) + setattr(world, name, fake_server) + + +@after.all +def stop_stubs(_): + """ + Shut down each stub service. + """ + for name in SERVICES.keys(): + stub_server = getattr(world, name, None) + if stub_server is not None: + stub_server.shutdown() diff --git a/common/lib/xmodule/xmodule/util/mock_youtube_server/__init__.py b/common/djangoapps/terrain/stubs/__init__.py similarity index 100% rename from common/lib/xmodule/xmodule/util/mock_youtube_server/__init__.py rename to common/djangoapps/terrain/stubs/__init__.py diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py new file mode 100644 index 0000000000..14dcf8ac2f --- /dev/null +++ b/common/djangoapps/terrain/stubs/http.py @@ -0,0 +1,181 @@ +""" +Stub implementation of an HTTP service. +""" + +from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler +import urlparse +import threading +import json +from lazy import lazy + +from logging import getLogger +LOGGER = getLogger(__name__) + + +class StubHttpRequestHandler(BaseHTTPRequestHandler, object): + """ + Handler for the stub HTTP service. + """ + + protocol = "HTTP/1.0" + + def log_message(self, format_str, *args): + """ + Redirect messages to keep the test console clean. + """ + + msg = "{0} - - [{1}] {2}\n".format( + self.client_address[0], + self.log_date_time_string(), + format_str % args + ) + + LOGGER.debug(msg) + + @lazy + def request_content(self): + """ + Retrieve the content of the request. + """ + try: + length = int(self.headers.getheader('content-length')) + + except (TypeError, ValueError): + return "" + else: + return self.rfile.read(length) + + @lazy + def post_dict(self): + """ + Retrieve the request POST parameters from the client as a dictionary. + If no POST parameters can be interpreted, return an empty dict. + """ + contents = self.request_content + + # The POST dict will contain a list of values for each key. + # None of our parameters are lists, however, so we map [val] --> val + # If the list contains multiple entries, we pick the first one + try: + post_dict = urlparse.parse_qs(contents, keep_blank_values=True) + return { + key: list_val[0] + for key, list_val in post_dict.items() + } + + except: + return dict() + + @lazy + def get_params(self): + """ + Return the GET parameters (querystring in the URL). + """ + return urlparse.parse_qs(self.path) + + def do_PUT(self): + """ + Allow callers to configure the stub server using the /set_config URL. + """ + if self.path == "/set_config" or self.path == "/set_config/": + + for key, value in self.post_dict.iteritems(): + self.log_message("Set config '{0}' to '{1}'".format(key, value)) + + try: + value = json.loads(value) + + except ValueError: + self.log_message(u"Could not parse JSON: {0}".format(value)) + self.send_response(400) + + else: + self.server.set_config(unicode(key, 'utf-8'), value) + self.send_response(200) + + else: + self.send_response(404) + + def send_response(self, status_code, content=None, headers=None): + """ + Send a response back to the client with the HTTP `status_code` (int), + `content` (str) and `headers` (dict). + """ + self.log_message( + "Sent HTTP response: {0} with content '{1}' and headers {2}".format(status_code, content, headers) + ) + + if headers is None: + headers = dict() + + BaseHTTPRequestHandler.send_response(self, status_code) + + for (key, value) in headers.items(): + self.send_header(key, value) + + if len(headers) > 0: + self.end_headers() + + if content is not None: + self.wfile.write(content) + + +class StubHttpService(HTTPServer, object): + """ + Stub HTTP service implementation. + """ + + # Subclasses override this to provide the handler class to use. + # Should be a subclass of `StubHttpRequestHandler` + HANDLER_CLASS = StubHttpRequestHandler + + def __init__(self, port_num=0): + """ + Configure the server to listen on localhost. + Default is to choose an arbitrary open port. + """ + address = ('127.0.0.1', port_num) + HTTPServer.__init__(self, address, self.HANDLER_CLASS) + + # Create a dict to store configuration values set by the client + self._config = dict() + + # Start the server in a separate thread + server_thread = threading.Thread(target=self.serve_forever) + server_thread.daemon = True + server_thread.start() + + # Log the port we're using to help identify port conflict errors + LOGGER.debug('Starting service on port {0}'.format(self.port)) + + def shutdown(self): + """ + Stop the server and free up the port + """ + # First call superclass shutdown() + HTTPServer.shutdown(self) + + # We also need to manually close the socket + self.socket.close() + + @property + def port(self): + """ + Return the port that the service is listening on. + """ + _, port = self.server_address + return port + + def config(self, key, default=None): + """ + Return the configuration value for `key`. If this + value has not been set, return `default` instead. + """ + return self._config.get(key, default) + + def set_config(self, key, value): + """ + Set the configuration `value` for `key`. + """ + self._config[key] = value + diff --git a/lms/djangoapps/courseware/mock_xqueue_server/__init__.py b/common/djangoapps/terrain/stubs/tests/__init__.py similarity index 100% rename from lms/djangoapps/courseware/mock_xqueue_server/__init__.py rename to common/djangoapps/terrain/stubs/tests/__init__.py diff --git a/common/djangoapps/terrain/stubs/tests/test_http.py b/common/djangoapps/terrain/stubs/tests/test_http.py new file mode 100644 index 0000000000..fb09bad173 --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_http.py @@ -0,0 +1,58 @@ +""" +Unit tests for stub HTTP server base class. +""" + +import unittest +import requests +import json +from terrain.stubs.http import StubHttpService + + +class StubHttpServiceTest(unittest.TestCase): + + def setUp(self): + self.server = StubHttpService() + self.addCleanup(self.server.shutdown) + + def test_configure(self): + """ + All HTTP stub servers have an end-point that allows + clients to configure how the server responds. + """ + params = { + 'test_str': 'This is only a test', + 'test_int': 12345, + 'test_float': 123.45, + 'test_unicode': u'\u2603 the snowman', + 'test_dict': { 'test_key': 'test_val' } + } + + for key, val in params.iteritems(): + post_params = {key: json.dumps(val)} + response = requests.put( + "http://127.0.0.1:{0}/set_config".format(self.server.port), + data=post_params + ) + + self.assertEqual(response.status_code, 200) + + # Check that the expected values were set in the configuration + for key, val in params.iteritems(): + self.assertEqual(self.server.config(key), val) + + def test_default_config(self): + self.assertEqual(self.server.config('not_set', default=42), 42) + + def test_bad_json(self): + response = requests.put( + "http://127.0.0.1:{0}/set_config".format(self.server.port), + data="{,}" + ) + self.assertEqual(response.status_code, 400) + + def test_unknown_path(self): + response = requests.put( + "http://127.0.0.1:{0}/invalid_url".format(self.server.port), + data="{}" + ) + self.assertEqual(response.status_code, 404) diff --git a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py new file mode 100644 index 0000000000..222792ebb3 --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py @@ -0,0 +1,74 @@ +""" +Unit tests for stub XQueue implementation. +""" + +import mock +import unittest +import json +import urllib +import time +from terrain.stubs.xqueue import StubXQueueService + + +class StubXQueueServiceTest(unittest.TestCase): + + def setUp(self): + self.server = StubXQueueService() + self.url = "http://127.0.0.1:{0}".format(self.server.port) + self.addCleanup(self.server.shutdown) + + # For testing purposes, do not delay the grading response + self.server.set_config('response_delay', 0) + + @mock.patch('requests.post') + def test_grade_request(self, post): + + # Send a grade request + callback_url = 'http://127.0.0.1:8000/test_callback' + + grade_header = json.dumps({ + 'lms_callback_url': callback_url, + 'lms_key': 'test_queuekey', + 'queue_name': 'test_queue' + }) + + grade_body = json.dumps({ + 'student_info': 'test', + 'grader_payload': 'test', + 'student_response': 'test' + }) + + grade_request = { + 'xqueue_header': grade_header, + 'xqueue_body': grade_body + } + + response_handle = urllib.urlopen( + self.url + '/xqueue/submit', + urllib.urlencode(grade_request) + ) + + response_dict = json.loads(response_handle.read()) + + # Expect that the response is success + self.assertEqual(response_dict['return_code'], 0) + + # Expect that the server tries to post back the grading info + xqueue_body = json.dumps( + {'correct': True, 'score': 1, 'msg': '
'} + ) + + expected_callback_dict = { + 'xqueue_header': grade_header, + 'xqueue_body': xqueue_body + } + + # Wait for the server to POST back to the callback URL + # Time out if it takes too long + start_time = time.time() + while time.time() - start_time < 5: + if post.called: + break + + # Check that the POST request was made with the correct params + post.assert_called_with(callback_url, data=expected_callback_dict) diff --git a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py new file mode 100644 index 0000000000..a3f048482c --- /dev/null +++ b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py @@ -0,0 +1,58 @@ +""" +Unit test for stub YouTube implementation. +""" + +import unittest +import requests +from terrain.stubs.youtube import StubYouTubeService + + +class StubYouTubeServiceTest(unittest.TestCase): + + def setUp(self): + self.server = StubYouTubeService() + self.url = "http://127.0.0.1:{0}/".format(self.server.port) + self.server.set_config('time_to_response', 0.0) + self.addCleanup(self.server.shutdown) + + def test_unused_url(self): + response = requests.get(self.url + 'unused_url') + self.assertEqual("Unused url", response.content) + + def test_video_url(self): + response = requests.get( + self.url + 'test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func' + ) + + self.assertEqual('callback_func({"message": "I\'m youtube."})', response.content) + + def test_transcript_url_equal(self): + response = requests.get( + self.url + 'test_transcripts_youtube/t__eq_exist' + ) + + self.assertEqual( + "".join([ + '', + '', + 'Equal transcripts' + ]), response.content + ) + + def test_transcript_url_not_equal(self): + response = requests.get( + self.url + 'test_transcripts_youtube/t_neq_exist', + ) + + self.assertEqual( + "".join([ + '', + '', + 'Transcripts sample, different that on server', + '' + ]), response.content + ) + + def test_transcript_not_found(self): + response = requests.get(self.url + 'test_transcripts_youtube/some_id') + self.assertEqual(404, response.status_code) diff --git a/common/djangoapps/terrain/stubs/xqueue.py b/common/djangoapps/terrain/stubs/xqueue.py new file mode 100644 index 0000000000..96e8e78f7b --- /dev/null +++ b/common/djangoapps/terrain/stubs/xqueue.py @@ -0,0 +1,122 @@ +""" +Stub implementation of XQueue for acceptance tests. +""" + +from .http import StubHttpRequestHandler, StubHttpService +import json +import requests +import threading + + +class StubXQueueHandler(StubHttpRequestHandler): + """ + A handler for XQueue POST requests. + """ + + DEFAULT_RESPONSE_DELAY = 2 + DEFAULT_GRADE_RESPONSE = {'correct': True, 'score': 1, 'msg': ''} + + def do_POST(self): + """ + Handle a POST request from the client + + Sends back an immediate success/failure response. + It then POSTS back to the client with grading results. + """ + msg = "XQueue received POST request {0} to path {1}".format(self.post_dict, self.path) + self.log_message(msg) + + # Respond only to grading requests + if self._is_grade_request(): + try: + xqueue_header = json.loads(self.post_dict['xqueue_header']) + callback_url = xqueue_header['lms_callback_url'] + + except KeyError: + # If the message doesn't have a header or body, + # then it's malformed. + # Respond with failure + error_msg = "XQueue received invalid grade request" + self._send_immediate_response(False, message=error_msg) + + except ValueError: + # If we could not decode the body or header, + # respond with failure + + error_msg = "XQueue could not decode grade request" + self._send_immediate_response(False, message=error_msg) + + else: + # Send an immediate response of success + # The grade request is formed correctly + self._send_immediate_response(True) + + # Wait a bit before POSTing back to the callback url with the + # grade result configured by the server + # Otherwise, the problem will not realize it's + # queued and it will keep waiting for a response indefinitely + delayed_grade_func = lambda: self._send_grade_response( + callback_url, xqueue_header + ) + + threading.Timer( + self.server.config('response_delay', default=self.DEFAULT_RESPONSE_DELAY), + delayed_grade_func + ).start() + + # If we get a request that's not to the grading submission + # URL, return an error + else: + error_message = "Invalid request URL" + self._send_immediate_response(False, message=error_message) + + def _send_immediate_response(self, success, message=""): + """ + Send an immediate success/failure message + back to the client + """ + + # Send the response indicating success/failure + response_str = json.dumps( + {'return_code': 0 if success else 1, 'content': message} + ) + + if self._is_grade_request(): + self.send_response( + 200, content=response_str, headers={'Content-type': 'text/plain'} + ) + self.log_message("XQueue: sent response {0}".format(response_str)) + + else: + self.send_response(500) + + def _send_grade_response(self, postback_url, xqueue_header): + """ + POST the grade response back to the client + using the response provided by the server configuration + """ + # Get the grade response from the server configuration + grade_response = self.server.config('grade_response', default=self.DEFAULT_GRADE_RESPONSE) + + # Wrap the message in
tags to ensure that it is valid XML + if isinstance(grade_response, dict) and 'msg' in grade_response: + grade_response['msg'] = "
{0}
".format(grade_response['msg']) + + data = { + 'xqueue_header': json.dumps(xqueue_header), + 'xqueue_body': json.dumps(grade_response) + } + + requests.post(postback_url, data=data) + self.log_message("XQueue: sent grading response {0}".format(data)) + + def _is_grade_request(self): + return 'xqueue/submit' in self.path + + +class StubXQueueService(StubHttpService): + """ + A stub XQueue grading server that responds to POST requests to localhost. + """ + + HANDLER_CLASS = StubXQueueHandler diff --git a/common/djangoapps/terrain/stubs/youtube.py b/common/djangoapps/terrain/stubs/youtube.py new file mode 100644 index 0000000000..07e4b3c92a --- /dev/null +++ b/common/djangoapps/terrain/stubs/youtube.py @@ -0,0 +1,85 @@ +""" +Stub implementation of YouTube for acceptance tests. +""" + +from .http import StubHttpRequestHandler, StubHttpService +import json +import time +import requests + + +class StubYouTubeHandler(StubHttpRequestHandler): + """ + A handler for Youtube GET requests. + """ + + # Default number of seconds to delay the response to simulate network latency. + DEFAULT_DELAY_SEC = 0.5 + + def do_GET(self): + """ + Handle a GET request from the client and sends response back. + """ + + self.log_message( + "Youtube provider received GET request to path {}".format(self.path) + ) + + if 'test_transcripts_youtube' in self.path: + + if 't__eq_exist' in self.path: + status_message = "".join([ + '', + '', + 'Equal transcripts' + ]) + + self.send_response( + 200, content=status_message, headers={'Content-type': 'application/xml'} + ) + + elif 't_neq_exist' in self.path: + status_message = "".join([ + '', + '', + 'Transcripts sample, different that on server', + '' + ]) + + self.send_response( + 200, content=status_message, headers={'Content-type': 'application/xml'} + ) + + else: + self.send_response(404) + + elif 'test_youtube' in self.path: + self._send_video_response("I'm youtube.") + + else: + self.send_response( + 404, content="Unused url", headers={'Content-type': 'text/plain'} + ) + + def _send_video_response(self, message): + """ + Send message back to the client for video player requests. + Requires sending back callback id. + """ + # Delay the response to simulate network latency + time.sleep(self.server.config('time_to_response', self.DEFAULT_DELAY_SEC)) + + # Construct the response content + callback = self.get_params['callback'][0] + response = callback + '({})'.format(json.dumps({'message': message})) + + self.send_response(200, content=response, headers={'Content-type': 'text/html'}) + self.log_message("Youtube: sent response {}".format(message)) + + +class StubYouTubeService(StubHttpService): + """ + A stub Youtube provider server that responds to GET requests to localhost. + """ + + HANDLER_CLASS = StubYouTubeHandler diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 2e6a6efc3b..bc61c0eed6 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -10,7 +10,7 @@ from textwrap import dedent from urllib import quote_plus from selenium.common.exceptions import ( WebDriverException, TimeoutException, - StaleElementReferenceException) + StaleElementReferenceException, InvalidElementStateException) from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait @@ -581,7 +581,7 @@ def trigger_event(css_selector, event='change', index=0): @world.absorb -def retry_on_exception(func, max_attempts=5, ignored_exceptions=StaleElementReferenceException): +def retry_on_exception(func, max_attempts=5, ignored_exceptions=(StaleElementReferenceException, InvalidElementStateException)): """ Retry the interaction, ignoring the passed exceptions. By default ignore StaleElementReferenceException, which happens often in our application diff --git a/common/lib/xmodule/xmodule/util/mock_youtube_server/mock_youtube_server.py b/common/lib/xmodule/xmodule/util/mock_youtube_server/mock_youtube_server.py deleted file mode 100644 index 3fc7c8285e..0000000000 --- a/common/lib/xmodule/xmodule/util/mock_youtube_server/mock_youtube_server.py +++ /dev/null @@ -1,125 +0,0 @@ -from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -import json -import mock -import sys -import threading -import time -import urlparse -from logging import getLogger - - -logger = getLogger(__name__) - -class MockYoutubeRequestHandler(BaseHTTPRequestHandler): - ''' - A handler for Youtube GET requests. - ''' - - protocol = "HTTP/1.0" - - def log_message(self, format, *args): - """Log an arbitrary message.""" - # Code copied from BaseHTTPServer.py. Changed to write to sys.stdout - # so that messages won't pollute test output. - sys.stdout.write("%s - - [%s] %s\n" % - (self.client_address[0], - self.log_date_time_string(), - format % args)) - - def do_HEAD(self): - code = 200 - if 'test_transcripts_youtube' in self.path: - if not 'trans_exist' in self.path: - code = 404 - self._send_head(code) - - def do_GET(self): - ''' - Handle a GET request from the client and sends response back. - ''' - logger.debug("Youtube provider received GET request to path {}".format( - self.path) - ) # Log the request - - if 'test_transcripts_youtube' in self.path: - if 't__eq_exist' in self.path: - status_message = """Equal transcripts""" - self._send_head() - self._send_transcripts_response(status_message) - elif 't_neq_exist' in self.path: - status_message = """Transcripts sample, different that on server""" - self._send_head() - self._send_transcripts_response(status_message) - else: - self._send_head(404) - elif 'test_youtube' in self.path: - self._send_head() - #testing videoplayers - status_message = "I'm youtube." - response_timeout = float(self.server.time_to_response) - - # threading timer produces TypeError: 'NoneType' object is not callable here - # so we use time.sleep, as we already in separate thread. - time.sleep(response_timeout) - self._send_video_response(status_message) - else: - # unused url - self._send_head() - self._send_transcripts_response('Unused url') - logger.debug("Request to unused url.") - - def _send_head(self, code=200): - ''' - Send the response code and MIME headers - ''' - - self.send_response(code) - self.send_header('Content-type', 'text/html') - self.end_headers() - - def _send_transcripts_response(self, message): - ''' - Send message back to the client for transcripts ajax requests. - ''' - response = message - # Log the response - logger.debug("Youtube: sent response {}".format(message)) - - self.wfile.write(response) - - def _send_video_response(self, message): - ''' - Send message back to the client for video player requests. - Requires sending back callback id. - ''' - callback = urlparse.parse_qs(self.path)['callback'][0] - response = callback + '({})'.format(json.dumps({'message': message})) - # Log the response - logger.debug("Youtube: sent response {}".format(message)) - - self.wfile.write(response) - - -class MockYoutubeServer(HTTPServer): - ''' - A mock Youtube provider server that responds - to GET requests to localhost. - ''' - - def __init__(self, address): - ''' - Initialize the mock XQueue server instance. - - *address* is the (host, host's port to listen to) tuple. - ''' - handler = MockYoutubeRequestHandler - HTTPServer.__init__(self, address, handler) - - def shutdown(self): - ''' - Stop the server and free up the port - ''' - # First call superclass shutdown() - HTTPServer.shutdown(self) - # We also need to manually close the socket - self.socket.close() diff --git a/common/lib/xmodule/xmodule/util/mock_youtube_server/test_mock_youtube_server.py b/common/lib/xmodule/xmodule/util/mock_youtube_server/test_mock_youtube_server.py deleted file mode 100644 index bb65760905..0000000000 --- a/common/lib/xmodule/xmodule/util/mock_youtube_server/test_mock_youtube_server.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Test for Mock_Youtube_Server -""" -import unittest -import threading -import requests -from mock_youtube_server import MockYoutubeServer - - -class MockYoutubeServerTest(unittest.TestCase): - ''' - A mock version of the YouTube provider server that listens on a local - port and responds with jsonp. - - Used for lettuce BDD tests in lms/courseware/features/video.feature - ''' - - def setUp(self): - - # Create the server - server_port = 8034 - server_host = '127.0.0.1' - address = (server_host, server_port) - self.server = MockYoutubeServer(address, ) - self.server.time_to_response = 0.5 - # Start the server in a separate daemon thread - server_thread = threading.Thread(target=self.server.serve_forever) - server_thread.daemon = True - server_thread.start() - - def tearDown(self): - - # Stop the server, freeing up the port - self.server.shutdown() - - def test_request(self): - """ - Tests that Youtube server processes request with right program - path, and responses with incorrect signature. - """ - # GET request - - # unused url - response = requests.get( - 'http://127.0.0.1:8034/some url', - ) - self.assertEqual("Unused url", response.content) - - # video player test url, callback shoud be presented in url params - response = requests.get( - 'http://127.0.0.1:8034/test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func', - ) - self.assertEqual("""callback_func({"message": "I\'m youtube."})""", response.content) - - # transcripts test url - response = requests.get( - 'http://127.0.0.1:8034/test_transcripts_youtube/t__eq_exist', - ) - self.assertEqual( - 'Equal transcripts', - response.content - ) - - # transcripts test url - response = requests.get( - 'http://127.0.0.1:8034/test_transcripts_youtube/t_neq_exist', - ) - self.assertEqual( - 'Transcripts sample, different that on server', - response.content - ) - - # transcripts test url, not trans_exist youtube_id, so 404 should be returned - response = requests.get( - 'http://127.0.0.1:8034/test_transcripts_youtube/some_id', - ) - self.assertEqual(404, response.status_code) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index b7ca37b16e..576c32782e 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -167,12 +167,6 @@ class VideoModule(VideoFields, XModule): sources = {get_ext(src): src for src in self.html5_sources} sources['main'] = self.source - # for testing Youtube timeout in acceptance tests - if getattr(settings, 'VIDEO_PORT', None): - yt_test_url = "http://127.0.0.1:" + str(settings.VIDEO_PORT) + '/test_youtube/' - else: - yt_test_url = 'https://gdata.youtube.com/feeds/api/videos/' - return self.system.render_template('video.html', { 'youtube_streams': _create_youtube_string(self), 'id': self.location.html_id(), @@ -191,7 +185,7 @@ class VideoModule(VideoFields, XModule): # TODO: Later on the value 1500 should be taken from some global # configuration setting field. 'yt_test_timeout': 1500, - 'yt_test_url': yt_test_url + 'yt_test_url': settings.YOUTUBE_TEST_URL }) diff --git a/lms/djangoapps/courseware/features/lti_setup.py b/lms/djangoapps/courseware/features/lti_setup.py index e86aa78ed2..40fb2ee969 100644 --- a/lms/djangoapps/courseware/features/lti_setup.py +++ b/lms/djangoapps/courseware/features/lti_setup.py @@ -14,9 +14,7 @@ logger = getLogger(__name__) def setup_mock_lti_server(): server_host = '127.0.0.1' - - # Add +1 to XQUEUE random port number - server_port = settings.XQUEUE_PORT + 1 + server_port = settings.LTI_PORT address = (server_host, server_port) diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 0be5b72284..278855a761 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -73,7 +73,7 @@ def set_external_grader_response(step, correctness): # Set the fake xqueue server to always respond # correct/incorrect when asked to grade a problem - world.xqueue_server.set_grade_response(response_dict) + world.xqueue.set_config('grade_response', response_dict) @step(u'I answer a "([^"]*)" problem "([^"]*)ly"') diff --git a/lms/djangoapps/courseware/features/video.feature b/lms/djangoapps/courseware/features/video.feature index 7518bf6bdb..1ca82b02a1 100644 --- a/lms/djangoapps/courseware/features/video.feature +++ b/lms/djangoapps/courseware/features/video.feature @@ -18,7 +18,7 @@ Feature: LMS.Video component # 3 # Youtube testing Scenario: Video component is fully rendered in the LMS in Youtube mode with HTML5 sources - Given youtube server is up and response time is 0.4 seconds + Given youtube server is up and response time is 0.4 seconds And the course has a Video component in Youtube_HTML5 mode Then when I view the video it has rendered in Youtube mode diff --git a/lms/djangoapps/courseware/features/video.py b/lms/djangoapps/courseware/features/video.py index 57fc8a0a9a..3635373437 100644 --- a/lms/djangoapps/courseware/features/video.py +++ b/lms/djangoapps/courseware/features/video.py @@ -83,7 +83,7 @@ def add_video_to_course(course, player_mode): @step('youtube server is up and response time is (.*) seconds$') def set_youtube_response_timeout(_step, time): - world.youtube_server.time_to_response = time + world.youtube.set_config('time_to_response', float(time)) @step('when I view the video it has rendered in (.*) mode$') diff --git a/lms/djangoapps/courseware/features/xqueue_setup.py b/lms/djangoapps/courseware/features/xqueue_setup.py deleted file mode 100644 index 90a68961ee..0000000000 --- a/lms/djangoapps/courseware/features/xqueue_setup.py +++ /dev/null @@ -1,35 +0,0 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 - -from courseware.mock_xqueue_server.mock_xqueue_server import MockXQueueServer -from lettuce import before, after, world -from django.conf import settings -import threading - -@before.all -def setup_mock_xqueue_server(): - - # Retrieve the local port from settings - server_port = settings.XQUEUE_PORT - - # Create the mock server instance - server = MockXQueueServer(server_port) - - # Start the server running in a separate daemon thread - # Because the thread is a daemon, it will terminate - # when the main thread terminates. - server_thread = threading.Thread(target=server.serve_forever) - server_thread.daemon = True - server_thread.start() - - # Store the server instance in lettuce's world - # so that other steps can access it - # (and we can shut it down later) - world.xqueue_server = server - - -@after.all -def teardown_mock_xqueue_server(total): - - # Stop the xqueue server and free up the port - world.xqueue_server.shutdown() diff --git a/lms/djangoapps/courseware/features/youtube_setup.py b/lms/djangoapps/courseware/features/youtube_setup.py deleted file mode 100644 index b9e536c5fe..0000000000 --- a/lms/djangoapps/courseware/features/youtube_setup.py +++ /dev/null @@ -1,46 +0,0 @@ -#pylint: disable=C0111 -#pylint: disable=W0621 -from xmodule.util.mock_youtube_server.mock_youtube_server import MockYoutubeServer -from lettuce import before, after, world -from django.conf import settings -import threading - -from logging import getLogger -logger = getLogger(__name__) - - -@before.all -def setup_mock_youtube_server(): - # import ipdb; ipdb.set_trace() - server_host = '127.0.0.1' - - server_port = settings.VIDEO_PORT - - address = (server_host, server_port) - - # Create the mock server instance - server = MockYoutubeServer(address) - logger.debug("Youtube server started at {} port".format(str(server_port))) - - server.time_to_response = 1 # seconds - - server.address = address - - # Start the server running in a separate daemon thread - # Because the thread is a daemon, it will terminate - # when the main thread terminates. - server_thread = threading.Thread(target=server.serve_forever) - server_thread.daemon = True - server_thread.start() - - # Store the server instance in lettuce's world - # so that other steps can access it - # (and we can shut it down later) - world.youtube_server = server - - -@after.all -def teardown_mock_youtube_server(total): - - # Stop the LTI server and free up the port - world.youtube_server.shutdown() diff --git a/lms/djangoapps/courseware/mock_xqueue_server/mock_xqueue_server.py b/lms/djangoapps/courseware/mock_xqueue_server/mock_xqueue_server.py deleted file mode 100644 index 7bc1b95662..0000000000 --- a/lms/djangoapps/courseware/mock_xqueue_server/mock_xqueue_server.py +++ /dev/null @@ -1,211 +0,0 @@ -from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler -import json -import urllib -import urlparse -import threading - -from logging import getLogger -logger = getLogger(__name__) - - -class MockXQueueRequestHandler(BaseHTTPRequestHandler): - ''' - A handler for XQueue POST requests. - ''' - - protocol = "HTTP/1.0" - - def do_HEAD(self): - self._send_head() - - def do_POST(self): - ''' - Handle a POST request from the client - - Sends back an immediate success/failure response. - It then POSTS back to the client - with grading results, as configured in MockXQueueServer. - ''' - self._send_head() - - # Retrieve the POST data - post_dict = self._post_dict() - - # Log the request - logger.debug("XQueue received POST request %s to path %s" % - (str(post_dict), self.path)) - - # Respond only to grading requests - if self._is_grade_request(): - try: - xqueue_header = json.loads(post_dict['xqueue_header']) - xqueue_body = json.loads(post_dict['xqueue_body']) - - callback_url = xqueue_header['lms_callback_url'] - - except KeyError: - # If the message doesn't have a header or body, - # then it's malformed. - # Respond with failure - error_msg = "XQueue received invalid grade request" - self._send_immediate_response(False, message=error_msg) - - except ValueError: - # If we could not decode the body or header, - # respond with failure - - error_msg = "XQueue could not decode grade request" - self._send_immediate_response(False, message=error_msg) - - else: - # Send an immediate response of success - # The grade request is formed correctly - self._send_immediate_response(True) - - # Wait a bit before POSTing back to the callback url with the - # grade result configured by the server - # Otherwise, the problem will not realize it's - # queued and it will keep waiting for a response - # indefinitely - delayed_grade_func = lambda: self._send_grade_response(callback_url, - xqueue_header) - - timer = threading.Timer(2, delayed_grade_func) - timer.start() - - # If we get a request that's not to the grading submission - # URL, return an error - else: - error_message = "Invalid request URL" - self._send_immediate_response(False, message=error_message) - - - def _send_head(self): - ''' - Send the response code and MIME headers - ''' - if self._is_grade_request(): - self.send_response(200) - else: - self.send_response(500) - - self.send_header('Content-type', 'text/plain') - self.end_headers() - - def _post_dict(self): - ''' - Retrieve the POST parameters from the client as a dictionary - ''' - - try: - length = int(self.headers.getheader('content-length')) - - post_dict = urlparse.parse_qs(self.rfile.read(length)) - - # The POST dict will contain a list of values - # for each key. - # None of our parameters are lists, however, - # so we map [val] --> val - # If the list contains multiple entries, - # we pick the first one - post_dict = dict(map(lambda (key, list_val): (key, list_val[0]), - post_dict.items())) - - except: - # We return an empty dict here, on the assumption - # that when we later check that the request has - # the correct fields, it won't find them, - # and will therefore send an error response - return {} - - return post_dict - - def _send_immediate_response(self, success, message=""): - ''' - Send an immediate success/failure message - back to the client - ''' - - # Send the response indicating success/failure - response_str = json.dumps({'return_code': 0 if success else 1, - 'content': message}) - - # Log the response - logger.debug("XQueue: sent response %s" % response_str) - - self.wfile.write(response_str) - - def _send_grade_response(self, postback_url, xqueue_header): - ''' - POST the grade response back to the client - using the response provided by the server configuration - ''' - response_dict = {'xqueue_header': json.dumps(xqueue_header), - 'xqueue_body': json.dumps(self.server.grade_response())} - - # Log the response - logger.debug("XQueue: sent grading response %s" % str(response_dict)) - - MockXQueueRequestHandler.post_to_url(postback_url, response_dict) - - def _is_grade_request(self): - return 'xqueue/submit' in self.path - - @staticmethod - def post_to_url(url, param_dict): - ''' - POST *param_dict* to *url* - We make this a separate function so we can easily patch - it during testing. - ''' - urllib.urlopen(url, urllib.urlencode(param_dict)) - - -class MockXQueueServer(HTTPServer): - ''' - A mock XQueue grading server that responds - to POST requests to localhost. - ''' - - def __init__(self, port_num, - grade_response_dict={'correct': True, 'score': 1, 'msg': ''}): - ''' - Initialize the mock XQueue server instance. - - *port_num* is the localhost port to listen to - - *grade_response_dict* is a dictionary that will be JSON-serialized - and sent in response to XQueue grading requests. - ''' - - self.set_grade_response(grade_response_dict) - - handler = MockXQueueRequestHandler - address = ('', port_num) - HTTPServer.__init__(self, address, handler) - - def shutdown(self): - ''' - Stop the server and free up the port - ''' - # First call superclass shutdown() - HTTPServer.shutdown(self) - - # We also need to manually close the socket - self.socket.close() - - def grade_response(self): - return self._grade_response - - def set_grade_response(self, grade_response_dict): - - # Check that the grade response has the right keys - assert('correct' in grade_response_dict and - 'score' in grade_response_dict and - 'msg' in grade_response_dict) - - # Wrap the message in
tags to ensure that it is valid XML - grade_response_dict['msg'] = "
%s
" % grade_response_dict['msg'] - - # Save the response dictionary - self._grade_response = grade_response_dict diff --git a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py b/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py deleted file mode 100644 index 3f9a8e5b42..0000000000 --- a/lms/djangoapps/courseware/mock_xqueue_server/test_mock_xqueue_server.py +++ /dev/null @@ -1,84 +0,0 @@ -import mock -import unittest -import threading -import json -import urllib -import time -from mock_xqueue_server import MockXQueueServer, MockXQueueRequestHandler - -from nose.plugins.skip import SkipTest - - -class MockXQueueServerTest(unittest.TestCase): - ''' - A mock version of the XQueue server that listens on a local - port and responds with pre-defined grade messages. - - Used for lettuce BDD tests in lms/courseware/features/problems.feature - and lms/courseware/features/problems.py - - This is temporary and will be removed when XQueue is - rewritten using celery. - ''' - - def setUp(self): - - # This is a test of the test setup, - # so it does not need to run as part of the unit test suite - # You can re-enable it by commenting out the line below - raise SkipTest - - # Create the server - server_port = 8034 - self.server_url = 'http://127.0.0.1:%d' % server_port - self.server = MockXQueueServer(server_port, - {'correct': True, 'score': 1, 'msg': ''}) - - # Start the server in a separate daemon thread - server_thread = threading.Thread(target=self.server.serve_forever) - server_thread.daemon = True - server_thread.start() - - def tearDown(self): - - # Stop the server, freeing up the port - self.server.shutdown() - - def test_grade_request(self): - - # Patch post_to_url() so we can intercept - # outgoing POST requests from the server - MockXQueueRequestHandler.post_to_url = mock.Mock() - - # Send a grade request - callback_url = 'http://127.0.0.1:8000/test_callback' - - grade_header = json.dumps({'lms_callback_url': callback_url, - 'lms_key': 'test_queuekey', - 'queue_name': 'test_queue'}) - - grade_body = json.dumps({'student_info': 'test', - 'grader_payload': 'test', - 'student_response': 'test'}) - - grade_request = {'xqueue_header': grade_header, - 'xqueue_body': grade_body} - - response_handle = urllib.urlopen(self.server_url + '/xqueue/submit', - urllib.urlencode(grade_request)) - - response_dict = json.loads(response_handle.read()) - - # Expect that the response is success - self.assertEqual(response_dict['return_code'], 0) - - # Wait a bit before checking that the server posted back - time.sleep(3) - - # Expect that the server tries to post back the grading info - xqueue_body = json.dumps({'correct': True, 'score': 1, - 'msg': '
'}) - expected_callback_dict = {'xqueue_header': grade_header, - 'xqueue_body': xqueue_body} - MockXQueueRequestHandler.post_to_url.assert_called_with(callback_url, - expected_callback_dict) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 6b4b5f7665..a10ff9a164 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -152,6 +152,7 @@ SELENIUM_GRID = { 'BROWSER': LETTUCE_BROWSER, } + ##################################################################### # See if the developer has any local overrides. try: @@ -161,22 +162,9 @@ except ImportError: # Because an override for where to run will affect which ports to use, # set these up after the local overrides. -if LETTUCE_SELENIUM_CLIENT == 'saucelabs': - LETTUCE_SERVER_PORT = choice(PORTS) - PORTS.remove(LETTUCE_SERVER_PORT) -else: - LETTUCE_SERVER_PORT = randint(1024, 65535) - -# Set up XQueue information so that the lms will send -# requests to a mock XQueue server running locally -if LETTUCE_SELENIUM_CLIENT == 'saucelabs': - XQUEUE_PORT = choice(PORTS) - PORTS.remove(XQUEUE_PORT) -else: - XQUEUE_PORT = randint(1024, 65535) - +# Configure XQueue interface to use our stub XQueue server XQUEUE_INTERFACE = { - "url": "http://127.0.0.1:%d" % XQUEUE_PORT, + "url": "http://127.0.0.1:{0:d}".format(XQUEUE_PORT), "django_auth": { "username": "lms", "password": "***REMOVED***" @@ -184,10 +172,5 @@ XQUEUE_INTERFACE = { "basic_auth": ('anant', 'agarwal'), } -# Set up Video information so that the lms will send -# requests to a mock Youtube server running locally -if LETTUCE_SELENIUM_CLIENT == 'saucelabs': - VIDEO_PORT = choice(PORTS) - PORTS.remove(VIDEO_PORT) -else: - VIDEO_PORT = randint(1024, 65535) +# Point the URL used to test YouTube availability to our stub YouTube server +YOUTUBE_TEST_URL = "http://127.0.0.1:{0}/test_youtube/".format(YOUTUBE_PORT) diff --git a/lms/envs/common.py b/lms/envs/common.py index 893e537f77..0ca13d9f8c 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -911,6 +911,11 @@ BULK_EMAIL_LOG_SENT_EMAILS = False BULK_EMAIL_RETRY_DELAY_BETWEEN_SENDS = 0.02 +############################## Video ########################################## + +# URL to test YouTube availability +YOUTUBE_TEST_URL = 'https://gdata.youtube.com/feeds/api/videos/' + ################################### APPS ###################################### INSTALLED_APPS = ( diff --git a/lms/envs/test.py b/lms/envs/test.py index 31636f6aad..eb08cef9bf 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -239,6 +239,16 @@ FILE_UPLOAD_HANDLERS = ( 'django.core.files.uploadhandler.TemporaryFileUploadHandler', ) +########################### Server Ports ################################### + +# These ports are carefully chosen so that if the browser needs to +# access them, they will be available through the SauceLabs SSH tunnel +LETTUCE_SERVER_PORT = 8003 +XQUEUE_PORT = 8040 +YOUTUBE_PORT = 8031 +LTI_PORT = 8765 + + ################### Make tests faster #http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/