From 589a45a1a59021dcee5d949f06dc57d582c092be Mon Sep 17 00:00:00 2001 From: Sean McLellan Date: Sat, 7 May 2016 12:39:40 -0400 Subject: [PATCH] Forward port PR #579 from master. Pretty dang awesome. The parent PR removed a ton of code, a dependency and made things clean! Synergize!! --- .gitignore | 1 + Readme.md | 46 +++++++----- actions/cookies.js | 24 +++--- actions/core.js | 65 ++++++---------- actions/input.js | 22 +++--- actions/navigation.js | 75 +++++++++--------- lib/ipc.js | 129 +++++++++++++++++++++++++++++-- lib/nightmare.js | 105 ++++++-------------------- lib/runner.js | 48 ++++++------ package.json | 1 - test/index.js | 171 +++++++++++++++++++++++++++++++++--------- 11 files changed, 414 insertions(+), 273 deletions(-) diff --git a/.gitignore b/.gitignore index 07635b57..a500cbed 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules test/tmp test/DevTools Extensions .vs/ +.vscode/ coverage/ \ No newline at end of file diff --git a/Readme.md b/Readme.md index 7f997c68..727e2508 100644 --- a/Readme.md +++ b/Readme.md @@ -10,7 +10,7 @@ This version of Nightmare relies on promises. The primary API change is that all Since all functions return promises, it's easy to synchronize between other Promise-based apis. -``` +``` js Promise.race([nightmare.goto('http://foo.com'), timeout(500)]) .then(function() { console.log("success!"); @@ -21,7 +21,7 @@ Since all functions return promises, it's easy to synchronize between other Prom However, Nightmare is still chainable through the .chain() function. -``` +``` js var Nightmare = require("nightmare"); var title = new Nightmare().chain() .goto("http://foo.com") @@ -39,7 +39,7 @@ Starting with Nightmare v3 one can choose the specific base functions that are d By default, all modules are associated with the nightmare instance whe using require("nightmare"). If you only want to use a portion of the functionality you can include only the modules you're interested in, or, if you're not happy with the included ones, completely rewrite your own actions. -``` +``` js const Nightmare = require("nightmare/lib/nightmare"); //require the base nightmare class. require("nightmare/actions/core"); //only pull in the 'core' set of actions. ``` @@ -55,7 +55,7 @@ The available modules are: Nightmare v3 can be extended by simply adding functions to Nightmare.prototype. -``` +``` js Nightmare.prototype.size = function (scale, offset) { return this.evaluate_now(function (scale, offset) { var w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0) @@ -242,7 +242,7 @@ var nightmare = new Nightmare({ With Nightmare v3, once a new Nightmare instance is created, the instance must first be initialized with the .init() function prior to calling any page interaction functions. -``` +```js var nightmare = new Nightmare(); yield nightmare.init(); yield nightmare.goto("http://foo.com"); @@ -253,7 +253,7 @@ With Nightmare v3, once a new Nightmare instance is created, the instance must f ##### Chain With Nightmare v3, all functions return promises, however, the API can still be chained using the .chain() function which dynamically creates a chainable promise: -``` +```js var nightmare = new Nightmare(); yield nightmare.chain() .goto("http://foo.com") @@ -542,7 +542,7 @@ With nightmare v3 the primary mechanism of adding custom behavior is by adding f Functions added to the prototype can be simple prototype functions that can return promises or values. Callback functions can be utilized, but are not required. Custom functions can be generators as well. -``` +```js Nightmare.prototype.size = function (scale, offset) { return this.evaluate_now(function (scale, offset) { var w = Math.max(document.documentElement.clientWidth, window.innerWidth || 0) @@ -560,7 +560,7 @@ Nightmare.prototype.size = function (scale, offset) { As described above, the built-in chain() function will make all functions exposed on the nightmare prototype chainable, so the 'this' object need not be returned by the extension function. Thus, the above custom action can be called simply by: -``` +```js let scaleFactor = 2.0; let offsetFactor = 1; @@ -572,7 +572,7 @@ let size = yield nightmare.chain() Custom 'namespaces' can be implemented by adding a psudo-class and calling the static function 'registerNamespace': -``` +``` js 'use strict'; Nightmare.prototype.MyStyle = class { *background() { @@ -592,7 +592,7 @@ Nightmare.registerNamespace("MyStyle"); Nightmare v3 will automatically make these chainable as well. -``` +``` js let nightmare = new Nightmare(); let color = yield nightmare.chain() .goto('http://www.github.com') @@ -602,17 +602,15 @@ let color = yield nightmare.chain() Custom electron behaviors can be attached by adding tuples of [ {electron function}, {function} ]to the Nightmare prototype. For instance: -``` +``` js Nightmare.prototype.getTitle = [ function (ns, options, parent, win, renderer) { - parent.on('getTitle', function () { - parent.emit('getTitle', { - result: win.webContents.getTitle() - }); + parent.respondTo('getTitle', function (done) { + done.resolve(win.webContents.getTitle()); }); }, - function (path, saveType) { - return this._invokeRunnerOperation("getTitle", path, saveType); + function () { + return this._invokeRunnerOperation("getTitle"); } ]; @@ -648,6 +646,20 @@ var size = yield new Nightmare().chain() However, what is this is doing is associating a 'size' function property on the Nightmare prototype for you. +.action() can be used to define custom Electron actions like before. + +```js +Nightmare.action('clearCache', + function(name, options, parent, win, renderer) { + parent.respondTo('clearCache', function(done) { + win.webContents.session.clearCache(done.resolve); + }); + }, + function(message) { + return this._invokeRunnerOperation("clearCache"); + } +``` + Any functions defined on the prototype can be called using the this. object. In Nightmare v3 the only difference between ```evaluate_now``` and ```evaluate``` is that evaluate checks that the argument passed is a function. Both return promises. We can also create custom namespaces. We do this internally for `nightmare.cookies.get` and `nightmare.cookies.set`. These are useful if you have a bundle of actions you want to expose, but it will clutter up the main nightmare object. Here's an example of that: diff --git a/actions/cookies.js b/actions/cookies.js index 25fdfb03..711deb74 100644 --- a/actions/cookies.js +++ b/actions/cookies.js @@ -15,17 +15,15 @@ Nightmare.prototype.cookies = (function () { }); Nightmare.prototype.cookies.prototype.get = [ function (ns, options, parent, win, renderer) { const _ = require("lodash"); - parent.on('cookie.get', function (query) { + parent.respondTo('cookie.get', function (query, done) { var details = _.assign({}, { url: win.webContents.getURL(), }, query); parent.emit('log', 'getting cookie: ' + JSON.stringify(details)); win.webContents.session.cookies.get(details, function (err, cookies) { - if (err) return parent.emit('cookie.get', err); - parent.emit('cookie.get', { - result: details.name ? cookies[0] : cookies - }); + if (err) return done.reject(err); + done.resolve(details.name ? cookies[0] : cookies); }); }); }, @@ -48,7 +46,7 @@ Nightmare.prototype.cookies.prototype.get = [ Nightmare.prototype.cookies.prototype.set = [ function (ns, options, parent, win, renderer) { const _ = require("lodash"); - parent.on('cookie.set', function (cookies) { + parent.respondTo('cookie.set', function (cookies, done) { var pending = cookies.length; for (var cookie of cookies) { var details = _.assign({}, { @@ -57,10 +55,8 @@ Nightmare.prototype.cookies.prototype.set = [ parent.emit('log', 'setting cookie: ' + JSON.stringify(details)); win.webContents.session.cookies.set(details, function (err) { - if (err) parent.emit('cookie.set', { - error: err - }); - else if (!--pending) parent.emit('cookie.set'); + if (err) return done.reject(err); + if (!--pending) done.resolve(); }); } }); @@ -90,7 +86,7 @@ Nightmare.prototype.cookies.prototype.set = [ */ Nightmare.prototype.cookies.prototype.clear = [ function (ns, options, parent, win, renderer) { - parent.on('cookie.clear', function (cookies) { + parent.respondTo('cookie.clear', function (cookies,done) { var pending = cookies.length; parent.emit('log', 'listing params', cookies); @@ -100,10 +96,8 @@ Nightmare.prototype.cookies.prototype.clear = [ parent.emit('log', 'clearing cookie: ' + JSON.stringify(cookie)); win.webContents.session.cookies.remove(url, name, function (err) { - if (err) parent.emit('cookie.clear', { - error: err, - }); - else if (!--pending) parent.emit('cookie.clear'); + if (err) return done.reject(err); + if (!--pending) done.resolve(); }); } }); diff --git a/actions/core.js b/actions/core.js index 0cf5c0e5..cb61cc78 100644 --- a/actions/core.js +++ b/actions/core.js @@ -92,13 +92,12 @@ Nightmare.prototype.getClientRects = function (selector) { */ Nightmare.prototype.html = [ function (ns, options, parent, win, renderer) { - parent.on('html', function (path, saveType) { + parent.respondTo('html', function (path, saveType, done) { // https://github.com/atom/electron/blob/master/docs/api/web-contents.md#webcontentssavepagefullpath-savetype-callback saveType = saveType || 'HTMLComplete'; win.webContents.savePage(path, saveType, function (err) { - parent.emit('html', { - err: err - }); + if (err) return done.reject(err); + done.resolve(); }); }); }, @@ -117,7 +116,7 @@ Nightmare.prototype.html = [ Nightmare.prototype.pdf = [ function (ns, options, parent, win, renderer) { const _ = require("lodash"); - parent.on('pdf', function (path, options) { + parent.respondTo('pdf', function (path, options, done) { // https://github.com/fraserxu/electron-pdf/blob/master/index.js#L98 options = _.defaults(options, { marginType: 0, @@ -127,10 +126,8 @@ Nightmare.prototype.pdf = [ }); win.webContents.printToPDF(options, function (err, data) { - if (err) return parent.emit('pdf', arguments); - parent.emit('pdf', { - result: data - }); + if (err) return done.reject(err); + done.resolve(data); }); }); }, @@ -160,12 +157,10 @@ Nightmare.prototype.pdf = [ */ Nightmare.prototype.screenshot = [ function (ns, options, parent, win, renderer, frameManager) { - parent.on('screenshot', function (clip) { + parent.respondTo('screenshot', function (clip, done) { // https://gist.github.com/twolfson/0d374d9d7f26eefe7d38 var args = [function handleCapture(img) { - parent.emit('screenshot', { - result: img.toPng() - }); + done.resolve(img.toPng()); }]; if (clip) args.unshift(clip); frameManager.requestFrame(function () { @@ -199,11 +194,9 @@ Nightmare.prototype.screenshot = [ */ Nightmare.prototype.setAudioMuted = [ function (ns, options, parent, win, renderer) { - parent.on('audio', function (audio) { + parent.respondTo('audio', function (audio, done) { win.webContents.setAudioMuted(audio); - parent.emit('audio', { - result: win.webContents.isAudioMuted() - }); + done.resolve(win.webContents.isAudioMuted()); }); }, function (isMuted) { @@ -216,11 +209,11 @@ Nightmare.prototype.setAudioMuted = [ */ Nightmare.prototype.setAuthenticationCredentials = [ function (ns, options, parent, win, renderer) { - parent.on('setAuthenticationCredentials', function (username, password) { + parent.respondTo('setAuthenticationCredentials', function (username, password, done) { win.webContents.on('login', function (webContents, request, authInfo, callback) { callback(username, password); }); - parent.emit('setAuthenticationCredentials'); + done.resolve(); }); }, function (username, password) { @@ -234,10 +227,8 @@ Nightmare.prototype.setAuthenticationCredentials = [ */ Nightmare.prototype.title = [ function (ns, options, parent, win, renderer) { - parent.on('title', function () { - parent.emit("title", { - result: win.webContents.getTitle() - }); + parent.respondTo('title', function (done) { + done.resolve(win.webContents.getTitle()); }); }, function () { @@ -251,10 +242,8 @@ Nightmare.prototype.title = [ */ Nightmare.prototype.url = [ function (ns, options, parent, win, renderer) { - parent.on('url', function () { - parent.emit("url", { - result: win.webContents.getURL() - }); + parent.respondTo('url', function (done) { + done.resolve(win.webContents.getURL()); }); }, function () { @@ -270,9 +259,9 @@ Nightmare.prototype.url = [ */ Nightmare.prototype.useragent = [ function (ns, options, parent, win, renderer) { - parent.on('useragent', function (useragent) { + parent.respondTo('useragent', function (useragent, done) { win.webContents.setUserAgent(useragent); - parent.emit('useragent'); + done.resolve(); }); }, function (useragent) { @@ -289,9 +278,9 @@ Nightmare.prototype.useragent = [ */ Nightmare.prototype.viewport = [ function (ns, options, parent, win, renderer) { - parent.on('size', function (width, height) { + parent.respondTo('size', function (width, height, done) { win.setSize(width, height); - parent.emit("size"); + done.resolve(); }); }, function (width, height) { @@ -342,7 +331,7 @@ Nightmare.prototype.wait = function () { let fn = function (selector) { var element = document.querySelector(selector); return (element ? true : false); - } + }; return this.waitUntilTrue(fn, arg); } else if (_.isFunction(arg)) { @@ -371,7 +360,7 @@ Nightmare.prototype.waitUntilTrue = function (fn/**, arg1, arg2...**/) { let testResult = false; do { testResult = yield self.evaluate_now.apply(self, args); - } while (!testResult) + } while (!testResult); }); return Promise.race([check, timeout]); @@ -382,7 +371,7 @@ Nightmare.prototype.waitUntilTrue = function (fn/**, arg1, arg2...**/) { */ Nightmare.prototype.waitUntilFinishLoad = [ function (ns, options, parent, win, renderer) { - parent.on("waitUntilFinishLoad", function () { + parent.respondTo("waitUntilFinishLoad", function (done) { var start; var init = new Promise(function (resolve, reject) { @@ -417,13 +406,9 @@ Nightmare.prototype.waitUntilFinishLoad = [ win.webContents.once('did-finish-load', resolveGoto); }); }).then(function (url) { - parent.emit('waitUntilFinishLoad', { - result: url - }); + done.resolve(url); }, function (message) { - parent.emit('waitUntilFinishLoad', { - error: message - }); + done.reject(message); }); start(); diff --git a/actions/input.js b/actions/input.js index b96565e4..2cd0e4d8 100644 --- a/actions/input.js +++ b/actions/input.js @@ -83,7 +83,7 @@ Nightmare.prototype.emulateClick = [ function (ns, options, parent, win, renderer) { const _ = require("lodash"); //Retrieves the specified element from clickOpts.selector and clicks it using webContents.sendInputEvent. - parent.on('emulateClick', function (clickOpts) { + parent.respondTo('emulateClick', function (clickOpts, done) { const _ = require("lodash"); clickOpts = _.defaults(clickOpts, { button: "left", @@ -97,9 +97,7 @@ Nightmare.prototype.emulateClick = [ win.webContents.sendInputEvent({ type: 'mouseDown', x: x, y: y, button: clickOpts.button, clickCount: clickOpts.clickCount }); setTimeout(function () { win.webContents.sendInputEvent({ type: 'mouseUp', x: x, y: y, button: clickOpts.button, clickCount: clickOpts.clickCount }); - parent.emit("emulateClick", { - result: { x: x, y: y } - }); + done.resolve({ x: x, y: y }); }, clickOpts.clickDelay); }); @@ -142,7 +140,7 @@ Nightmare.prototype.emulateKeystrokes = [ const _ = require("lodash"); const async = require("async"); - parent.on('emulateKeystrokes', function (keystrokeOpts) { + parent.respondTo('emulateKeystrokes', function (keystrokeOpts, done) { keystrokeOpts = _.defaults(keystrokeOpts, { keyCodes: [], keystrokeDelay: 87, @@ -179,7 +177,7 @@ Nightmare.prototype.emulateKeystrokes = [ q.drain = function () { //this is to allow the final keyup to be fired. setTimeout(function () { - parent.emit("emulateKeystrokes"); + done.resolve(); }, keystrokeOpts.finalKeystrokeDelay); }; @@ -265,9 +263,9 @@ Nightmare.prototype.focus = function (selector) { */ Nightmare.prototype.insert = [ function (ns, options, parent, win, renderer) { - parent.on('insert', function (value) { + parent.respondTo('insert', function (value, done) { win.webContents.insertText(String(value)); - parent.emit('insert'); + done.resolve(); }); }, function (selector, text) { @@ -374,15 +372,13 @@ Nightmare.prototype.select = function (selector, option) { */ Nightmare.prototype.type = [ function (ns, options, parent, win, renderer) { - parent.on('type', function (value) { + parent.respondTo('type', function (value, done) { var chars = String(value).split(''); function type() { var ch = chars.shift(); - if (ch === undefined) { - parent.emit('type'); - return; - } + if (ch === undefined) + return done.resolve(); // keydown win.webContents.sendInputEvent({ diff --git a/actions/navigation.js b/actions/navigation.js index 4c45d469..d3eba899 100644 --- a/actions/navigation.js +++ b/actions/navigation.js @@ -8,16 +8,12 @@ const Nightmare = require("../lib/nightmare"); */ Nightmare.prototype.back = [ function (ns, options, parent, win, renderer) { - parent.on('goBack', function () { + parent.respondTo('goBack', function (done) { if (!win.webContents.canGoBack()) { - parent.emit('goBack', { - error: true - }); + done.reject("Browser unable to go back."); } else { win.webContents.once('did-finish-load', function () { - parent.emit('goBack', { - result: win.webContents.getURL() - }); + done.resolve('goBack', win.webContents.getURL()); }); win.webContents.goBack(); } @@ -33,14 +29,12 @@ Nightmare.prototype.back = [ */ Nightmare.prototype.forward = [ function (ns, options, parent, win, renderer) { - parent.on('goForward', function () { + parent.respondTo('goForward', function (done) { if (!win.webContents.canGoForward()) { - parent.emit('goForward', true); + done.reject("Browser unable to go forward."); } else { win.webContents.once('did-finish-load', function () { - parent.emit('goForward', { - result: win.webContents.getURL() - }); + done.resolve(win.webContents.getURL()); }); win.webContents.goForward(); } @@ -64,7 +58,7 @@ Nightmare.prototype.goto = [ const KNOWN_PROTOCOLS = ['http', 'https', 'file', 'about', 'javascript']; - parent.on('goto', function (url, headers) { + parent.respondTo('goto', function (url, headers, done) { var extraHeaders = ''; for (var key in headers) { extraHeaders += key + ': ' + headers[key] + '\n'; @@ -78,22 +72,22 @@ Nightmare.prototype.goto = [ var responseData = {}; let resolveGoto = function (message) { - done({ - result: responseData - }); + cleanup(); + + done.resolve(responseData); }; let rejectGoto = function (event, code, detail, failedUrl, isMainFrame) { if (!isMainFrame) return; - - done({ - err: { - message: 'navigation error', - code: code, - details: detail, - url: failedUrl || url - } + + cleanup(); + + done.reject({ + message: 'navigation error', + code: code, + details: detail, + url: failedUrl || url }); }; @@ -111,12 +105,12 @@ Nightmare.prototype.goto = [ }; }; - let done = function(data) { + let cleanup = function(data) { win.webContents.removeListener('did-fail-load', rejectGoto); win.webContents.removeListener('did-get-response-details', getDetails); win.webContents.removeListener('did-finish-load', resolveGoto); // wait a tick before notifying to resolve race conditions for events - setImmediate(() => parent.emit('goto', data)); + //setImmediate(() => parent.emit('goto', data)); }; // In most environments, loadURL handles this logic for us, but in some @@ -145,14 +139,13 @@ Nightmare.prototype.goto = [ if (protocol === 'javascript:') { setTimeout(function () { if (!win.webContents.isLoading()) { - done({ - result: { - url: url, - code: 200, - method: 'GET', - referrer: win.webContents.getURL(), - headers: {} - } + cleanup(); + done.resolve({ + url: url, + code: 200, + method: 'GET', + referrer: win.webContents.getURL(), + headers: {} }); } }, 10); @@ -160,13 +153,13 @@ Nightmare.prototype.goto = [ return; } - done({ - err:{ + cleanup(); + done.reject({ message: 'navigation error', code: -300, details: 'ERR_INVALID_URL', url: url - }}); + }); }); } }); @@ -197,9 +190,9 @@ Nightmare.prototype.refresh = function (ns, options, parent, win, renderer) { */ Nightmare.prototype.reload = [ function () { - parent.on('reload', function () { + parent.respondTo('reload', function (done) { win.webContents.reload(); - parent.emit('reload'); + done.resolve(); }); }, function () { @@ -212,9 +205,9 @@ Nightmare.prototype.reload = [ */ Nightmare.prototype.stop = [ function (ns, options, parent, win, renderer) { - parent.on('stop', function () { + parent.respondTo('stop', function (done) { win.webContents.stop(); - parent.emit('stop'); + done.resolve(); }); }, function () { diff --git a/lib/ipc.js b/lib/ipc.js index a02d4fc9..9c8dd1cd 100644 --- a/lib/ipc.js +++ b/lib/ipc.js @@ -6,6 +6,15 @@ const Emitter = require('events').EventEmitter; //Emitter.defaultMaxListeners = 0; +let debug = require('debug')('nightmare:ipc'); + +// If this process has a parent, redirect debug logs to it +if (process.send) { + debug = function () { + process.send(['nightmare:ipc:debug'].concat(Array.from(arguments))); + }; +} + /** * Export `IPC` */ @@ -15,25 +24,133 @@ module.exports = IPC; /** * Initialize `IPC` */ +const instance = Symbol(); function IPC(process) { - var emitter = new Emitter(); - var emit = emitter.emit; + if (process[instance]) { + return process[instance]; + } + + var emitter = process[instance] = new Emitter(); + let emit = emitter.emit; + let callId = 0; + let responders = {}; // no parent if (!process.send) { return emitter; } - process.on('message', function(data) { + process.on('message', function (data) { + // handle debug logging specially + if (data[0] === 'nightmare:ipc:debug') { + debug.apply(null, Array.from(data).slice(1)); + } emit.apply(emitter, Array.from(data)); }); - emitter.emit = function() { - if(process.connected){ - process.send(Array.from(arguments)); + emitter.emit = function () { + if (process.connected) { + process.send(Array.from(arguments)); + } + }; + + /** + * Call a responder function in the associated process. (In the process, + * responders can be registered with `ipc.respondTo()`.) + * This returns a Promise that has a progress property that exposes a event emitter. + * You can listen for the results of the responder using the `end` event (this is the same as passing a callback). + * Additionally, you can listen for `data` events, which the responder may + * send to indicate some sort of progress. + * @param {String} name Name of the responder function to call + * @param {...Objects} [arguments] Any number of arguments to send + * @return {Promise} + */ + emitter.call = function (name) { + let args = Array.from(arguments).slice(1); + let id = callId++; + + let finalResolve, finalReject; + let promise = new Promise(function (resolve, reject) { + finalResolve = resolve; + finalReject = reject; + }); + + promise.progress = new Emitter(); + + emitter.on(`CALL_DATA_${id}`, function (args) { + let argsArray = Object.keys(args).map(key => args[key]); + promise.progress.emit.apply(promise.progress, ['data'].concat(argsArray)); + }); + + let finalize = function (args) { + promise.progress.emit.apply(promise.progress, ['end'].concat(args)); + emitter.removeAllListeners(`CALL_DATA_${id}`); + emitter.removeAllListeners(`CALL_ERROR_${id}`); + emitter.removeAllListeners(`CALL_RESULT_${id}`); + promise.progress.removeAllListeners(); + promise.progress = undefined; + }; + + emitter.once(`CALL_ERROR_${id}`, function (args) { + let argsArray = Object.keys(args).map(key => args[key]); + finalize(argsArray); + finalReject.apply(null, argsArray); + }); + + emitter.once(`CALL_RESULT_${id}`, function (args) { + let argsArray = Object.keys(args).map(key => args[key]); + finalize(argsArray); + finalResolve.apply(null, argsArray); + }); + + emitter.emit.apply(emitter, ['CALL', id, name].concat(args)); + return promise; + }; + + /** + * Register a responder to be called from other processes with `ipc.call()`. + * The responder should be a function that accepts any number of arguments, + * where the last argument is a callback function. When the responder has + * finished its work, it MUST call the callback. The first argument should be + * an error, if any, and the second should be the results. + * Only one responder can be registered for a given name. + * @param {String} name The name to register the responder under. + * @param {Function} responder + */ + emitter.respondTo = function (name, responder) { + if (responders[name]) { + debug(`Replacing responder named "${name}"`); } + responders[name] = responder; }; + emitter.on('CALL', function (id, name) { + var args = Array.from(arguments).slice(2); + var responder = responders[name]; + var done = { + resolve: function () { + emitter.emit(`CALL_RESULT_${id}`, arguments); + }, + reject: function () { + emitter.emit(`CALL_ERROR_${id}`, arguments); + }, + progress: function () { + emitter.emit(`CALL_DATA_${id}`, arguments); + } + }; + + if (!responder) { + return done.reject(`Nothing responds to "${name}"`); + } + + try { + responder.apply(null, Array.from(arguments).slice(2).concat([done])); + } + catch (error) { + done.reject(error); + } + }); + return emitter; } diff --git a/lib/nightmare.js b/lib/nightmare.js index 7ccaac0c..1aa7e1ed 100644 --- a/lib/nightmare.js +++ b/lib/nightmare.js @@ -21,7 +21,6 @@ const co = require("co"); const async = require("async"); const delay = require("delay"); const fs = require('fs'); -const shortid = require("shortid"); const split2 = require('split2'); const util = require("util"); @@ -93,12 +92,6 @@ class Nightmare { this._headers = {}; } - _noop() { - return new Promise(function (resolve, reject) { - resolve(); - }); - }; - _endInstance(instance) { debug('_endInstance() starting.'); this._detachFromProcess(instance); @@ -140,46 +133,13 @@ class Nightmare { } _invokeRunnerOperation(operationName) { - if (this.state !== "ready") throw "Nightmare is not ready. Did you forget to call init()?"; - var runnerRequestMessageName, runnerResponseMessageName; - if (_.isObject(operationName)) { - runnerRequestMessageName = operationName.runnerRequestMessageName; - runnerResponseMessageName = operationName.runnerResponseMessageName ? operationName.runnerResponseMessageName : operationName.runnerRequestMessageName; - } - else { - runnerRequestMessageName = operationName; - runnerResponseMessageName = operationName; - } - let child = this.child; - let p = new Promise(function (resolve, reject) { - verbose('._invokeRunnerOperation() waiting for %s', runnerResponseMessageName); - - child.once(runnerResponseMessageName, function (result) { - if (result && !_.isUndefined(result.err)) { - verbose('._invokeRunnerOperation() %s failed.', runnerResponseMessageName); - reject(result.err); - return; - } - verbose('._invokeRunnerOperation() %s succeeded.', runnerResponseMessageName); - if (result && !_.isUndefined(result.result)) { - resolve(result.result); - return; - } - - resolve(undefined); - }); - }); - let args = Array.from(arguments); - args[0] = runnerRequestMessageName; - - verbose('._invokeRunnerOperation() invoking %s', runnerRequestMessageName); - child.emit.apply(this, args); - return p; + verbose('._invokeRunnerOperation() invoking %s', operationName); + return child.call.apply(this, Array.from(arguments)); } /** @@ -192,7 +152,7 @@ class Nightmare { /** * Creates a nightmare object which can be used to chain a number of actions sequentally. */ - chain() { + chain(initOpts) { let self = this; let chainArgs = Array.from(arguments); @@ -329,7 +289,7 @@ class Nightmare { if (self.state != "ready") { debug("chain() called before init() queueing init"); - initialPromise.init(); + initialPromise.init(initOpts); } return initialPromise; @@ -350,12 +310,7 @@ class Nightmare { debug('.evaluate_now() fn on the page'); let js = "(" + template.evaluate + "(" + (String(fn)) + ", " + (JSON.stringify(args)) + "))"; - var id = shortid.generate(); - var operationParams = { - runnerRequestMessageName: "javascript", - runnerResponseMessageName: "javascript " + id - }; - return this._invokeRunnerOperation(operationParams, js, false, id); + return this._invokeRunnerOperation("javascript", js, false); } /* @@ -366,12 +321,7 @@ class Nightmare { debug('.evaluate_async() fn on the page'); let js = "(" + template.evaluateAsync + "(" + (String(fn)) + ", " + (JSON.stringify(args)) + "))"; - var id = shortid.generate(); - var operationParams = { - runnerRequestMessageName: "javascript", - runnerResponseMessageName: "javascript " + id - }; - return this._invokeRunnerOperation(operationParams, js, true, id); + return this._invokeRunnerOperation("javascript", js, true); } /** @@ -384,14 +334,19 @@ class Nightmare { this._headers = header; } - return this._noop(); + return Promise.resolve(); }; /* * Initializes the nightmare */ - init() { + init(opts) { debug('.init() starting'); + + opts = _.defaultsDeep(opts, { + onChildReady: undefined + }); + this.proc = proc.spawn(this._options.electronPath, [runnerPath].concat(JSON.stringify(this._options.electronArgs)), { stdio: [null, null, null, 'ipc'] }); @@ -415,14 +370,6 @@ class Nightmare { }); }); - let browserInitializePromise = new Promise(function (resolve, reject) { - child.once('browser-initialize', function (err, result) { - if (err) - reject(err); - resolve(result); - }); - }); - // propagate console.log(...) through child.on('log', function () { log.apply(log, arguments); @@ -459,9 +406,13 @@ class Nightmare { child.on('destroyed', function () { eventLog('destroyed', JSON.stringify(Array.from(arguments))); }); self.child = child; - + self.state = "childReady"; + if (_.isFunction(self.childReady)) self.childReady(); + + if (_.isFunction(opts.onChildReady)) + opts.onChildReady(); return co(function* () { self.initializeNamespaces(); @@ -469,9 +420,7 @@ class Nightmare { self._engineVersions = yield readyPromise; - self.child.emit('browser-initialize', self._options); - - yield browserInitializePromise; + yield self.child.call('browser-initialize', self._options); debug('.init() now ready.'); self.state = "ready"; @@ -540,16 +489,10 @@ class Nightmare { initializeElectronActions() { debug('.initializeElectronActions() starting'); if (_.isUndefined(Nightmare._electronActions)) { - debug('.initializeElectronActions() no namespaces defined.'); + debug('.initializeElectronActions() no electron actions defined.'); return; } - var id = shortid.generate(); - var operationParams = { - runnerRequestMessageName: "electronAction", - runnerResponseMessageName: "electronAction " + id - }; - let electronActions = []; let promises = []; for (let electronActionName of _.keys(Nightmare._electronActions)) { @@ -561,7 +504,7 @@ class Nightmare { }); } - return this._invokeRunnerOperation(operationParams, electronActions, null, id).then(function () { + return this._invokeRunnerOperation("electronAction", electronActions, null).then(function () { debug('.initializeElectronActions() completed.'); }); }; @@ -578,7 +521,7 @@ class Nightmare { let innerJS = fs.readFileSync(file, { encoding: 'utf-8' }); let js = "(" + template.inject + "(function(){" + innerJS + "}))"; - return this._invokeRunnerOperation("javascript", js); + return this._invokeRunnerOperation("javascript", js, false); } else if (type === 'css') { let css = fs.readFileSync(file, { encoding: 'utf-8' }); @@ -594,11 +537,11 @@ class Nightmare { * on */ on(event, handler) { - if (this.state !== "ready") + if (this.state !== "childReady" && this.state !== "ready") throw "Nightmare is not ready. Did you forget to call init()?"; this.child.on(event, handler); - return this._noop(); + return Promise.resolve(); }; /** diff --git a/lib/runner.js b/lib/runner.js index 615c5daa..a1a00bf0 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -65,12 +65,12 @@ if (!processArgs.dock && app.dock) { app.on('ready', function () { let win, frameManager, options; - + /** * create a browser window */ - parent.on('browser-initialize', function (opts) { + parent.respondTo('browser-initialize', function (opts, done) { options = _.defaults(opts, { show: false, alwaysOnTop: true, @@ -139,14 +139,13 @@ app.on('ready', function () { win.webContents.on('plugin-crashed', forward('plugin-crashed')); win.webContents.on('destroyed', forward('destroyed')); - - parent.emit('browser-initialize'); + done.resolve(); }); /** * javascript */ - parent.on('javascript', function (code, isAsync, responseId) { + parent.respondTo('javascript', function (code, isAsync, done) { var logForwarder = function (event, args) { parent.emit.apply(parent, ['log'].concat(args)); @@ -154,40 +153,42 @@ app.on('ready', function () { renderer.on('log', logForwarder); - var parentMessageName = "javascript"; - if (responseId) - parentMessageName += " " + responseId; - - parent.emit("log", parentMessageName); win.webContents.executeJavaScript(code, function (result) { renderer.removeListener("log", logForwarder); - //If we're not async, we're done at this point. - if (!isAsync) { - parent.emit(parentMessageName, result); - return; + if (isAsync) { + //wait until the script emits a 'javascript' event. + renderer.once('javascript', function (event, result) { + if (result.err) { + done.reject(result.err); + } else { + done.resolve(result.result); + } + }); + } else { + //We're not async, we're done at this point. + if (result.err) { + done.reject(result.err); + } else { + done.resolve(result.result); + } } - - //otherwise, wait until the script emits a 'javascript' event. - renderer.once('javascript', function (event, result) { - parent.emit(parentMessageName, result); - }); }); }); /** * insert css */ - parent.on('insertCSS', function (css) { + parent.respondTo('insertCSS', function (css, done) { win.webContents.insertCSS(css); - parent.emit("insertCSS"); + done.resolve(); }); /** * Add custom functionality */ - parent.on('electronAction', function (name, fntext, id) { + parent.respondTo('electronAction', function (name, fntext, done) { let actions = []; if (_.isArray(name)) @@ -206,8 +207,7 @@ app.on('ready', function () { }); fn(name, options, parent, win, renderer, frameManager); } - - parent.emit('electronAction ' + id); + done.resolve(); }); /** diff --git a/package.json b/package.json index b0c4ed0f..ad833b0f 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "delay": "^1.3.1", "electron-prebuilt": "^0.37.8", "lodash": "^4.11.2", - "shortid": "^2.2.6", "split2": "^2.0.1" }, "devDependencies": { diff --git a/test/index.js b/test/index.js index 92685bd1..dc24d2e9 100644 --- a/test/index.js +++ b/test/index.js @@ -6,6 +6,7 @@ require('mocha-generators').install(); var Nightmare = require('..'); +var IPC = require('../lib/ipc'); var chai = require('chai'); var asPromised = require('chai-as-promised'); var url = require('url'); @@ -1651,14 +1652,14 @@ describe('Nightmare', function () { it('should support extending Electron', function* () { Nightmare.action('bind', function (ns, options, parent, win, renderer) { - parent.on('bind', function (name) { + parent.respondTo('bind', function (name, done) { "use strict"; if (renderer.listeners(name).length === 0) { renderer.on(name, function () { parent.emit.apply(parent, [name].concat(Array.from(arguments).slice(1))); }); } - parent.emit('bind'); + done.resolve(); }); }, function (name, handler) { @@ -1666,11 +1667,8 @@ describe('Nightmare', function () { if (handler) { child.on(name, handler); } - var p = new Promise(function (resolve, reject) { - child.once('bind', resolve); - }); - child.emit('bind', name); - return p; + + return child.call ('bind', name); }); var eventResults; @@ -1819,14 +1817,12 @@ describe('Nightmare', function () { Nightmare.prototype.getTitle = [ function (ns, options, parent, win, renderer) { - parent.on('getTitle', function () { - parent.emit('getTitle', { - result: win.webContents.getTitle() - }); + parent.respondTo('getTitle', function (done) { + done.resolve(win.webContents.getTitle()); }); }, - function (path, saveType) { - return this._invokeRunnerOperation("getTitle", path, saveType); + function () { + return this._invokeRunnerOperation("getTitle"); } ]; @@ -1840,14 +1836,12 @@ describe('Nightmare', function () { Nightmare.prototype.getTitle = [ function (ns, options, parent, win, renderer) { - parent.on('getTitle', function () { - parent.emit('getTitle', { - result: win.webContents.getTitle() - }); + parent.respondTo('getTitle', function (done) { + done.resolve(win.webContents.getTitle()); }); }, - function (path, saveType) { - return this._invokeRunnerOperation("getTitle", path, saveType); + function () { + return this._invokeRunnerOperation("getTitle"); } ]; @@ -1863,14 +1857,12 @@ describe('Nightmare', function () { Nightmare.prototype.MyTitle = (function () { }); Nightmare.prototype.MyTitle.prototype.getTitle = [ function (ns, options, parent, win, renderer) { - parent.on('getTitle', function () { - parent.emit('getTitle', { - result: win.webContents.getTitle() - }); + parent.respondTo('getTitle', function (done) { + done.resolve(win.webContents.getTitle()); }); }, - function (path, saveType) { - return this._invokeRunnerOperation("getTitle", path, saveType); + function () { + return this._invokeRunnerOperation("getTitle"); } ]; @@ -1887,14 +1879,12 @@ describe('Nightmare', function () { Nightmare.prototype.MyTitle = (function () { }); Nightmare.prototype.MyTitle.prototype.getTitle = [ function (ns, options, parent, win, renderer) { - parent.on('getTitle', function () { - parent.emit('getTitle', { - result: win.webContents.getTitle() - }); + parent.respondTo('getTitle', function (done) { + done.resolve(win.webContents.getTitle()); }); }, - function (path, saveType) { - return this._invokeRunnerOperation("getTitle", path, saveType); + function () { + return this._invokeRunnerOperation("getTitle"); } ]; @@ -2022,10 +2012,8 @@ describe('Nightmare', function () { beforeEach(function () { Nightmare.action('waitForDevTools', function (ns, options, parent, win, renderer) { - parent.on('waitForDevTools', function () { - parent.emit('waitForDevTools', { - result: win.webContents.isDevToolsOpened() - }); + parent.respondTo('waitForDevTools', function (done) { + done.resolve(win.webContents.isDevToolsOpened()); }); }, function () { @@ -2048,6 +2036,119 @@ describe('Nightmare', function () { devToolsOpen.should.be.true; }); }); + + describe('ipc', function () { + let nightmare; + beforeEach(function* () { + Nightmare.action('test', + function (_, __, parent, ___, ____) { + parent.respondTo('test', function (arg1, done) { + done.progress('one'); + done.progress('two'); + if (arg1 === 'error') { + done.reject('Error!'); + } + else { + done.resolve(`Got ${arg1}`); + } + }); + }, + function (options) { + + var promise = this.child.call('test', options.arg || options); + if (options.onData) { + promise.progress.on('data', options.onData); + } + + if (options.onEnd) { + promise.progress.on('end', options.onEnd); + } + + return promise; + }); + Nightmare.action('noImplementation', + function () { + return this.child.call('noImplementation'); + }); + nightmare = new Nightmare(); + yield nightmare.init(); + }); + + afterEach(function () { + nightmare.end(); + }); + + it('should only make one IPC instance per process', function () { + var processStub = { send: function () { }, on: function () { } }; + var ipc1 = IPC(processStub); + var ipc2 = IPC(processStub); + ipc1.should.equal(ipc2); + }); + + it('should support basic call-response', function* () { + var result = yield nightmare.test('x'); + result.should.equal('Got x'); + }); + + it('should support errors across IPC', function (done) { + nightmare.test('error').then( + function () { + done.reject(new Error('Action succeeded when it should have errored!')); + }, + function () { + done(); + }); + }); + + it('should stream progress', function* () { + var progress = []; + yield nightmare.test({ + arg: 'x', + onData: (data) => progress.push(data), + onEnd: (data) => progress.push(data) + }); + progress.should.deep.equal(['one', 'two', 'Got x']); + }); + + it('should trigger error if no responder is registered', function (done) { + nightmare.noImplementation().then( + function () { + done(new Error('Action succeeded when it should have errored!')); + }, + function () { + done(); + }); + }); + + it('should log a warning when replacing a responder', function* () { + Nightmare.action('uhoh', + function (_, __, parent, ___, ____) { + parent.respondTo('test', function (done) { + done.resolve(); + }); + }, + function () { + return this.child.call('test'); + }); + + let logged = false; + let nightmare = new Nightmare(); + + var result = yield nightmare.chain({ + onChildReady: function () { + nightmare.on('nightmare:ipc:debug', function (message) { + if (message.toLowerCase().indexOf('replacing') > -1) { + logged = true; + } + }); + } + }) + .goto('about:blank') + .end(); + + logged.should.be.true; + }); + }); }); /**