├── test ├── mocha.opts ├── fixtures │ └── downloads │ │ └── index.html ├── server.js └── index.js ├── .gitignore ├── History.md ├── circle.yml ├── package.json ├── README.md └── nightmare-inline-download.js /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --slow 30s 2 | --timeout 60s 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | test/tmp 4 | -------------------------------------------------------------------------------- /History.md: -------------------------------------------------------------------------------- 1 | 0.2.2 / 2016-07-08 2 | ================== 3 | 4 | * ensures the download handler is discarded when download is complete 5 | * resets the child's flag for parent requested download 6 | 7 | 0.2.1 / 2016-06-20 8 | ================== 9 | 10 | * adds Circle configuration 11 | * upgrades to Nightmare 2.5.1 12 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | dependencies: 2 | pre: 3 | - sudo apt-get update; sudo apt-get install libnotify-bin 4 | - npm install -g npm@3.x.x 5 | test: 6 | override: 7 | - nvm use 4 && npm install nightmare && npm test 8 | - nvm use 5 && npm install nightmare && npm test 9 | - nvm use 6 && npm install nightmare && npm test 10 | -------------------------------------------------------------------------------- /test/fixtures/downloads/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | downloads 5 | 6 | 7 |
8 | download 100kib txt file 9 |
10 |
11 | download 20mib txt file 12 |
13 |
14 | download 200kib txt file 15 |
16 | 17 | 20 | 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nightmare-inline-download", 3 | "version": "0.2.2", 4 | "license": "MIT", 5 | "main": "./nightmare-inline-download.js", 6 | "description": "Add inline download management to NightmareJS", 7 | "scripts": { 8 | "test": "mocha test" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/rosshinkley/nightmare-inline-download.git" 13 | }, 14 | "author": "Ross Hinkley", 15 | "keywords": [ 16 | "nightmare", 17 | "phantomjs", 18 | "download", 19 | "downloads" 20 | ], 21 | "peerDependencies": { 22 | "nightmare": "^2.5.2" 23 | }, 24 | "dependencies": { 25 | "debug": "^2.2.0", 26 | "sliced": "^1.0.1" 27 | }, 28 | "devDependencies": { 29 | "async": "^1.5.2", 30 | "chai": "^3.5.0", 31 | "express": "^4.13.3", 32 | "mime": "^1.3.4", 33 | "mkdirp": "^0.5.1", 34 | "mocha": "^2.3.0", 35 | "mocha-generators": "^1.2.0", 36 | "rimraf": "^2.5.2", 37 | "serve-static": "^1.11.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | var express = require('express'), 2 | path = require('path'), 3 | serve = require('serve-static'), 4 | util = require('util'), 5 | mime = require('mime'), 6 | async = require('async'); 7 | 8 | var app = module.exports = express(); 9 | 10 | app.get('/download/:size', function(req, res) { 11 | 12 | var conversion = {}; 13 | conversion.kib = { 14 | factor: 1024, 15 | chunksize: Math.pow(2, 8) 16 | }; 17 | conversion.mib = { 18 | factor: 1024 * 1024, 19 | chunksize: Math.pow(2, 16) 20 | }; 21 | 22 | var parts = /^(\d+)([a-z]{2,3})\.(\w+)$/.exec(req.params.size); 23 | var s = conversion[parts[2].toLowerCase()]; 24 | var size = parseInt(parts[1], 10) * s.factor; 25 | 26 | var mimetype = mime.lookup(req.params.size); 27 | 28 | res.setHeader('Content-disposition', 'attachment; filename=' + req.params.size); 29 | res.setHeader('Content-length', size); 30 | res.setHeader('Content-type', mimetype); 31 | 32 | var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 33 | 34 | var tasks = [] 35 | for (var i = 0; i < size / s.chunksize; i++) { 36 | tasks.push(function(cb) { 37 | setTimeout(function() { 38 | var str = []; 39 | for (var j = 0; j < s.chunksize; j++) { 40 | str.push(possible.charAt(Math.floor(Math.random() * possible.length))); 41 | } 42 | res.write(str.join('')); 43 | cb(); 44 | }, 1); 45 | }); 46 | } 47 | 48 | if (size % s.chunksize > 0) { 49 | tasks.push(function(cb) { 50 | var str = []; 51 | for (var j = 0; j < size % s.chunksize; j++) { 52 | str.push(possible.charAt(Math.floor(Math.random() * possible.length))); 53 | } 54 | res.write(str.join('')); 55 | cb(); 56 | }); 57 | } 58 | 59 | async.series(tasks, function() { 60 | res.end(); 61 | }); 62 | }); 63 | 64 | app.use(serve(path.resolve(__dirname, 'fixtures'))); 65 | 66 | if (!module.parent) app.listen(7500); 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | nightmare-inline-download 2 | ====================== 3 | 4 | Add inline download management to your [Nightmare](http://github.com/segmentio/nightmare) scripts. 5 | 6 | # Important Note 7 | If you need to manage multiple downloads at the same time or want downloads to be processed in the background, check out the [Nightmare download manager](https://github.com/rosshinkley/nightmare-download-manager). 8 | 9 | ## Usage 10 | Require the library: and pass the Nightmare library as a reference to attach the plugin actions: 11 | 12 | ```js 13 | var Nightmare = require('nightmare'); 14 | require('nightmare-inline-download')(Nightmare); 15 | ``` 16 | 17 | ... and that's it. You should now be able to handle downloads. 18 | 19 | ### .download([path|action]) 20 | 21 | Allows for downloads to be saved to a custom location or cancelled. The possible values for `action` are `'cancel'`, `'continue'` for default behavior, or a file path (file name and extension inclusive) to save the download to an alternative location. If yielded upon, `.download()` returns a hash of download information: 22 | 23 | * **filename**: the filename the server sent. 24 | * **mimetype**: the mimetype of the download. 25 | * **receivedBytes**: the number of bytes received for a download. 26 | * **totalBytes**: the number of bytes to expect if `Content-length` is set as a header. 27 | * **url**: the address of where the download is being sent from. 28 | * **path**: specifies the save path for the download. 29 | * **state**: the state of the download. At yield, `state` can be `'cancelled'`, `'interrupted'`, or `'completed'`. 30 | 31 | ## Additional Nightmare Options 32 | 33 | ### ignoreDownloads 34 | Defines whether or not all downloads should be ignored. 35 | 36 | ### maxDownloadRequestWait 37 | Sets the maximum time for the client to anticipate a `.download()` call. If the call is not made, the download is automatically cancelled. 38 | 39 | ### paths.downloads 40 | Sets the Electron path for where downloads are saved. 41 | 42 | ## Example 43 | 44 | ```javascript 45 | var Nightmare = require('nightmare'); 46 | require('nightmare-inline-download')(Nightmare); 47 | var nightmare = Nightmare(); 48 | var downloadInfo = nightmare 49 | .goto('https://github.com/segmentio/nightmare') 50 | .click('a[href="/segmentio/nightmare/archive/master.zip"]') 51 | .download('/some/other/path/master.zip'); 52 | 53 | // ... do something with downloadInfo, in an evaluate for example ... 54 | 55 | .end() 56 | .then(()=>console.log('done')); 57 | ``` 58 | 59 | -------------------------------------------------------------------------------- /nightmare-inline-download.js: -------------------------------------------------------------------------------- 1 | var sliced = require('sliced'), 2 | debug = require('debug')('nightmare:download'); 3 | 4 | module.exports = exports = function(Nightmare) { 5 | Nightmare.action('download', 6 | function(ns, options, parent, win, renderer, done) { 7 | var fs = require('fs'), 8 | join = require('path') 9 | .join, 10 | sliced = require('sliced'); 11 | 12 | var app = require('electron').app; 13 | 14 | var _parentRequestedDownload = false, 15 | _maxParentRequestWait = options.maxDownloadRequestWait || 5000; 16 | parent.on('expect-download', function() { 17 | _parentRequestedDownload = true; 18 | }); 19 | 20 | win.webContents.session.on('will-download', 21 | function(event, downloadItem, webContents) { 22 | //pause the download and set the save path to prevent dialog 23 | downloadItem.pause(); 24 | downloadItem.setSavePath(join(app.getPath('downloads'), downloadItem.getFilename())); 25 | 26 | var downloadInfo = { 27 | filename: downloadItem.getFilename(), 28 | mimetype: downloadItem.getMimeType(), 29 | receivedBytes: 0, 30 | totalBytes: downloadItem.getTotalBytes(), 31 | url: downloadItem.getURL(), 32 | path: join(app.getPath('downloads'), downloadItem.getFilename()) 33 | }; 34 | 35 | var elapsed = 0; 36 | var wait = function() { 37 | if (_parentRequestedDownload) { 38 | parent.emit('log', 'will-download'); 39 | if (options.ignoreDownloads) { 40 | parent.emit('log', 'ignoring all downloads'); 41 | parent.emit('download', 'cancelled', downloadInfo); 42 | downloadItem.cancel(); 43 | return; 44 | } 45 | downloadItem.on('done', function(e, state) { 46 | if (state == 'completed') { 47 | fs.renameSync(join(app.getPath('downloads'), downloadItem.getFilename()), downloadInfo.path); 48 | } 49 | _parentRequestedDownload = false; 50 | parent.emit('download', state, downloadInfo); 51 | }); 52 | 53 | downloadItem.on('updated', function(event) { 54 | downloadInfo.receivedBytes = event.sender.getReceivedBytes(); 55 | parent.emit('download', 'updated', downloadInfo); 56 | }); 57 | 58 | downloadItem.setSavePath(downloadInfo.path); 59 | 60 | var handler = function() { 61 | var arguments = sliced(arguments) 62 | .filter(function(arg) { 63 | return !!arg; 64 | }); 65 | var item, path; 66 | if (arguments.length == 1 && arguments[0] === Object(arguments[0])) { 67 | item = arguments[0]; 68 | } else if (arguments.length == 2) { 69 | path = arguments[0]; 70 | item = arguments[1]; 71 | } 72 | 73 | if (item.filename == downloadItem.getFilename()) { 74 | if (path == 'cancel') { 75 | downloadItem.cancel(); 76 | } else { 77 | if (path && path !== 'continue') { 78 | //.setSavePath() does not overwrite the first .setSavePath() call 79 | //use `fs.rename` when download is completed 80 | downloadInfo.path = path; 81 | } 82 | downloadItem.resume(); 83 | } 84 | } 85 | }; 86 | 87 | parent.once('download', handler); 88 | parent.emit('log', 'will-download about bubble to parent'); 89 | parent.emit('download', 'started', downloadInfo); 90 | } else if (elapsed >= _maxParentRequestWait) { 91 | parent.emit('download', 'force-cancelled', downloadInfo); 92 | parent.emit('log', 'no parent request received for download, discarding'); 93 | return downloadItem.cancel(); 94 | } else { 95 | parent.emit('log', 'waiting, elapsed: ' + elapsed); 96 | elapsed += 100; 97 | setTimeout(wait, 100); 98 | } 99 | } 100 | wait(); 101 | }); 102 | done(); 103 | }, 104 | function() { 105 | var self = this, 106 | path, done; 107 | if (arguments.length == 2) { 108 | path = arguments[0]; 109 | done = arguments[1]; 110 | } else { 111 | done = arguments[0]; 112 | } 113 | 114 | var handler = function(state, downloadInfo) { 115 | downloadInfo.state = state; 116 | debug('download', downloadInfo); 117 | if (state == 'started') { 118 | if (self.options.ignoreDownloads) { 119 | self.child.emit('download', 'cancel', downloadInfo); 120 | } else { 121 | self.child.emit('download', path || 'continue', downloadInfo); 122 | } 123 | } else { 124 | if (state == 'interrupted' || state == 'force-cancelled') { 125 | self.child.removeListener('download', handler); 126 | done(state, downloadInfo); 127 | } else if (state == 'completed' || state == 'cancelled') { 128 | self.child.removeListener('download', handler); 129 | done(null, downloadInfo); 130 | } 131 | } 132 | }; 133 | self.child.on('download', handler); 134 | 135 | self.child.emit('expect-download'); 136 | return this; 137 | }); 138 | }; 139 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Module dependencies. 3 | */ 4 | 5 | require('mocha-generators') 6 | .install(); 7 | 8 | var Nightmare = require('nightmare'); 9 | var should = require('chai') 10 | .should(); 11 | var url = require('url'); 12 | var server = require('./server'); 13 | var fs = require('fs'); 14 | var mkdirp = require('mkdirp'); 15 | var path = require('path'); 16 | var rimraf = require('rimraf'); 17 | 18 | /** 19 | * Temporary directory 20 | */ 21 | 22 | var tmp_dir = path.join(__dirname, 'tmp') 23 | 24 | /** 25 | * Get rid of a warning. 26 | */ 27 | 28 | process.setMaxListeners(0); 29 | 30 | /** 31 | * Locals. 32 | */ 33 | 34 | var base = 'http://localhost:7500/'; 35 | 36 | describe('Nightmare download manager', function() { 37 | before(function(done) { 38 | require('../nightmare-inline-download')(Nightmare); 39 | server.listen(7500, done); 40 | }); 41 | 42 | it('should be constructable', function * () { 43 | var nightmare = Nightmare(); 44 | nightmare.should.be.ok; 45 | nightmare.download.should.be.ok; 46 | yield nightmare.end(); 47 | }); 48 | 49 | describe('downloads', function() { 50 | var nightmare; 51 | 52 | before(function(done) { 53 | mkdirp(path.join(tmp_dir, 'subdir'), done); 54 | }) 55 | 56 | after(function(done) { 57 | rimraf(tmp_dir, done) 58 | }) 59 | 60 | afterEach(function * () { 61 | yield nightmare.end(); 62 | }); 63 | 64 | it('should download a file', function * () { 65 | var downloadItem, statFail = false; 66 | 67 | nightmare = Nightmare({ 68 | paths: { 69 | 'downloads': tmp_dir 70 | }, 71 | }); 72 | 73 | var downloadItem = yield nightmare 74 | .goto(fixture('downloads')) 75 | .click('#dl1') 76 | .download(); 77 | 78 | try { 79 | fs.statSync(path.join(tmp_dir, '100kib.txt')); 80 | } catch (e) { 81 | statFail = true; 82 | } 83 | 84 | downloadItem.should.be.ok; 85 | downloadItem.filename.should.equal('100kib.txt'); 86 | downloadItem.state.should.equal('completed'); 87 | statFail.should.be.false; 88 | }); 89 | 90 | it('should error when download time exceeds request timeout', function * () { 91 | var didForceCancel = false; 92 | 93 | nightmare = Nightmare({ 94 | paths: { 95 | 'downloads': tmp_dir 96 | }, 97 | waitTimeout: 30000, 98 | maxDownloadRequestWait: 100 99 | }); 100 | 101 | nightmare.on('download', function(state, downloadInfo) { 102 | if (state == 'force-cancelled') { 103 | didForceCancel = true; 104 | } 105 | }) 106 | yield nightmare 107 | .goto(fixture('downloads')) 108 | .click('#dl2') 109 | 110 | yield nightmare.wait(1000); 111 | 112 | didForceCancel.should.be.true; 113 | }); 114 | 115 | it('should set a path for a specific download', function * () { 116 | var downloadItem, statFail = false, 117 | finalState; 118 | 119 | nightmare = Nightmare({ 120 | paths:{ 121 | downloads: tmp_dir 122 | } 123 | }); 124 | 125 | var downloadItem = yield nightmare 126 | .goto(fixture('downloads')) 127 | .click('#dl1') 128 | .download(path.join(tmp_dir, 'subdir', '100kib.txt')); 129 | 130 | try { 131 | fs.statSync(path.join(tmp_dir, 'subdir', '100kib.txt')); 132 | } catch (e) { 133 | statFail = true; 134 | } 135 | 136 | downloadItem.should.be.ok; 137 | downloadItem.state.should.equal('completed'); 138 | statFail.should.be.false; 139 | }); 140 | 141 | it('should allow for multiple downloads', function(done) { 142 | var downloadItem, statFail = false, 143 | finalState; 144 | 145 | nightmare = Nightmare({ 146 | paths:{ 147 | downloads: tmp_dir 148 | } 149 | }); 150 | 151 | nightmare 152 | .goto('http://localhost:7500/downloads') 153 | .evaluate(function(){ 154 | return ['dl1', 'dl2', 'dl3']; 155 | }) 156 | .then((linknames) => { 157 | return linknames.reduce((acc, name, ix) => { 158 | return acc.then(function(results){ 159 | return nightmare 160 | .click('#'+name) 161 | .download(path.resolve(tmp_dir, 'subdir', `thing_${ix}.txt`)) 162 | .then(info => { 163 | results.push(info); 164 | return results; 165 | }); 166 | }); 167 | }, Promise.resolve([])) 168 | .then(function(results){ 169 | var stats = []; 170 | try { 171 | stats.push(fs.statSync(path.join(tmp_dir, 'subdir', 'thing_0.txt'))); 172 | stats.push(fs.statSync(path.join(tmp_dir, 'subdir', 'thing_1.txt'))); 173 | stats.push(fs.statSync(path.join(tmp_dir, 'subdir', 'thing_2.txt'))); 174 | } catch (e) { 175 | statFail = true; 176 | } 177 | 178 | statFail.should.be.false; 179 | if(!statFail){ 180 | results[0].totalBytes.should.equal(stats[0].size); 181 | /thing\_0\.txt$/.test(results[0].path).should.be.true; 182 | results[1].totalBytes.should.equal(stats[1].size); 183 | /thing\_1\.txt$/.test(results[1].path).should.be.true; 184 | results[2].totalBytes.should.equal(stats[2].size); 185 | /thing\_2\.txt$/.test(results[2].path).should.be.true; 186 | } 187 | done(); 188 | }) 189 | }); 190 | }); 191 | 192 | it('should cancel a specific download', function * () { 193 | var downloadItem, finalState; 194 | 195 | nightmare = Nightmare({ 196 | paths: { 197 | downloads: tmp_dir 198 | } 199 | }); 200 | 201 | var downloadItem = yield nightmare 202 | .goto(fixture('downloads')) 203 | .click('#dl1') 204 | .download('cancel'); 205 | 206 | downloadItem.should.be.ok; 207 | downloadItem.state.should.equal('cancelled'); 208 | }); 209 | 210 | it('should ignore all downloads', function * () { 211 | nightmare = Nightmare({ 212 | paths: { 213 | 'downloads': tmp_dir 214 | }, 215 | ignoreDownloads: true 216 | }); 217 | 218 | var downloadItem = yield nightmare 219 | .goto(fixture('downloads')) 220 | .click('#dl1') 221 | .download(); 222 | 223 | downloadItem.should.be.ok; 224 | downloadItem.state.should.equal('cancelled'); 225 | }); 226 | }); 227 | }); 228 | 229 | /** 230 | * Generate a URL to a specific fixture. 231 | * @param {String} path 232 | * @returns {String} 233 | */ 234 | 235 | function fixture(path) { 236 | return url.resolve(base, path); 237 | } 238 | --------------------------------------------------------------------------------