AJAX callback support for Behat with Mink (and jQuery)

SilverStripe relies heavily on AJAX interactions with feedback times varying from 200ms to multiple seconds. In order to make testing both stable and fast we needed a more sophisticated system than fixed timeouts.

This has already been implemented for the old spec-by-example module.

As you can see in Developing Web Applications with Behat and Mink cookbook there is a separate step added that tells the scenario to wait for the specific event to occur:

And I wait for the suggestion box to appear

Then the following step is defined:

/**
 * @Then /^I wait for the suggestion box to appear$/
 */
public function iWaitForTheSuggestionBoxToAppear()
{
    $this->getSession()->wait(5000, "$('.suggestions-results').children().length > 0");
}

Good thing about the wait() function is that it continues after the condition provided as a second argument is true, so it doesn’t have to wait the entire time it has. But the problem with this solution is that this separate step is not really an active step that a non-technical user would spell out, and more importantly, it doesn’t add any value to the test descriptiveness as such.

Next you can find the complete code implemented by me that suppress the need of an additional step. It assumes that you use jQuery, but this can be changed easily. The code is then followed by a short explanation.

<?php
 
/**
 * Features context.
 */
class FeatureContext extends MinkContext
{
 
    // ...
 
    /**
     * @BeforeStep @javascript
     */
    public function beforeStep($event)
    {
        $text = $event->getStep()->getText();
        if (preg_match('/(follow|press|click|submit)/i', $text)) {
            $this->ajaxClickHandler_before();
        }
    }
 
    /**
     * @AfterStep @javascript
     */
    public function afterStep($event)
    {
        $text = $event->getStep()->getText();
        if (preg_match('/(follow|press|click|submit)/i', $text)) {
            $this->ajaxClickHandler_after();
        }
    }
 
    /**
     * Hook into jQuery ajaxStart and ajaxComplete events.
     * Prepare __ajaxStatus() functions and attach them to these events.
     * Event handlers are removed after one run.
     */
    public function ajaxClickHandler_before()
    {
        $javascript = <<<JS
window.jQuery(document).one('ajaxStart.ss.test', function(){
    window.__ajaxStatus = function() {
        return 'waiting';
    };
});
window.jQuery(document).one('ajaxComplete.ss.test', function(){
    window.__ajaxStatus = function() {
        return 'no ajax';
    };
});
JS;
        $this->getSession()->executeScript($javascript);
    }
 
    /**
     * Wait for the __ajaxStatus()to return anything but 'waiting'.
     * Don't wait longer than 5 seconds.
     */
    public function ajaxClickHandler_after()
    {
        $this->getSession()->wait(5000,
            "(typeof window.__ajaxStatus !== 'undefined' ?
                window.__ajaxStatus() : 'no ajax') !== 'waiting'"
        );
    }
 
    // ...
 
}

AJAX click handler function is attached before every step with follow, press, click or submit text in its definition, then it is removed after the step execution has finished.

JavaScript code is executed before the step runs to hook custom __ajaxStatus() function into jQuery ajaxStart and ajaxComplete events. This function allows us to check whether there was an AJAX call and, if it is the case, whether it has already finished. By hooking to ajaxStart event, we ensure that our code is run before any AJAX call is made.

If there is an AJAX event still pending, __ajaxStatus() will return ‘waiting’. Otherwise it should return ‘no ajax’ (this is why we listen to the ajaxComplete event).

Manual usage

In case you don’t want to fire these handlers for every matched step, you may get rid of @BeforeStep and @AfterStep hooks and just call AJAX click handlers by hand.

/**
 * @Given /^I press "([^"]*)" button$/
 */
public function stepIPressButton($button)
{
        $page = $this->getSession()->getPage();
 
        $button_selector = array('link_or_button', "'$button'");
        $button_element = $page->find('named', $button_selector);
 
        if (null === $button_element) {
                throw new Exception("'$button' button not found");
        }
 
        $this->ajaxClickHandler_before();
        $button_element->click();
        $this->ajaxClickHandler_after();
}

SilverStripe virtual redirects

In SilverStripe, things get a little more complicated.

For example, when you press the “Add new” page button on the Pages admin screen, two things happen:

  1. An AJAX request fires off the POST data and comes back with an empty response along with X-ControllerURL response header.
  2. The CMS JS logic picks up this head, and makes another ajax request for this new URL. Its not implemented as a HTTP 302 redirect (or direct HTML response) in order to update the URL correctly through History.pushState().

This was a problem for the above solution, but is not anymore thanks to the following change to the code:

window.jQuery(document).one('ajaxComplete.ss.test', function(e, jqXHR){
    if (null === jqXHR.getResponseHeader('X-ControllerURL')) {
        window.__ajaxStatus = function() {
            return 'no ajax';
        };
    }
});

jQuery ajaxComplete event now checks whether the X-ControllerURL response header exists. If it is, the __ajaxStatus() function output is not changed.

Possible drawbacks

Looks like it may be too difficult to solve it in a generic fashion, given that not all X-ControllerURL actually lead to new ajax requests (they have to be different)1 2.

Notes:

  1. Confirmation needed.
  2. Everything seems to work just fine.

Thanks to Ingo for great github issues descriptions as they made writing this one a lot easier.

Comments

  1. MyKiwi on

    Thanks !

    Reply

Leave a Reply