Testing File Downloads using WebdriverIO

Are you testing a site that allows you to download content?

Do you need to verify the downloads are actually working?

I've run across this a couple of times in the past month, and have settled on a pretty stable approach that I'm happy with.

I figured I'd share it, as it does have some complexity to it.

A working demo can be found here:
https://github.com/klamping/wdio-downloads

Step 1: Where Should We Download?

The first thing we should do is tell our automation where to store our downloads.

You could use the browser defaults, but those can be in different places depending on the computer/OS you're testing on.

I prefer to create and use a temporary download directory for my testing.

To do this, I'm going to add some code to my wdio.conf.js file.

This works for setting up Chrome, but a similar approach would work for Firefox as well (just need to change the 'options').

// Load the libraries we need for path/filesystem manipulation
const path = require('path')
const fs = require('fs')

// Store the directory path in a global, which allows us to access this path inside our tests
global.downloadDir = path.join(__dirname, 'tempDownload');

exports.config = {
  // ... previous configs ...
  capabilities: [{
    browserName: 'chrome',
    // this overrides the default chrome download directory with our temporary one
    'goog:chromeOptions': {
      prefs: {
        'directory_upgrade': true,
        'prompt_for_download': false,
        'download.default_directory': downloadDir
      }
    }
  }],
  // ... rest of configs ...

One thing that's important is to ensure the directory exists before running our tests.

This can be done in WebdriverIO onPrepare statement:

exports.config = {
  // ... previous configs ...
  onPrepare: function (config, capabilities) {
    // make sure download directory exists
    if (!fs.existsSync(downloadDir)){
        // if it doesn't exist, create it
        fs.mkdirSync(downloadDir);
    }
  },
  // ... rest of configs ...
}

Okay, to recap so far, those changes to our wdio.conf.js file will define the directory we want our downloads to go in, tell Chrome about it, then setup the folder if it's not there.

Step 2: Cleaning Up After Ourselves

While we could move on to our test at this point, I don't like keeping that download folder (and the downloaded files) around after our test.

So I'm going add an onComplete hook to the wdio.conf.js file, which will remove the download directory, deleting any files inside of it.

The rmdir function was pulled from a gist I found, and could likely better be replaced with any number of NPM modules.

// Pulled from https://gist.github.com/tkihira/2367067
// this could be moved to a separate file if we wanted
function rmdir(dir) {
  var list = fs.readdirSync(dir);
  for(var i = 0; i < list.length; i++) {
    var filename = path.join(dir, list[i]);
    var stat = fs.statSync(filename);

    if(filename == "." || filename == "..") {
      // pass these files
    } else if(stat.isDirectory()) {
      // rmdir recursively
      rmdir(filename);
    } else {
      // rm fiilename
      fs.unlinkSync(filename);
    }
  }
  fs.rmdirSync(dir);
}

exports.config = {
  // ... previous configs ...
  onComplete: function() {
    rmdir(downloadDir)
  }
  // ... rest of configs ...
}

Step 3: Download That File!

We've got our folder prepped and ready to use. Now let's actually download something!

You'll probably want to add this to an existing test file, but for this example, we'll use a brand-spanking new one:

A quick note before proceding. I'm assuming that the download action is through an anchor tag (e.g. <a href="path/to/file.txt">).

However, many times this is done through a button with a JavaScript action instead.

In that case, you'll need to come up with a different solution to determine the file name that will be generated.

I haven't investigated that yet, so I don't have any good ideas for handling that right now.

All that said, here's what the test file could look like:

// Load the libraries we need for path/URL manipulation & assertions
const path = require('path')
const fs = require('fs')
const { URL } = require('url')
const assert = require('assert');

describe('Downloads', function () {
  it('should download the file', function () {
    // go to a good page for testing download functionality
    browser.url('./download')

    // store the element reference for repeated use
    const downloadLink = $('*=some-file.txt');

    // click the link to initiate the download
    downloadLink.click();

    // get the value of the 'href' attibute on the download link
    // e.g. 'http://the-internet.herokuapp.com/download/some-file.txt'
    const downloadHref = downloadLink.getAttribute('href');

    // pass through Node's `URL` class
    // @see https://nodejs.org/dist/latest-v8.x/docs/api/url.html
    const downloadUrl = new URL(downloadHref);

    // get the 'pathname' off the url
    // e.g. 'download/some-file.txt'
    const fullPath = downloadUrl.pathname;

    // split in to an array, so we can get just the filename
    // e.g. ['download', 'some-file.txt']
    const splitPath = fullPath.split('/')

    // get just the filename at the end of the array
    // e.g.  'some-file.txt'
    const fileName = splitPath.splice(-1)[0]

    // join the filename to the path where we're storing the downloads
    // '/path/to/wdio/tests/tempDownload/some-file.txt'
    const filePath = path.join(global.downloadDir, fileName)

    // we need to wait for the file to fully download
    // so we use the 'browser.call' function since this is an async operation
    // @see http://webdriver.io/api/utility/call.html
    browser.call(function (){
      // call our custom function that checks for the file to exist
      return waitForFileExists(filePath, 60000)
    });

    // now that we have our file downloaded, we can do whatever we want with it
    // in this example, we'll read the file contents and
    // assert it contains the expected text
    const fileContents = fs.readFileSync(filePath, 'utf-8')
    assert.ok(fileContents.includes('asdf'))
  })
})

Hopefully the comments explain most of what's going on.

Basically, we click the download link, then figure out what the filename is going to be based on the href of that link.

With that filename, we're going to call a custom function that will wait for the file to exist.

Because of the way downloads are named, while the download is running, it's stored as a different filename.

Not until the entire thing has been pulled down does the final file get created.

Here's that waitForFileExists function by the way. You can store this in your test file, or if you want to reuse, store it in it's own file (or replace it with some NPM module that I'm sure exists).

// pulled from https://stackoverflow.com/a/47764403
function waitForFileExists(filePath, timeout) {
  return new Promise(function (resolve, reject) {

    var timer = setTimeout(function () {
      watcher.close();
      reject(new Error('File did not exists and was not created during the timeout.'));
    }, timeout);

    fs.access(filePath, fs.constants.R_OK, function (err) {
      if (!err) {
        clearTimeout(timer);
        watcher.close();
        resolve();
      }
    });

    var dir = path.dirname(filePath);
    var basename = path.basename(filePath);
    var watcher = fs.watch(dir, function (eventType, filename) {
      if (eventType === 'rename' && filename === basename) {
        clearTimeout(timer);
        watcher.close();
        resolve();
      }
    });
  });
}

Step 4: Run Your Tests!

That sums up all that's needed to download and verify your file.

Chances are you won't be downloading a plain text file, but rather something more useful, like a zip folder or spreadsheet file.

Luckily, NodeJS and NPM have you covered, offering a wide variety of packages built around reading special files.

And since WebdriverIO runs inside NodeJS, you can easily include these packages in your test script, utilizing them for your test assertions.

A working demo can be found on GitHub at:
https://github.com/klamping/wdio-downloads

Header Photo by Samuel Zeller on Unsplash