Cypress: Turbolinks issues when clicking links.

Created on 13 Jun 2018  路  13Comments  路  Source: cypress-io/cypress

Current behavior:

I use Turbolinks in my application. If I click on two links after each other, where the link is present on both pages like a "Next" link, I get "xhr aborted" and it fails.
It seems as cypress clicks the same link twice (not waiting for the xhr to finish)...
If I add a cy.wait(0) between the clicks it seems to work. But it's a bit of a hack :)

Desired behavior:

It should wait for the next page and click the correct link.

Steps to reproduce:

Create a page with Turbolinks which links to another page (like a "Next" link in a pagination).
On this other page, add the same link to a "Next" page.

Now, run a Cypress test where you click on the "Next" link twice. This will fail (might be a bit random).

Adding cy.wait(0) between the clicks seem to solve the problem for some reason.

Versions

Cypress: 3.0.1
Macos 10.13.4

question

Most helpful comment

For anyone still having issues with turbolinks and Cypress, I solved mine by using the following implementation of the above suggestions (goes into support/commands.js):

// Overwrite the Cypress .click() function
Cypress.Commands.overwrite("click", (originalFn, subject, ...args) => {
    var lastArg = args.length ? (args[args.length - 1]) : {}
    // Check if turbolinks could be active for this click event - could be improved
    if ((typeof lastArg !== 'object' || !lastArg['noWaiting']) && subject[0].tagName === 'A' && subject[0].getAttribute('href')) {
        // Wait for turbolinks to finish loading the page before proceeding with the next Cypress instructions.
        // First, get the document
        cy.document({ log: false }).then($document => {
            Cypress.log({ $el: subject, name: 'click', displayName: 'click', message: 'click and wait for page to load', consoleProps: () => ({ subject: subject })})
            // Make Cypress wait for this promise which waits for the turbolinks:load event
            return new Cypress.Promise(resolve => {
                // Once we receive the event,
                const onTurbolinksLoad = () => {
                    // clean up
                    $document.removeEventListener('turbolinks:load', onTurbolinksLoad)
                    // signal to Cypress that we're done
                    resolve()
                }
                // Add our logic as event listener
                $document.addEventListener('turbolinks:load', onTurbolinksLoad)
                // Finally, we are ready to perform the actual click operation
                originalFn(subject, ...args)
            })
        })
    } else {
        // Not a normal click on an <a href> tag, turbolinks will not interfere here
        return originalFn(subject, ...args)
    }
})

Alternatively, you can disable turbolinks while running in Cypress, by canceling each turbolinks:click event (this goes into support/index.js):

Cypress.on('window:load', $window => {
    $window.document.addEventListener('turbolinks:click', event => {
        event.preventDefault()
    })
})

All 13 comments

@linuus is the failure you mentioned caused by the "xhr aborted"? Is cypress failing because of an unexpected error in your application?
Could you minimally reproduce this in a small repo? It sounds like "xhr aborted" is correct behavior because you cancel the loading of the "next" page in favor of the "next-next" page.

Well, the test doesn't actually fail because of the xhr aborted error. But, we don't go to the next page before the other assertions are executed.

Small example:

// We are on Page 1
cy.get("next-link").click(); // This loads the next page via AJAX using Turbolinks
cy.get("next-link").click(); // This clicks the same element as above, it doesn't wait until the AJAX request is completed. Turbolinks (I think) aborts the first AJAX request.

This should also illustrate the problem. Let's say we have this on our pages:

<h1 data-cy="page-title">Page 1</h2>

Where we have "Page 1", "Page 2" etc for the different pages.

cy.get("next-link").click(); // This loads the next page via AJAX using Turbolinks
cy.get("[data-cy=page-title]").contains("Page 2");

The second line above will fail, because it doesn't wait for the next page to load via Turbolinks and since there is an element matching the selector on the current page it uses that instead.

I'll try to see if I can put together some small repo for the issue.

Yes, this seems like correct Cypress behavior.
Think about how you would test the functionality of the XHR getting aborted- you would want to click "next" twice without waiting- and that's what Cypress does. We can't assume you want to wait for every XHR, because then you can't test a multitude of cases that the user could run into, like race conditions. The same thing goes for navigating to the page while still being able to click elements on the previous page.
The solution would be to alias the route and then cy.wait('@turbolink') to make Cypress wait for the page load.

Yeah I guess that makes sense :)

Thanks!

@Bkucera I was wondering about this as well. With Turbolinks, or any other PJAX solution, like the one used here on GitHub for instance, tests would get littered with cy.wait('@turbolink') calls, basically every time a link is clicked in a test. Also you would have to alias every route of the application, except "normal" AJAX calls. Is there any way around this?

@jensljungblad if you could find a glob or regex that exclusively matches turbolinks, then you can simply have one cy.route alias for all your navigation links.
Perhaps cy.route(/\.html$/).as('turbolink') then all xhrs ending with .html would be aliased.
It is then possible to write a custom command clickLink that will bake in a cy.wait, so that you can do the following:

cy.get('.mylink').clickLink()
// Cypress will wait for xhr before moving on

Alternatively, if the url is updated after page navigation, you can just wait for the url to update.

