New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

In-browser file download support #949

Open
kamituel opened this Issue Nov 22, 2017 · 5 comments

Comments

@kamituel
Copy link

kamituel commented Nov 22, 2017

Is this a Feature or Bug?

Feature.

Current behavior:

In Electron, when an anchor is clicked that downloads a file from the server, a "Save" dialog is shown thus preventing the rest of the test case from completing / succeeding. No dialog is shown in Chrome, at least currently, but this appears to be just because that's Chrome's default behaviour.

Furthermore, there is no way of specifying the desired location of file downloads.

Desired behavior:

No "Save" dialog should be ever shown.

Not as crucial (i.e. with rather easy workaround in the CI), but we should be able to configure browser (both Chrome and Electron, and any browsers supported in the future) with the location where files should be downloaded.

This should work both for files downloaded from the server (with Content-Disposition HTTP header), as well as for files downloaded using in-browser Blob + dynamically generated anchor approach.

How to reproduce:

Given any site that contains an <a href="">...</a> pointing to a file served with Content-Disposition header, use a test script similar to the one posted below:

Test code:

describe('Lorem', function() {
  it('Ipsum', function() {
    cy.visit("https://localhost")
      .get("a.download-link").click()
      // This blocks in Electron because of the "Save" popup.
      .readFile("/Users/kls/Downloads/file.txt")
      .then(function() {
         // Do something with the file.
      });
  })
})

Additional Info (images, stack traces, etc)

This functionality is needed in cases where it is not possible to verify validity of the download using other methods suggested in the FAQ:

  • When downloaded file is dynamically generated on the server based on a data that browser sends over to it, in case when this data is sensitive and cannot be sent over in the file URL. Imagine a file path such as https://bank/transaction-history/34964483209802/pdf, where 34964483209802 is a credit card number. This is unacceptable from the security / compliance perspective, because HTTP URL's are oftentimes logged in proxies or web servers. One solution is to send sensitive data over secure comms, i.e. WebSockets, just after user clicks the anchor. But this makes it impossible to just look at URL and issue cy.request().
  • When downloaded file is dynamically generated in the browser using a Blob + transient <a> with data URI technique, in which case no request is made to the server at all.
@jennifer-shehane

This comment has been minimized.

Copy link
Member

jennifer-shehane commented Nov 27, 2017

Related: #433 #311

@kutlaykural

This comment has been minimized.

Copy link

kutlaykural commented Jun 19, 2018

@kamituel did you solve this problem?
can you share your solution?

@kamituel

This comment has been minimized.

Copy link
Author

kamituel commented Jun 26, 2018

@kutlaykural Sorry for the late response, I was on vacation.

Yes, I did solve this problem in a sufficient way, at least for our needs.

1. Files that depend on more than URL to generate

For the files which are being generated on the backend based on a parameters supplied by the frontend via means other than URL itself (e.g. over an out-of-band WebSockets connection), this approach works:

<a id="download-file" href="/file?download-id=ABCDEFGH">Download</a>

<script>
  document.getElementById('download-file').onclick = function(event) {
    // In test, prevent the browser from actually downloading the file.
    if (window.Cypress) {
      event.preventDefault();
    }

    // But let it do all other things, most importantly talk to the backend
    // out-of-band to configure the download.
    setupFileOverWebSockets('ABCDEFGH');
  }
</script>

Then, in the test, I would do:

cy.get('a').click().then((anchor) => {
  const url = anchor.prop('href');
  // cy.request() will work, because web app did talk to the backend out-of-band.
  cy.request(url).then(/* do something, e.g. assert */);
});

2. Files generated in the browser

This technique involves using a temporary anchor element, which has a href set to a value obtained from URL.createObjectURL(). I think the following should work:

    <button id="test">Download file!</button>
    <script>
      const button = document.getElementById('test');
      button.addEventListener('click', () => {
        const blob = new Blob(['lorem ipsum dolor sit amet']);
        const anchor = document.createElement('a');
        const url = URL.createObjectURL(blob);

        anchor.download = 'test.txt';
        anchor.href = url;
        document.body.appendChild(anchor);

        if (window.Cypress) {
          // Do not attempt to actually download the file in test.
          // Just leave the anchor in there. Ensure your code doesn't
          // automatically remove it either.
          return;
        }

        anchor.click();
      });
    </script>

And the test:

  it.only('asserts in-browser generated file', () => {
    // Click the button to start downloading the file. The web app
    // will detect it's running within Cypress, and will create 
    // a temporary anchor, will set a correct href on it, but it won't
    // click it and won't attempt to remove it.
    cy.get('#test').click();

    // Obtain a reference to that temporary anchor.
    cy.get('a[download]')
      .then((anchor) => (
        new Cypress.Promise((resolve, reject) => {
          // Use XHR to get the blob that corresponds to the object URL.
          const xhr = new XMLHttpRequest();
          xhr.open('GET', anchor.prop('href'), true);
          xhr.responseType = 'blob';

          // Once loaded, use FileReader to get the string back from the blob.
          xhr.onload = () => {
            if (xhr.status === 200) {
              const blob = xhr.response;
              const reader = new FileReader();
              reader.onload = () => {
                // Once we have a string, resolve the promise to let
                // the Cypress chain continue, e.g. to assert on the result.
                resolve(reader.result);
              };
              reader.readAsText(blob);
            }
          };
          xhr.send();
        })
      ))
      // Now the regular Cypress assertions should work.
      .should('equal', 'lorem ipsum dolor sit amet');
  })

In summary, I use the first approach in our test already and it works well. I don't test the in-browser generated files yet using Cypress (we have legacy Selenium suite for that), but I quickly tested the approach I described here and it seems to be working.

I'd love if Cypress would let us just download a file and assert on its contents, but in the meantime those approaches provide enough coverage for most, if not all, needs.

@oimou oimou referenced this issue Aug 8, 2018

Merged

e2e tests for the reports page #33

4 of 4 tasks complete
@Shubha-Alvares

This comment has been minimized.

Copy link

Shubha-Alvares commented Jan 7, 2019

Download <script> document.getElementById('download-file').onclick = function(event) { // In test, prevent the browser from actually downloading the file. if (window.Cypress) { event.preventDefault(); } // But let it do all other things, most importantly talk to the backend // out-of-band to configure the download. setupFileOverWebSockets('ABCDEFGH'); } </script>

@kamituel
Can you please tell me where should the above piece of code be written?

I tried the in 'test portion' of the code in the cypress test, and yet I still saw the 'save pop up'

@kamituel

This comment has been minimized.

Copy link
Author

kamituel commented Jan 8, 2019

@Shubha-Alvares The code you posted:

// '#download-file' here is an anchor that user clicks to download a file.
// It has to be the correct DOM element in order for the code to cancel
// the correct 'event' object.
document.getElementById('download-file').onclick = function(event) {

  // ...

  // In test, prevent the browser from actually downloading the file.
  if (window.Cypress) {
    event.preventDefault();
  }

  // ...

});

If you're seeing the popup, it might mean that:

  • you added the if (window.Cypress) { ... } guard in the incorrect event handler.
  • the event you invoked event.preventDefault() on is not the correct event object.
  • you're running your web app outside of Cypress (and hence window.Cypress is unset).

Hard to be more definitive not seeing your code. But in general, this approach should work as long as you manage to cancel .preventDefault() the correct event. In fact, I'm using this approach in our production app for months now with no issues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment