Merge pull request #1965 from edx/will/fake-server-refactor
Stub Server Port Conflicts and Refactor
This commit is contained in:
@@ -4,3 +4,4 @@
|
||||
from terrain.browser import *
|
||||
from terrain.steps import *
|
||||
from terrain.factories import *
|
||||
from terrain.start_stubs import *
|
||||
|
||||
37
common/djangoapps/terrain/start_stubs.py
Normal file
37
common/djangoapps/terrain/start_stubs.py
Normal file
@@ -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()
|
||||
0
common/djangoapps/terrain/stubs/__init__.py
Normal file
0
common/djangoapps/terrain/stubs/__init__.py
Normal file
181
common/djangoapps/terrain/stubs/http.py
Normal file
181
common/djangoapps/terrain/stubs/http.py
Normal file
@@ -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
|
||||
|
||||
0
common/djangoapps/terrain/stubs/tests/__init__.py
Normal file
0
common/djangoapps/terrain/stubs/tests/__init__.py
Normal file
58
common/djangoapps/terrain/stubs/tests/test_http.py
Normal file
58
common/djangoapps/terrain/stubs/tests/test_http.py
Normal file
@@ -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)
|
||||
74
common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py
Normal file
74
common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py
Normal file
@@ -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': '<div></div>'}
|
||||
)
|
||||
|
||||
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)
|
||||
58
common/djangoapps/terrain/stubs/tests/test_youtube_stub.py
Normal file
58
common/djangoapps/terrain/stubs/tests/test_youtube_stub.py
Normal file
@@ -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([
|
||||
'<?xml version="1.0" encoding="utf-8" ?>',
|
||||
'<transcript><text start="1.0" dur="1.0">',
|
||||
'Equal transcripts</text></transcript>'
|
||||
]), response.content
|
||||
)
|
||||
|
||||
def test_transcript_url_not_equal(self):
|
||||
response = requests.get(
|
||||
self.url + 'test_transcripts_youtube/t_neq_exist',
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
"".join([
|
||||
'<?xml version="1.0" encoding="utf-8" ?>',
|
||||
'<transcript><text start="1.1" dur="5.5">',
|
||||
'Transcripts sample, different that on server',
|
||||
'</text></transcript>'
|
||||
]), response.content
|
||||
)
|
||||
|
||||
def test_transcript_not_found(self):
|
||||
response = requests.get(self.url + 'test_transcripts_youtube/some_id')
|
||||
self.assertEqual(404, response.status_code)
|
||||
122
common/djangoapps/terrain/stubs/xqueue.py
Normal file
122
common/djangoapps/terrain/stubs/xqueue.py
Normal file
@@ -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 <div> tags to ensure that it is valid XML
|
||||
if isinstance(grade_response, dict) and 'msg' in grade_response:
|
||||
grade_response['msg'] = "<div>{0}</div>".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
|
||||
85
common/djangoapps/terrain/stubs/youtube.py
Normal file
85
common/djangoapps/terrain/stubs/youtube.py
Normal file
@@ -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([
|
||||
'<?xml version="1.0" encoding="utf-8" ?>',
|
||||
'<transcript><text start="1.0" dur="1.0">',
|
||||
'Equal transcripts</text></transcript>'
|
||||
])
|
||||
|
||||
self.send_response(
|
||||
200, content=status_message, headers={'Content-type': 'application/xml'}
|
||||
)
|
||||
|
||||
elif 't_neq_exist' in self.path:
|
||||
status_message = "".join([
|
||||
'<?xml version="1.0" encoding="utf-8" ?>',
|
||||
'<transcript><text start="1.1" dur="5.5">',
|
||||
'Transcripts sample, different that on server',
|
||||
'</text></transcript>'
|
||||
])
|
||||
|
||||
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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user