ui_helpers.py 21.2 KB
Newer Older
1
# pylint: disable=missing-docstring
2

3
import json
4
import platform
5 6 7 8
import re
import time
from textwrap import dedent
from urllib import quote_plus
9 10 11 12 13

# django_url is assigned late in the process of loading lettuce,
# so we import this as a module, and then read django_url from
# it to get the correct value
import lettuce.django
14 15
from lettuce import world
from nose.tools import assert_true
16
from selenium.common.exceptions import (
17 18 19 20 21
    InvalidElementStateException,
    StaleElementReferenceException,
    TimeoutException,
    WebDriverException
)
22
from selenium.webdriver.common.by import By
23
from selenium.webdriver.support import expected_conditions as EC
24
from selenium.webdriver.support.ui import WebDriverWait
25

26
GLOBAL_WAIT_FOR_TIMEOUT = 60
27

28 29
REQUIREJS_WAIT = {
    # Settings - Schedule & Details
Braden MacDonald committed
30
    re.compile(r'^Schedule & Details Settings \|'): [
31
        "jquery", "js/base", "js/models/course",
32 33 34
        "js/models/settings/course_details", "js/views/settings/main"],

    # Settings - Advanced Settings
Braden MacDonald committed
35
    re.compile(r'^Advanced Settings \|'): [
36
        "jquery", "js/base", "js/models/course", "js/models/settings/advanced",
37 38
        "js/views/settings/advanced", "codemirror"],

39
    # Unit page
Braden MacDonald committed
40
    re.compile(r'^Unit \|'): [
41
        "jquery", "js/base", "js/models/xblock_info", "js/views/pages/container",
42
        "js/collections/component_template", "xmodule", "cms/js/main", "xblock/cms.runtime.v1"],
43

44 45
    # Content - Outline
    # Note that calling your org, course number, or display name, 'course' will mess this up
Braden MacDonald committed
46
    re.compile(r'^Course Outline \|'): [
47
        "js/base", "js/models/course", "js/models/location", "js/models/section"],
48 49

    # Dashboard
Braden MacDonald committed
50
    re.compile(r'^Studio Home \|'): [
51
        "js/sock", "gettext", "js/base",
52
        "jquery.ui", "cms/js/main", "underscore"],
53 54 55

    # Upload
    re.compile(r'^\s*Files & Uploads'): [
56
        'js/base', 'jquery.ui', 'cms/js/main', 'underscore',
57
        'js/views/assets', 'js/views/asset'
58 59 60
    ],

    # Pages
Braden MacDonald committed
61
    re.compile(r'^Pages \|'): [
62
        'js/models/explicit_url', 'js/views/tabs',
63
        'xmodule', 'cms/js/main', 'xblock/cms.runtime.v1'
64
    ],
65 66 67
}


68 69 70 71
@world.absorb
def wait(seconds):
    time.sleep(float(seconds))

Will Daly committed
72

73 74 75 76 77 78 79 80 81 82
@world.absorb
def wait_for_js_to_load():
    requirements = None
    for test, req in REQUIREJS_WAIT.items():
        if test.search(world.browser.title):
            requirements = req
            break
    world.wait_for_requirejs(requirements)


83 84 85 86 87 88 89 90 91 92 93
# Selenium's `execute_async_script` function pauses Selenium's execution
# until the browser calls a specific Javascript callback; in effect,
# Selenium goes to sleep until the JS callback function wakes it back up again.
# This callback is passed as the last argument to the script. Any arguments
# passed to this callback get returned from the `execute_async_script`
# function, which allows the JS to communicate information back to Python.
# Ref: https://selenium.googlecode.com/svn/trunk/docs/api/dotnet/html/M_OpenQA_Selenium_IJavaScriptExecutor_ExecuteAsyncScript.htm
@world.absorb
def wait_for_js_variable_truthy(variable):
    """
    Using Selenium's `execute_async_script` function, poll the Javascript
94
    environment until the given variable is defined and truthy. This process
95 96
    guards against page reloads, and seamlessly retries on the next page.
    """