cy.get('.mylink').click()
cy.url().should('contain', 'nextpage')
// Continue after url updates

Does the alternative solution work if the url is updated with the History api? I _think_ this updates the url immediately. I can鈥檛 verify that at the moment though (on mobile).

Unfortunately not. The URL is updated before the page has finished re-rendering (or Cypress has noticed the re-render at least).

Turbolinks sets a Turbolinks-Referrer header containing the URL. Is it possible in Cypress to look at all XHR requests, check for that header and make Cypress wait for those URLs somehow?

For turbolinks specifically, there are a number of Events on document you can listen to https://github.com/turbolinks/turbolinks#full-list-of-events

Turbolinks here isn't the problem - it is merely another implementation of SPA's that you'd find in React, Angular, Vue, etc.

The solution to dealing with this is not different than any other SPA.

You have a situation where an action causes a side effect.

Clicking the link does something. You can either wait for the thing that causes the side effect (which would be the XHR) or you can test for the side effect itself (the reaction) of the event happening.

In either case, you either wait for the turboLinks XHR as @Bkucera suggested, or you add assertions which act as guards on the side effect of what the turboLinks does.

For instance, turbolinks modifies the DOM right? So add an assertion about the DOM after you click the link. That will block / guard Cypress until this condition has been met.

You could also assert on the URL. Yes, I understand that it gets updated immediately before the side effect has taken place, so that may not be the best strategy in this case, but is overall a common and good one.

What else changes? Assert on whatever is the difference. That way Cypress does not proceed until you know that condition has been reached.

Also as @Bkucera suggested - you have native access to everything from Cypress itself. You can tap into and listen to any events on any object. You have to make your app testable, and you need to create "seams" that it can tap into so it understands how to automatically know when to proceed. Perhaps you could keep a global object that holds the current turboLink that only gets set when the turbolink has finished rendering. In essence this could act as like a "secondary URL" but is populated on completion as opposed to request.

If this were the case you could expand Cypress or overwrite the click command (or whatever else) and if you're clicking a link that is a turbolink, to automatically wait until the state of this thing matches the href property. Therefore you'd get automatic waiting without needing to assert on anything that is the side effect of the turbolink.

At the end of the day it's up to you (the application creator) to create seams that enable automated testing to tap into. Most of the time you can get by with what's natively built into Cypress. If you'd like a deeper integration, you need to expose some state that guarantees your application has stabilized and is ready to proceed.

Thanks for the comprehensive answer! Everything you write makes sense. I agree this is an issue with SPAs, not Turbolinks or Cypress.

I think probably I'm just not used to integration testing SPAs. The thing that strikes me is that the async nature of SPAs is the thing that doesn't map perfectly to integration testing, since actual users wouldn't have these problems. An actual user would wait for the page to load (and know that the page hasn't loaded yet if the old page is still showing) before "asserting" that the page contains what it should. It seems to me that the equivalent test in a traditional server rendered app would be shorter and simpler.

Regarding your suggested approaches, I'm currently using the "assertions which act as guards on the side effect" strategy, but it's brittle, since if you forget the add the guard to the assertion (currently I'm using a page-level selector as the guard, and scope all other selectors from that) the tests can fail randomly, because sometimes the page renders in time, and sometimes not.

Overriding click to check for links using Turbolinks sounds like an interesting approach, I'll try that!

For anyone still having issues with turbolinks and Cypress, I solved mine by using the following implementation of the above suggestions (goes into support/commands.js):

// Overwrite the Cypress .click() function
Cypress.Commands.overwrite("click", (originalFn, subject, ...args) => {
    var lastArg = args.length ? (args[args.length - 1]) : {}
    // Check if turbolinks could be active for this click event - could be improved
    if ((typeof lastArg !== 'object' || !lastArg['noWaiting']) && subject[0].tagName === 'A' && subject[0].getAttribute('href')) {
        // Wait for turbolinks to finish loading the page before proceeding with the next Cypress instructions.
        // First, get the document
        cy.document({ log: false }).then($document => {
            Cypress.log({ $el: subject, name: 'click', displayName: 'click', message: 'click and wait for page to load', consoleProps: () => ({ subject: subject })})
            // Make Cypress wait for this promise which waits for the turbolinks:load event
            return new Cypress.Promise(resolve => {
                // Once we receive the event,
                const onTurbolinksLoad = () => {
                    // clean up
                    $document.removeEventListener('turbolinks:load', onTurbolinksLoad)
                    // signal to Cypress that we're done
                    resolve()
                }
                // Add our logic as event listener
                $document.addEventListener('turbolinks:load', onTurbolinksLoad)
                // Finally, we are ready to perform the actual click operation
                originalFn(subject, ...args)
            })
        })
    } else {
        // Not a normal click on an <a href> tag, turbolinks will not interfere here
        return originalFn(subject, ...args)
    }
})

Alternatively, you can disable turbolinks while running in Cypress, by canceling each turbolinks:click event (this goes into support/index.js):

Cypress.on('window:load', $window => {
    $window.document.addEventListener('turbolinks:click', event => {
        event.preventDefault()
    })
})

@carlobeltrame Thanks for the summary here. Definitely helped me out. 馃檹

Was this page helpful?
0 / 5 - 0 ratings