Skip to content
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

Closed
kamituel opened this issue Nov 22, 2017 · 73 comments · Fixed by #14465
Closed

In-browser file download support #949

kamituel opened this issue Nov 22, 2017 · 73 comments · Fixed by #14465
Labels
topic: native events type: feature New feature that does not currently exist

Comments

@kamituel
Copy link

kamituel commented Nov 22, 2017

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:

  1. No "Save" dialog should be ever shown.
  2. 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
Copy link
Member

jennifer-shehane commented Nov 27, 2017

Related: #433 #311 #4675

@kutlaykural
Copy link

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

@kamituel
Copy link
Author

@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.

@jennifer-shehane jennifer-shehane added the type: feature New feature that does not currently exist label Nov 2, 2018
@Shubha-Alvares
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
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.

@sarah-young
Copy link

sarah-young commented Jul 12, 2019

Just a note on solution 1 by @kamituel:

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 */);
});

...didn't work for me, BUT using .attr() instead of .props() did:

cy.get('a').click().then((anchor) => {
  const url = anchor.attr('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 */);
});

Thank you @kamituel! You solved a blocker for me.

@natejcho
Copy link

natejcho commented Aug 6, 2019

I apologize if this is obvious but based on @kamituel solution above, are we able to change the save location when cypress downloads a file?

@kamituel
Copy link
Author

@natejcho No, because at no point the browser actually downloads the file using the native "download manager". I explicitly prevent that in test (when window.Cypress is set).

That's in fact the main reason the two approaches I described work. I prevent the browser from actually downloading the file, and instead I download the file, store its contents in a variable, and assert that (in the test code).

@natejcho
Copy link

natejcho commented Aug 19, 2019

@kamituel I realized this after playing around with your code more.

But I solved my unique issue in which I wanted cypress to download a file and place it in a specific location.

I used your solution so the test could receive a base64 string. Then created a plugin to handle the encoding and writing the file to a directory. Cypress plugins run in the node and I am able to utilize the fs library and write files at the project root level.

See [https://github.com//issues/2029] for writing the plugin

@Prashant-Kan
Copy link

@kamituel I realized this after playing around with your code more.

But I solved my unique issue in which I wanted cypress to download a file and place it in a specific location.

I used your solution so the test could receive a base64 string. Then created a plugin to handle the encoding and writing the file to a directory. Cypress plugins run in the node and I am able to utilize the fs library and write files at the project root level.

See [https://github.com//issues/2029] for writing the plugin

Hello, natejcho

I want to implement the same way. I want to get the response of the file thats require to download.
it can be word/pdf or excel file.
Want to validate the downloaded content. Thought the same way as you have written, but its not working. Could you please describe more on this or share a prototype of your code.(way of implementation).

waiting for your reply.

Thanks

@Prashant-Kan
Copy link

Prashant-Kan commented Sep 12, 2019

I have used cy.request to get the response of the file. Which I am getting successfully. Now, How to pass the response to the nodejs plugin is the problem. I have tried the below code but its not working.

cy.request(href).then((response) => {
  expect(response.status).to.equal(200);
  expect(response.body).not.to.null;
  cy.exec("node cypress/support/Core/Parser/PDFParser.js " + response.body, {failOnNonZeroExit: false}).then((result: any) => {
             console.log(result);
              });
});

@flotwig
Copy link
Contributor

flotwig commented Oct 9, 2019

For a future solution to this problem in Cypress itself: Once #4628 is merged, we'll have access to the Chrome DevTools Protocol, which has these 2 functions:

Cypress could set the downloadBehavior to deny (so any request to download a file isn't actually downloaded), and then listen on downloadWillBegin to catch any requests for file downloads, allowing them to be captured & tested inside of Cypress.

Maybe the API could look something like this:

Cypress.on('download:will:begin', (url) => {
	// cy.request(url) could be used here to assert on contents?
	// potential problem: we don't have access to the request body or headers
	// used to make this download happen
})

@natejcho
Copy link

natejcho commented Oct 25, 2019

To add on top of this. I'd like to make a feature request

getDownload or downloadToRoot. The naming convention isn't important to me, but it would save a download to the root of cypress project. I commented above how I've already achieved this in my application. I would be happy to open an MR for it as well.

This issue has lots of activity and been mentioned in many different issues. Which I think indicates a good need for it

@Gurgen
Copy link

Gurgen commented Nov 8, 2019

For a future solution to this problem in Cypress itself: Once #4628 is merged, we'll have access to the Chrome DevTools Protocol, which has these 2 functions:

Cypress could set the downloadBehavior to deny (so any request to download a file isn't actually downloaded), and then listen on downloadWillBegin to catch any requests for file downloads, allowing them to be captured & tested inside of Cypress.

Maybe the API could look something like this:

Cypress.on('download:will:begin', (url) => {
	// cy.request(url) could be used here to assert on contents?
	// potential problem: we don't have access to the request body or headers
	// used to make this download happen
})

Hello.
Any news from this feature?

@agugut-nexgen
Copy link

Hello Any news to how to manage download in cypress?
Folder destination and popup?

@Sree2412
Copy link

Sree2412 commented Aug 27, 2020

Ran the test in chrome and ended up with this error "cy.task('allowDownloads') failed with the following error: The 'task' event has not been registered in the plugins file. You must register it before using cy.task()" please advise

@hhaidar
Copy link

hhaidar commented Aug 27, 2020

@Sree2412 read the error, you need to add the allowDownloads task in plugins file. Did you add all the code from my example?

@Sree2412
Copy link

Sree2412 commented Aug 27, 2020

I am very new to cypress, just started with it, going through cypress documentation changed my plugin file like this.. but still getting error.Please guide, yes I have added all the above mentioned code .

this is how my plugin file looks now

//// <reference types="cypress" /> 
// *********************************************************** 
// reflect-metadata is required since PortsFileService and DebugLogger has decorator injectable



const ntlmAuth = require("cypress-ntlm-auth/dist/plugin");
const CDP = require('chrome-remote-interface')
const debug = require('debug')('cypress:server:protocol')


 

 module.exports =  (on, config) => {
  config = ntlmAuth.initNtlmAuth(config);
  return config;

  function ensureRdpPort (args) {
    const existing = args.find((arg) => arg.slice(0, 23) === '--remote-debugging-port')
  
    if (existing) {
      return Number(existing.split('=')[1])
    }
  
    const port = 40000 + Math.round(Math.random() * 25000)
  
    args.push(`--remote-debugging-port=${port}`)
  
    return port
   }
  
   let port = 0
   let client = null

  on('before:browser:launch', (browser, launchOptionsOrArgs) => {
    debug('browser launch args or options %o', launchOptionsOrArgs)
    const args = Array.isArray(launchOptionsOrArgs) ? launchOptionsOrArgs : launchOptionsOrArgs.args

    port = ensureRdpPort(args)
    debug('ensureRdpPort %d', port)
    debug('Chrome arguments %o', args)
  })

  on('task', {
    resetCRI: async () => {
      if (client) {
        debug('resetting CRI client')
        await client.close()
        client = null
      }

      return Promise.resolve(true)
    },

    allowDownload: async () => {
      client = client || await CDP({ port })
      return client.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: 'C:/Users/username/Downloads' })
    }
 });
 
}

@Sree2412
Copy link

Sree2412 commented Aug 27, 2020

I have this code under test

describe('Downloads', () => {
  beforeEach(() => {
    cy.task('resetCRI')
    cy.task('allowDownload')
    cy.visit("")
  })

but still receiving "cy.task('resetCRI') failed with the following error:

The 'task' event has not been registered in the plugins file. You must register it before using cy.task()"

I think I registerd my task under my plugin index file, unable to figure out what I am missing. some one pls help!

@Sree2412
Copy link

some one pls look at above issue and advise

@AndreVirtimo
Copy link

@hhaidar thank you for you solution. Works fine for me on Mac and Linux. With Windows Cypress didn't come up. Maybe this is also the problem for @Sree2412

@hhaidar
Copy link

hhaidar commented Oct 20, 2020

@hhaidar thank you for you solution. Works fine for me on Mac and Linux. With Windows Cypress didn't come up. Maybe this is also the problem for @Sree2412

Are you using Chromium?

@AndreVirtimo
Copy link

@hhaidar thank you for you solution. Works fine for me on Mac and Linux. With Windows Cypress didn't come up. Maybe this is also the problem for @Sree2412

Are you using Chromium?

Yes. With Windows Chromium opens immediately after "cypress open" with an error. I will paste a screenshot.

@AndreVirtimo
Copy link

image

There is the message "Google API-Keys missing".

Currently I think the "chromium" packages load the latest SNAPSHOT and not a stable version from chromium.

@AndreVirtimo
Copy link

After "npm ci" on my Mac. I also got problems with chromium. It seems there is a problem in newer version.

Now I'm using a fix revision of chromium by adding the file .npmrc with node_chromium_revision=782078. This is currently a Chromium 85.

@nickbreid
Copy link

I can tell you how I have things set up to support downloads in the application via setting <iframe> src attribute.

In the Cypress plugins file:

on('before:browser:launch', (browser, options) => {
        if (browser.family === 'chromium') {
            const browserPath = String(browser.path);
            options.args.push('--disable-popup-blocking');
            if (browserPath.indexOf('D:\\a\\r1\\a\\') > 0) {
                options.args.push('--no-sandbox');
                options.args.push('--incognito');
                options.args.push('--webview-disable-safebrowsing-support');
                options.args.push('--window-size=1920,1080');
                options.args.push('--disable-headless-mode');
            }
            options.preferences.default.profile = {
                content_settings: {
                    exceptions: {
                        automatic_downloads: {
                            '*': { setting: 1 }
                        }
                    }
                },
                default_content_settings: { popups: 0 }
            };
            options.preferences.default['download'] = 
            { 
                default_directory: defaultDownloadPath,
                prompt_for_download: false
            }
            return options;
        }
    });

These settings worked for me and seem to be a better solution than anything you might stick in beforeEach

@visjag
Copy link

visjag commented Oct 27, 2020

@nickbreid I tried above solution with my windows machine but it's giving following error,
[excel download failed] - Download error
filedownload

@AndreVirtimo
Copy link

@nickbreid for me this works. But not in headless mode, which is a problem for me.

@dmitry

This comment has been minimized.

@AndreVirtimo
Copy link

@AndreVirtimo have you found a solution for the headless version? Any idea how to pass specs in CI for this case?

I have switched back to this solution #949 (comment)

But I have removed the browser config in plugins/index.js which adds the local chromium. This didn't work under windows.

@jennifer-shehane
Copy link
Member

We're hoping to do some work to assess the effort it would take to have built-in file download support soon. Priorities can change as we move forward, but you can see the larger features we have planned here: https://on.cypress.io/roadmap

@norvinino
Copy link

@jennifer-shehane @kamituel @TomaszG @dmitry I am validating that a file was exported in cypress, but it gives me an error as the file name varies depending on the download as it has the download date and the sequence code. How can I validate without putting the specific name of the file?

@norvinino

This comment has been minimized.

@jennifer-shehane
Copy link
Member

There's currently a PR open so that when you click to download a file, the download popup will prevent from showing and download the file directly to a cypress/downloads folder. #14431

Priorities can change as we move forward, but here is an outline of what we're planning for download file support.

  • Ability to clear the downloads folder before runs, just as videos and screenshots are cleared.
  • Ability to configure where downloads are saved to (aside from default cypress/downloads folder)
  • Display in the command log when a test causes a file to be downloaded. The entry should include the path where the file was saved.
  • Add a 'read:file' event so that you can add your own parsing logic in the plugins file when using cy.readFile() so you can test various types of files.

@jennifer-shehane
Copy link
Member

jennifer-shehane commented Jan 12, 2021

The code for this is done in #14431, #14452, and #14465, but this has yet to be released. We'll update this issue and reference the changelog when it's released.

@cypress-bot
Copy link
Contributor

cypress-bot bot commented Jan 20, 2021

Released in 6.3.0.

This comment thread has been locked. If you are still experiencing this issue after upgrading to
Cypress v6.3.0, please open a new issue.

@cypress-bot cypress-bot bot removed the stage: ready for work The issue is reproducible and in scope label Jan 20, 2021
@cypress-bot cypress-bot bot locked as resolved and limited conversation to collaborators Jan 20, 2021
@jennifer-shehane
Copy link
Member

Hey everyone, file download is now supported in Cypress 6.3.0.

You can now test file downloads in Cypress without the download prompt displaying. Any files downloaded while testing file downloads will be stored in the downloadsFolder which is set to cypress/downloads by default. The downloadsFolder will be deleted before each run unless trashAssetsBeforeRuns is set to false.

This means that if you had any code written to prevent the download prompt or to configure the download location (like in a before:browser:launch event handler), you can remove all of these workarounds.

Our file download recipe has been updated, so you can see some ways to test different types of files after they've been downloaded there.

If you're encountering any bugs while testing file downloads, please open a new issue.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
topic: native events type: feature New feature that does not currently exist
Projects
None yet
Development

Successfully merging a pull request may close this issue.