97
    javascript = """
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115
        var callback = arguments[arguments.length - 1];
        var unloadHandler = function() {{
          callback("unload");
        }}
        addEventListener("beforeunload", unloadHandler);
        addEventListener("unload", unloadHandler);
        var intervalID = setInterval(function() {{
          try {{
            if({variable}) {{
              clearInterval(intervalID);
              removeEventListener("beforeunload", unloadHandler);
              removeEventListener("unload", unloadHandler);
              callback(true);
            }}
          }} catch (e) {{}}
        }}, 10);
    """.format(variable=variable)
    for _ in range(5):  # 5 attempts max
116 117 118 119 120 121 122
        try:
            result = world.browser.driver.execute_async_script(dedent(javascript))
        except WebDriverException as wde:
            if "document unloaded while waiting for result" in wde.msg:
                result = "unload"
            else:
                raise
123 124 125 126 127 128 129 130 131 132 133 134 135
        if result == "unload":
            # we ran this on the wrong page. Wait a bit, and try again, when the
            # browser has loaded the next page.
            world.wait(1)
            continue
        else:
            return result


@world.absorb
def wait_for_xmodule():
    "Wait until the XModule Javascript has loaded on the page."
    world.wait_for_js_variable_truthy("XModule")
136
    world.wait_for_js_variable_truthy("XBlock")
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153


@world.absorb
def wait_for_mathjax():
    "Wait until MathJax is loaded and set up on the page."
    world.wait_for_js_variable_truthy("MathJax.isReady")


class RequireJSError(Exception):
    """
    An error related to waiting for require.js. If require.js is unable to load
    a dependency in the `wait_for_requirejs` function, Python will throw
    this exception to make sure that the failure doesn't pass silently.
    """
    pass


154
def load_requrejs_modules(dependencies, callback="callback(true);"):
155
    javascript = """
156 157 158 159 160 161 162 163 164
        var callback = arguments[arguments.length - 1];
        if(window.require) {{
          requirejs.onError = callback;
          var unloadHandler = function() {{
            callback("unload");
          }}
          addEventListener("beforeunload", unloadHandler);
          addEventListener("unload", unloadHandler);
          require({deps}, function($) {{
165
            var modules = arguments;
166 167 168
            setTimeout(function() {{
              removeEventListener("beforeunload", unloadHandler);
              removeEventListener("unload", unloadHandler);
169
              {callback}
170 171 172 173 174
            }}, 50);
          }});
        }} else {{
          callback(false);
        }}
175
    """.format(deps=json.dumps(dependencies), callback=callback)
176
    for _ in range(5):  # 5 attempts max
177 178 179 180 181 182 183
        try:
            result = world.browser.driver.execute_async_script(dedent(javascript))
        except WebDriverException as wde:
            if "document unloaded while waiting for result" in wde.msg:
                result = "unload"
            else:
                raise
184 185 186 187 188 189
        if result == "unload":
            # we ran this on the wrong page. Wait a bit, and try again, when the
            # browser has loaded the next page.
            world.wait(1)
            continue
        elif result not in (None, True, False):
190 191 192 193 194 195 196 197 198 199 200 201 202 203
            # We got a require.js error
            # Sometimes requireJS will throw an error with requireType=require
            # This doesn't seem to cause problems on the page, so we ignore it
            if result['requireType'] == 'require':
                world.wait(1)
                continue

            # Otherwise, fail and report the error
            else:
                msg = "Error loading dependencies: type={0} modules={1}".format(
                    result['requireType'], result['requireModules'])
                err = RequireJSError(msg)
                err.error = result
                raise err
204 205 206 207
        else:
            return result


