├── 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 |
10 |
13 |
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 |
--------------------------------------------------------------------------------