UNPKG

nightmare

Version:

A high-level browser automation library.

646 lines (547 loc) 15.9 kB
/** * DEBUG=nightmare* */ var log = require('debug')('nightmare:log') var debug = require('debug')('nightmare') var electronLog = { stdout: require('debug')('electron:stdout'), stderr: require('debug')('electron:stderr') } /** * Module dependencies */ var default_electron_path = require('electron') var proc = require('child_process') var actions = require('./actions') var path = require('path') var sliced = require('sliced') var child = require('./ipc') var once = require('once') var split2 = require('split2') var defaults = require('defaults') var noop = function() {} var keys = Object.keys // Standard timeout for loading URLs const DEFAULT_GOTO_TIMEOUT = 30 * 1000 // Standard timeout for wait(ms) const DEFAULT_WAIT_TIMEOUT = 30 * 1000 // Timeout between keystrokes for `.type()` const DEFAULT_TYPE_INTERVAL = 100 // timeout between `wait` polls const DEFAULT_POLL_INTERVAL = 250 // max retry for authentication const MAX_AUTH_RETRIES = 3 // max execution time for `.evaluate()` const DEFAULT_EXECUTION_TIMEOUT = 30 * 1000 // Error message when halted const DEFAULT_HALT_MESSAGE = 'Nightmare Halted' // Non-persistent partition to use by defaults const DEFAULT_PARTITION = 'nightmare' /** * Export `Nightmare` */ module.exports = Nightmare /** * runner script */ var runner = path.join(__dirname, 'runner.js') /** * Template */ var template = require('./javascript') /** * Initialize `Nightmare` * * @param {Object} options */ function Nightmare(options) { if (!(this instanceof Nightmare)) return new Nightmare(options) options = options || {} var electronArgs = {} var self = this options.waitTimeout = options.waitTimeout || DEFAULT_WAIT_TIMEOUT options.gotoTimeout = options.gotoTimeout || DEFAULT_GOTO_TIMEOUT options.pollInterval = options.pollInterval || DEFAULT_POLL_INTERVAL options.typeInterval = options.typeInterval || DEFAULT_TYPE_INTERVAL options.executionTimeout = options.executionTimeout || DEFAULT_EXECUTION_TIMEOUT options.webPreferences = options.webPreferences || {} // null is a valid value, which will result in the use of the electron default behavior, which is to persist storage. // The default behavior for nightmare will be to use non-persistent storage. // http://electron.atom.io/docs/api/browser-window/#new-browserwindowoptions options.webPreferences.partition = options.webPreferences.partition !== undefined ? options.webPreferences.partition : DEFAULT_PARTITION options.Promise = options.Promise || Nightmare.Promise || Promise var electron_path = options.electronPath || default_electron_path if (options.paths) { electronArgs.paths = options.paths } if (options.switches) { electronArgs.switches = options.switches } options.maxAuthRetries = options.maxAuthRetries || MAX_AUTH_RETRIES electronArgs.loadTimeout = options.loadTimeout if ( options.loadTimeout && options.gotoTimeout && options.loadTimeout < options.gotoTimeout ) { debug( `WARNING: load timeout of ${ options.loadTimeout } is shorter than goto timeout of ${options.gotoTimeout}` ) } electronArgs.dock = options.dock || false electronArgs.certificateSubjectName = options.certificateSubjectName || null attachToProcess(this) // initial state this.state = 'initial' this.running = false this.ending = false this.ended = false this._queue = [] this._headers = {} this.options = options debug('queuing process start') this.queue(done => { this.proc = proc.spawn( electron_path, [runner].concat(JSON.stringify(electronArgs)), { stdio: [null, null, null, 'ipc'], env: defaults(options.env || {}, process.env) } ) this.proc.stdout.pipe(split2()).on('data', data => { electronLog.stdout(data) }) this.proc.stderr.pipe(split2()).on('data', data => { electronLog.stderr(data) }) this.proc.on('close', code => { if (!self.ended) { handleExit(code, self, noop) } }) this.child = child(this.proc) this.child.once('die', function(err) { debug('dying: ' + err) self.die = err }) // propagate console.log(...) through this.child.on('log', function() { log.apply(log, arguments) }) this.child.on('uncaughtException', function(err) { const e = new Error('Nightmare runner error: ' + err.message) e.stack = err.stack || '' const onClose = () => { throw err } endInstance(self, onClose, true) }) this.child.on('page', function(type) { log.apply(null, ['page-' + type].concat(sliced(arguments, 1))) }) // propogate events through to debugging this.child.on('did-finish-load', function() { log('did-finish-load', JSON.stringify(sliced(arguments))) }) this.child.on('did-fail-load', function() { log('did-fail-load', JSON.stringify(sliced(arguments))) }) this.child.on('did-fail-provisional-load', function() { log('did-fail-provisional-load', JSON.stringify(sliced(arguments))) }) this.child.on('did-frame-finish-load', function() { log('did-frame-finish-load', JSON.stringify(sliced(arguments))) }) this.child.on('did-start-loading', function() { log('did-start-loading', JSON.stringify(sliced(arguments))) }) this.child.on('did-stop-loading', function() { log('did-stop-loading', JSON.stringify(sliced(arguments))) }) this.child.on('did-get-response-details', function() { log('did-get-response-details', JSON.stringify(sliced(arguments))) }) this.child.on('did-get-redirect-request', function() { log('did-get-redirect-request', JSON.stringify(sliced(arguments))) }) this.child.on('dom-ready', function() { log('dom-ready', JSON.stringify(sliced(arguments))) }) this.child.on('page-favicon-updated', function() { log('page-favicon-updated', JSON.stringify(sliced(arguments))) }) this.child.on('new-window', function() { log('new-window', JSON.stringify(sliced(arguments))) }) this.child.on('will-navigate', function() { log('will-navigate', JSON.stringify(sliced(arguments))) }) this.child.on('crashed', function() { log('crashed', JSON.stringify(sliced(arguments))) }) this.child.on('plugin-crashed', function() { log('plugin-crashed', JSON.stringify(sliced(arguments))) }) this.child.on('destroyed', function() { log('destroyed', JSON.stringify(sliced(arguments))) }) this.child.on('media-started-playing', function() { log('media-started-playing', JSON.stringify(sliced(arguments))) }) this.child.on('media-paused', function() { log('media-paused', JSON.stringify(sliced(arguments))) }) this.child.once('ready', versions => { this.engineVersions = versions this.child.call('browser-initialize', options, function() { self.state = 'ready' done() }) }) }) // initialize namespaces Nightmare.namespaces.forEach(function(name) { if ('function' === typeof this[name]) { this[name] = this[name]() } }, this) //prepend adding child actions to the queue Object.keys(Nightmare.childActions).forEach(function(key) { debug('queueing child action addition for "%s"', key) this.queue(function(done) { this.child.call('action', key, String(Nightmare.childActions[key]), done) }) }, this) } function handleExit(code, instance, cb) { var help = { 127: 'command not found - you may not have electron installed correctly', 126: 'permission problem or command is not an executable - you may not have all the necessary dependencies for electron', 1: 'general error - you may need xvfb', 0: 'success!' } debug('electron child process exited with code ' + code + ': ' + help[code]) instance.proc.removeAllListeners() cb() } function endInstance(instance, cb, forceKill) { instance.ended = true detachFromProcess(instance) if (instance.proc && instance.proc.connected) { instance.proc.on('close', code => { handleExit(code, instance, cb) }) instance.child.call('quit', () => { instance.child.removeAllListeners() if (forceKill) { instance.proc.kill('SIGINT') } }) } else { debug('electron child process not started yet, skipping kill.') cb() } } /** * Attach any instance-specific process-level events. */ function attachToProcess(instance) { instance._endNow = endInstance.bind(null, instance, noop) process.setMaxListeners(Infinity) process.on('exit', instance._endNow) process.on('SIGINT', instance._endNow) process.on('SIGTERM', instance._endNow) process.on('SIGQUIT', instance._endNow) process.on('SIGHUP', instance._endNow) process.on('SIGBREAK', instance._endNow) } function detachFromProcess(instance) { process.removeListener('exit', instance._endNow) process.removeListener('SIGINT', instance._endNow) process.removeListener('SIGTERM', instance._endNow) process.removeListener('SIGQUIT', instance._endNow) process.removeListener('SIGHUP', instance._endNow) process.removeListener('SIGBREAK', instance._endNow) } /** * Namespaces to initialize */ Nightmare.namespaces = [] /** * Child actions to create */ Nightmare.childActions = {} /** * Version */ Nightmare.version = require(path.resolve( __dirname, '..', 'package.json' )).version /** * Promise library (can override) */ Nightmare.Promise = Promise /** * Override headers for all HTTP requests */ Nightmare.prototype.header = function(header, value) { if (header && typeof value !== 'undefined') { this._headers[header] = value } else { this._headers = header || {} } return this } /** * Go to a `url` */ Nightmare.prototype.goto = function(url, headers) { debug('queueing action "goto" for %s', url) var self = this headers = headers || {} for (var key in this._headers) { headers[key] = headers[key] || this._headers[key] } this.queue(function(fn) { self.child.call('goto', url, headers, this.options.gotoTimeout, fn) }) return this } /** * run */ Nightmare.prototype.run = function(fn) { debug('running') var steps = this.queue() this.running = true this._queue = [] var self = this // kick us off next() // next function function next(err, _res) { var item = steps.shift() // Immediately halt execution if an error has been thrown, or we have no more queued up steps. if (err || !item) return done.apply(self, arguments) var args = item[1] || [] var method = item[0] args.push(once(after)) method.apply(self, args) } function after(err, _res) { err = err || self.die var args = [err].concat(sliced(arguments, 1)) if (self.child) { self.child.call('continue', () => next.apply(self, args)) } else { next.apply(self, args) } } function done() { var doneargs = arguments self.running = false if (self.ending) { return endInstance(self, () => fn.apply(self, doneargs)) } return fn.apply(self, doneargs) } return this } /** * run the code now (do not queue it) * * you should not use this, unless you know what you're doing * it should be used for plugins and custom actions, not for * normal API usage */ Nightmare.prototype.evaluate_now = function(js_fn, done) { var args = Array.prototype.slice .call(arguments) .slice(2) .map(a => { return { argument: JSON.stringify(a) } }) var source = template.execute({ src: String(js_fn), args: args }) this.child.call('javascript', source, done) return this } /** * inject javascript */ Nightmare.prototype._inject = function(js, done) { this.child.call('javascript', template.inject({ src: js }), done) return this } /** * end */ Nightmare.prototype.end = function(done) { this.ending = true if (done && !this.running && !this.ended) { return this.then(done) } return this } /** * Halt - Force kills the electron process immediately and empties the queue * * @param {Error|String} error (Optional: defaults to 'Nightmare Halted'.) Error to pass to rejected promise * @param {Function} done (Optional: defaults to no operation) callback when the child process exits * @return {Nightmare} returns self */ Nightmare.prototype.halt = function(error, done) { this.ending = true var queue = this.queue() // empty the queue queue.splice(0) if (!this.ended) { var message = error if (error instanceof Error) { message = error.message } this.die = message || DEFAULT_HALT_MESSAGE if (typeof this._rejectActivePromise === 'function') { this._rejectActivePromise(error || DEFAULT_HALT_MESSAGE) } var callback = done if (!callback || typeof callback !== 'function') { callback = noop } endInstance(this, callback, true) } return this } /** * on */ Nightmare.prototype.on = function(event, handler) { this.queue(function(done) { this.child.on(event, handler) done() }) return this } /** * once */ Nightmare.prototype.once = function(event, handler) { this.queue(function(done) { this.child.once(event, handler) done() }) return this } /** * removeEventListener */ Nightmare.prototype.removeListener = function(event, handler) { this.child.removeListener(event, handler) return this } /** * Queue */ Nightmare.prototype.queue = function(_done) { if (!arguments.length) return this._queue var args = sliced(arguments) var fn = args.pop() this._queue.push([fn, args]) } /** * then */ Nightmare.prototype.then = function(fulfill, reject) { var self = this return new this.options.Promise(function(success, failure) { self._rejectActivePromise = failure self.run(function(err, result) { if (err) failure(err) else success(result) }) }).then(fulfill, reject) } /** * catch */ Nightmare.prototype.catch = function(reject) { this._rejectActivePromise = reject return this.then(undefined, reject) } /** * use */ Nightmare.prototype.use = function(fn) { fn(this) return this } // wrap all the functions in the queueing function function queued(name, fn) { return function action() { debug('queueing action "' + name + '"') var args = [].slice.call(arguments) this._queue.push([fn, args]) return this } } /** * Static: Support attaching custom actions * * @param {String} name - method name * @param {Function|Object} [childfn] - Electron implementation * @param {Function|Object} parentfn - Nightmare implementation * @return {Nightmare} */ Nightmare.action = function() { var name = arguments[0], childfn, parentfn if (arguments.length === 2) { parentfn = arguments[1] } else { parentfn = arguments[2] childfn = arguments[1] } // support functions and objects // if it's an object, wrap it's // properties in the queue function if (parentfn) { if (typeof parentfn === 'function') { Nightmare.prototype[name] = queued(name, parentfn) } else { if (!~Nightmare.namespaces.indexOf(name)) { Nightmare.namespaces.push(name) } Nightmare.prototype[name] = function() { var self = this return keys(parentfn).reduce(function(obj, key) { obj[key] = queued(name, parentfn[key]).bind(self) return obj }, {}) } } } if (childfn) { if (typeof childfn === 'function') { Nightmare.childActions[name] = childfn } else { for (var key in childfn) { Nightmare.childActions[name + '.' + key] = childfn[key] } } } } /** * Attach all the actions. */ Object.keys(actions).forEach(function(name) { var fn = actions[name] Nightmare.action(name, fn) })