208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247
def wait_for_xmodules_to_load():
    """
    If requirejs is loaded on the page, this function will pause
    Selenium until require is finished loading all xmodules.
    If requirejs is not loaded on the page, this function will return
    immediately.
    """
    callback = """
        if (modules[0] && modules[0].done) {{
            modules[0].done(function () {{callback(true)}});
        }}
    """
    return load_requrejs_modules(["xmodule"], callback)


@world.absorb
def wait_for_requirejs(dependencies=None):
    """
    If requirejs is loaded on the page, this function will pause
    Selenium until require is finished loading the given dependencies.
    If requirejs is not loaded on the page, this function will return
    immediately.

    :param dependencies: a list of strings that identify resources that
        we should wait for requirejs to load. By default, requirejs will only
        wait for jquery.
    """
    if not dependencies:
        dependencies = ["jquery"]
    # stick jquery at the front
    if dependencies[0] != "jquery":
        dependencies.insert(0, "jquery")

    result = load_requrejs_modules(dependencies)
    if result and "xmodule" in dependencies:
        result = wait_for_xmodules_to_load()

    return result


248 249 250 251 252 253 254 255 256 257
@world.absorb
def wait_for_ajax_complete():
    """
    Wait until all jQuery AJAX calls have completed. "Complete" means that
    either the server has sent a response (regardless of whether the response
    indicates success or failure), or that the AJAX call timed out waiting for
    a response. For more information about the `jQuery.active` counter that
    keeps track of this information, go here:
    http://stackoverflow.com/questions/3148225/jquery-active-function#3148506
    """
258
    javascript = """
259 260 261 262 263 264 265 266 267
        var callback = arguments[arguments.length - 1];
        if(!window.jQuery) {callback(false);}
        var intervalID = setInterval(function() {
          if(jQuery.active == 0) {
            clearInterval(intervalID);
            callback(true);
          }
        }, 100);
    """
268 269 270 271 272 273 274 275 276 277 278 279 280 281
    # Sometimes the ajax when it returns will make the browser reload
    # the DOM, and throw a WebDriverException with the message:
    # 'javascript error: document unloaded while waiting for result'
    for _ in range(5):  # 5 attempts max
        try:
            result = world.browser.driver.execute_async_script(dedent(javascript))
        except WebDriverException as wde:
            if "document unloaded while waiting for result" in wde.msg:
                # Wait a bit, and try again, when the browser has reloaded the page.
                world.wait(1)
                continue
            else:
                raise
        return result
282 283


284
@world.absorb
285
def visit(url):
286
    world.browser.visit(lettuce.django.django_url(url))
287
    wait_for_js_to_load()
288 289 290 291


@world.absorb
def url_equals(url):
292
    return world.browser.url == lettuce.django.django_url(url)
293 294 295


@world.absorb
296
def is_css_present(css_selector, wait_time=30):
297 298 299 300 301
    return world.browser.is_element_present_by_css(css_selector, wait_time=wait_time)


@world.absorb
def is_css_not_present(css_selector, wait_time=5):
302 303 304 305 306 307 308
    world.browser.driver.implicitly_wait(1)
    try:
        return world.browser.is_element_not_present_by_css(css_selector, wait_time=wait_time)
    except:
        raise
    finally:
        world.browser.driver.implicitly_wait(world.IMPLICIT_WAIT)
Will Daly committed
309

310

311
@world.absorb
312
def css_has_text(css_selector, text, index=0, strip=False):
313 314 315 316 317 318 319 320 321 322
    """
    Return a boolean indicating whether the element with `css_selector`
    has `text`.

    If `strip` is True, strip whitespace at beginning/end of both
    strings before comparing.

    If there are multiple elements matching the css selector,
    use `index` to indicate which one.
    """
323 324 325
    # If we're expecting a non-empty string, give the page
    # a chance to fill in text fields.
    if text:
326
        wait_for(lambda _: css_text(css_selector, index=index))
327

328
    actual_text = css_text(css_selector, index=index)
329 330 331 332 333 334 335 336 337

    if strip:
        actual_text = actual_text.strip()
        text = text.strip()

    return actual_text == text


@world.absorb
338 339 340 341 342 343 344 345 346 347 348
def css_contains_text(css_selector, partial_text, index=0):
    """
    Return a boolean indicating whether the element with `css_selector`
    contains `partial_text`.

    If there are multiple elements matching the css selector,
    use `index` to indicate which one.
    """
    # If we're expecting a non-empty string, give the page
    # a chance to fill in text fields.
    if partial_text:
349
        wait_for(lambda _: css_html(css_selector, index=index), timeout=8)
350

351
    actual_text = css_html(css_selector, index=index)
352 353 354 355 356

    return partial_text in actual_text


@world.absorb
357
def css_has_value(css_selector, value, index=0):
358 359 360 361 362 363 364
    """
    Return a boolean indicating whether the element with
    `css_selector` has the specified `value`.

    If there are multiple elements matching the css selector,
    use `index` to indicate which one.
    """
365 366 367
    # If we're expecting a non-empty string, give the page
    # a chance to fill in values
    if value:
368
        wait_for(lambda _: css_value(css_selector, index=index))
369

370
    return css_value(css_selector, index=index) == value
371

372

373
@world.absorb
374
def wait_for(func, timeout=5, timeout_msg=None):
375
    """
376 377
    Calls the method provided with the driver as an argument until the
    return value is not False.
378
    Throws an error if the WebDriverWait timeout clock expires.
379
    Otherwise this method will return None.
380
    """
381
    msg = timeout_msg or "Timed out after {} seconds.".format(timeout)
382
    try:
383 384 385 386
        WebDriverWait(
            driver=world.browser.driver,
            timeout=timeout,
            ignored_exceptions=(StaleElementReferenceException)
387
        ).until(func)
388
    except TimeoutException:
389
        raise TimeoutException(msg)
390

Will Daly committed
391

392
@world.absorb
393
def wait_for_present(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
394
    """
395
    Wait for the element to be present in the DOM.
396
    """
397 398 399 400 401 402 403 404
    wait_for(
        func=lambda _: EC.presence_of_element_located((By.CSS_SELECTOR, css_selector,)),
        timeout=timeout,
        timeout_msg="Timed out waiting for {} to be present.".format(css_selector)
    )


@world.absorb
405
def wait_for_visible(css_selector, index=0, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
406 407 408 409 410 411 412 413
    """
    Wait for the element to be visible in the DOM.
    """
    wait_for(
        func=lambda _: css_visible(css_selector, index),
        timeout=timeout,
        timeout_msg="Timed out waiting for {} to be visible.".format(css_selector)
    )
414 415


416
@world.absorb
417
def wait_for_invisible(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
418
    """
419
    Wait for the element to be either invisible or not present on the DOM.
420
    """
421 422 423 424 425
    wait_for(
        func=lambda _: EC.invisibility_of_element_located((By.CSS_SELECTOR, css_selector,)),
        timeout=timeout,
        timeout_msg="Timed out waiting for {} to be invisible.".format(css_selector)
    )
426

Will Daly committed
427

428
@world.absorb
429
def wait_for_clickable(css_selector, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
430
    """
431
    Wait for the element to be present and clickable.
432
    """
433 434 435 436 437
    wait_for(
        func=lambda _: EC.element_to_be_clickable((By.CSS_SELECTOR, css_selector,)),
        timeout=timeout,
        timeout_msg="Timed out waiting for {} to be clickable.".format(css_selector)
    )
438 439


440
@world.absorb
441
def css_find(css, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
442
    """
443 444 445 446 447 448 449 450 451 452
    Wait for the element(s) as defined by css locator
    to be present.

    This method will return a WebDriverElement.
    """
    wait_for_present(css_selector=css, timeout=wait_time)
    return world.browser.find_by_css(css)


@world.absorb
453
def css_click(css_selector, index=0, wait_time=GLOBAL_WAIT_FOR_TIMEOUT, dismiss_alert=False):
454 455 456 457 458
    """
    Perform a click on a CSS selector, first waiting for the element
    to be present and clickable.

    This method will return True if the click worked.
459 460

    If `dismiss_alert` is true, dismiss any alerts that appear.
461 462
    """
    wait_for_clickable(css_selector, timeout=wait_time)
463
    wait_for_visible(css_selector, index=index, timeout=wait_time)
464
    assert_true(
465
        css_visible(css_selector, index=index),
466 467
        msg="Element {}[{}] is present but not visible".format(css_selector, index)
    )
468

469 470 471 472 473 474 475 476 477 478
    retry_on_exception(lambda: css_find(css_selector)[index].click())

    # Dismiss any alerts that occur.
    # We need to do this before calling `wait_for_js_to_load()`
    # to avoid getting an unexpected alert exception
    if dismiss_alert:
        world.browser.get_alert().accept()

    wait_for_js_to_load()
    return True
479 480 481


@world.absorb
482
def css_check(css_selector, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
483 484 485 486 487
    """
    Checks a check box based on a CSS selector, first waiting for the element
    to be present and clickable. This is just a wrapper for calling "click"
    because that's how selenium interacts with check boxes and radio buttons.

488
    Then for synchronization purposes, wait for the element to be checked.
489 490
    This method will return True if the check worked.
    """
491 492 493
    css_click(css_selector=css_selector, wait_time=wait_time)
    wait_for(lambda _: css_find(css_selector).selected)
    return True
494 495 496


@world.absorb
497
def select_option(name, value, wait_time=GLOBAL_WAIT_FOR_TIMEOUT):
498 499
    '''
    A method to select an option
500
    Then for synchronization purposes, wait for the option to be selected.
501 502 503 504 505 506
    This method will return True if the selection worked.
    '''
    select_css = "select[name='{}']".format(name)
    option_css = "option[value='{}']".format(value)

    css_selector = "{} {}".format(select_css, option_css)
507 508 509
    css_click(css_selector=css_selector, wait_time=wait_time)
    wait_for(lambda _: css_has_value(select_css, value))
    return True
510 511 512


@world.absorb
513 514 515 516
def id_click(elem_id):
    """
    Perform a click on an element as specified by its id
    """
517
    css_click('#{}'.format(elem_id))
518 519 520


@world.absorb
521
def css_fill(css_selector, text, index=0):
522 523 524 525 526
    """
    Set the value of the element to the specified text.
    Note that this will replace the current value completely.
    Then for synchronization purposes, wait for the value on the page.
    """
527
    wait_for_visible(css_selector, index=index)
528
    retry_on_exception(lambda: css_find(css_selector)[index].fill(text))
529 530
    wait_for(lambda _: css_has_value(css_selector, text, index=index))
    return True
531

532

533
@world.absorb
534
def click_link(partial_text, index=0):
535
    retry_on_exception(lambda: world.browser.find_link_by_partial_text(partial_text)[index].click())
536
    wait_for_js_to_load()
537

538

539
@world.absorb
540 541 542 543 544 545 546 547
def click_button(data_attr, index=0):
    xpath = '//button[text()="{button_text}"]'.format(
        button_text=data_attr
    )
    world.browser.find_by_xpath(xpath)[index].click()


@world.absorb
548 549 550 551 552
def click_link_by_text(text, index=0):
    retry_on_exception(lambda: world.browser.find_link_by_text(text)[index].click())


@world.absorb
553
def css_text(css_selector, index=0, timeout=GLOBAL_WAIT_FOR_TIMEOUT):
554
    # Wait for the css selector to appear
555
    if is_css_present(css_selector):
556
        return retry_on_exception(lambda: css_find(css_selector, wait_time=timeout)[index].text)
557 558
    else:
        return ""
559

560

561
@world.absorb
562
def css_value(css_selector, index=0):
563
    # Wait for the css selector to appear
564
    if is_css_present(css_selector):
565
        return retry_on_exception(lambda: css_find(css_selector)[index].value)
566 567 568 569 570
    else:
        return ""


@world.absorb
571
def css_html(css_selector, index=0):
572
    """
573
    Returns the HTML of a css_selector
574 575
    """
    assert is_css_present(css_selector)
576
    return retry_on_exception(lambda: css_find(css_selector)[index].html)
577 578 579


@world.absorb
580
def css_has_class(css_selector, class_name, index=0):
581
    return retry_on_exception(lambda: css_find(css_selector)[index].has_class(class_name))
582 583 584


@world.absorb
585
def css_visible(css_selector, index=0):
586
    assert is_css_present(css_selector)
587
    return retry_on_exception(lambda: css_find(css_selector)[index].visible)
588

589

590
@world.absorb
591
def dialogs_closed():
cahrens committed
592
    def are_dialogs_closed(_driver):
593 594 595 596 597 598 599 600 601
        '''
        Return True when no modal dialogs are visible
        '''
        return not css_visible('.modal')
    wait_for(are_dialogs_closed)
    return not css_visible('.modal')


@world.absorb
602
def save_the_html(path='/tmp'):
cahrens committed
603
    url = world.browser.url
604
    html = world.browser.html.encode('ascii', 'ignore')
605 606 607
    filename = "{path}/{name}.html".format(path=path, name=quote_plus(url))
    with open(filename, "w") as f:
        f.write(html)
David Baumgold committed
608

David Baumgold committed
609

610 611
@world.absorb
def click_course_content():
612
    world.wait_for_js_to_load()
613
    course_content_css = 'li.nav-course-courseware'
614
    css_click(course_content_css)
David Baumgold committed
615

David Baumgold committed
616

David Baumgold committed
617 618
@world.absorb
def click_course_settings():
619
    world.wait_for_js_to_load()
David Baumgold committed
620
    course_settings_css = 'li.nav-course-settings'
621
    css_click(course_settings_css)
David Baumgold committed
622 623 624 625


@world.absorb
def click_tools():
626
    world.wait_for_js_to_load()
David Baumgold committed
627
    tools_css = 'li.nav-course-tools'
628
    css_click(tools_css)
629 630 631 632 633


@world.absorb
def is_mac():
    return platform.mac_ver()[0] is not ''
634

635

636 637 638 639
@world.absorb
def is_firefox():
    return world.browser.driver_name is 'Firefox'

640

641 642 643
@world.absorb
def trigger_event(css_selector, event='change', index=0):
    world.browser.execute_script("$('{}:eq({})').trigger('{}')".format(css_selector, index, event))
644

645

646
@world.absorb
647
def retry_on_exception(func, max_attempts=5, ignored_exceptions=(StaleElementReferenceException, InvalidElementStateException)):
648 649 650 651 652 653 654
    """
    Retry the interaction, ignoring the passed exceptions.
    By default ignore StaleElementReferenceException, which happens often in our application
    when the DOM is being manipulated by client side JS.
    Note that ignored_exceptions is passed directly to the except block, and as such can be
    either a single exception or multiple exceptions as a parenthesized tuple.
    """
655 656
    attempt = 0
    while attempt < max_attempts:
657 658
        try:
            return func()
659
        except ignored_exceptions:
660 661
            world.wait(1)
            attempt += 1
662

663
    assert_true(attempt < max_attempts, 'Ran out of attempts to execute {}'.format(func))
664 665 666 667 668 669 670 671 672 673 674 675 676 677


@world.absorb
def disable_jquery_animations():
    """
    Disable JQuery animations on the page.  Any state changes
    will occur immediately to the final state.
    """

    # Ensure that jquery is loaded
    world.wait_for_js_to_load()

    # Disable jQuery animations
    world.browser.execute_script("jQuery.fx.off = true;")