From 4368842da73d4ed501df21b61daf71951e59524b Mon Sep 17 00:00:00 2001 From: Hank Duan Date: Wed, 10 Dec 2014 14:50:03 -0800 Subject: [PATCH] feat(wddebugger): enable repl (with autocomplete) for browser.pause --- lib/debugger/commandRepl.js | 119 +++++++++++++++++++++++++++ lib/debugger/debuggerRepl.js | 128 +++++++++++++++++++++++++++++ lib/debugger/wddebugger.js | 126 ++++++++++++++++++++++++++++ lib/protractor.js | 93 ++++++++++++++++++++- lib/wddebugger.js | 155 ----------------------------------- 5 files changed, 463 insertions(+), 158 deletions(-) create mode 100644 lib/debugger/commandRepl.js create mode 100644 lib/debugger/debuggerRepl.js create mode 100644 lib/debugger/wddebugger.js delete mode 100644 lib/wddebugger.js diff --git a/lib/debugger/commandRepl.js b/lib/debugger/commandRepl.js new file mode 100644 index 000000000..2676ed2ae --- /dev/null +++ b/lib/debugger/commandRepl.js @@ -0,0 +1,119 @@ +var REPL_INITIAL_SUGGESTIONS = [ + 'element(by.id(\'\'))', + 'element(by.css(\'\'))', + 'element(by.name(\'\'))', + 'element(by.binding(\'\'))', + 'element(by.xpath(\'\'))', + 'element(by.tagName(\'\'))', + 'element(by.className(\'\'))' +]; + +/** + * Repl to interactively run code. + * + * @param {Client} node debugger client. + * @constructor + */ +var CommandRepl = function(client) { + this.client = client; + this.prompt = '> '; +}; + +/** + * Eval function for processing a single step in repl. + * Call callback with the result when complete. + * + * @public + * @param {string} expression + * @param {function} callback + */ +CommandRepl.prototype.stepEval = function(expression, callback) { + expression = expression.replace(/"/g, '\\\"'); + var expr = 'browser.dbgCodeExecutor_.execute("' + expression + '")'; + this.evaluate_(expr, callback); +}; + +/** + * Autocomplete user entries. + * Call callback with the suggestions. + * + * @public + * @param {string} line Initial user entry + * @param {function} callback + */ +CommandRepl.prototype.complete = function(line, callback) { + if (line === '') { + callback(null, [REPL_INITIAL_SUGGESTIONS, '']); + } else { + line = line.replace(/"/g, '\\\"'); + var expr = 'browser.dbgCodeExecutor_.complete("' + line + '")'; + this.evaluate_(expr, callback); + } +}; + +/** + * Helper function to evaluate an expression remotely, and callback with + * the result. The expression can be a promise, in which case, the method + * will wait for the result and callback with the resolved value. + * + * @private + * @param {string} expression Expression to evaluate + * @param {function} callback + */ +CommandRepl.prototype.evaluate_ = function(expression, callback) { + var self = this; + var onbreak_ = function() { + self.client.req({ + command: 'evaluate', + arguments: { + frame: 0, + maxStringLength: 1000, + expression: 'browser.dbgCodeExecutor_.resultReady()' + } + }, function(err, res) { + // If code finished executing, get result. + if (res.value) { + self.client.req({ + command: 'evaluate', + arguments: { + frame: 0, + maxStringLength: 2000, + expression: 'browser.dbgCodeExecutor_.getResult()' + } + }, function(err, res) { + try { + var result = res.value === undefined ? + undefined : JSON.parse(res.value); + callback(err, result); + } catch(e) { + callback(e, null); + } + self.client.removeListener('break', onbreak_); + }); + } else { + // If we need more loops for the code to finish executing, continue + // until the next execute step. + self.client.reqContinue(function() { + // Intentionally blank. + }); + } + }); + }; + + this.client.on('break', onbreak_); + + this.client.req({ + command: 'evaluate', + arguments: { + frame: 0, + maxStringLength: 1000, + expression: expression + } + }, function() { + self.client.reqContinue(function() { + // Intentionally blank. + }); + }); +}; + +module.exports = CommandRepl; diff --git a/lib/debugger/debuggerRepl.js b/lib/debugger/debuggerRepl.js new file mode 100644 index 000000000..477d62658 --- /dev/null +++ b/lib/debugger/debuggerRepl.js @@ -0,0 +1,128 @@ +var util = require('util'); + +var DBG_INITIAL_SUGGESTIONS = + ['repl', 'c', 'frame', 'scopes', 'scripts', 'source', 'backtrace', 'd']; + +/** + * Repl to step through code. + * + * @param {Client} node debugger client. + * @constructor + */ +var DebuggerRepl = function(client) { + this.client = client; + this.prompt = 'wd-debug> '; +}; + +/** + * Eval function for processing a single step in repl. + * Call callback with the result when complete. + * + * @public + * @param {string} cmd + * @param {function} callback + */ +DebuggerRepl.prototype.stepEval = function(cmd, callback) { + switch (cmd) { + case 'c': + this.printControlFlow_(callback); + this.client.reqContinue(function() { + // Intentionally blank. + }); + break; + case 'frame': + this.client.req({command: 'frame'}, function(err, res) { + console.log(util.inspect(res, {colors: true})); + callback(); + }); + break; + case 'scopes': + this.client.req({command: 'scopes'}, function(err, res) { + console.log(util.inspect(res, {depth: 4, colors: true})); + callback(); + }); + break; + case 'scripts': + this.client.req({command: 'scripts'}, function(err, res) { + console.log(util.inspect(res, {depth: 4, colors: true})); + callback(); + }); + break; + case 'source': + this.client.req({command: 'source'}, function(err, res) { + console.log(util.inspect(res, {depth: 4, colors: true})); + callback(); + }); + break; + case 'backtrace': + this.client.req({command: 'backtrace'}, function(err, res) { + console.log(util.inspect(res, {depth: 4, colors: true})); + callback(); + }); + break; + case 'd': + this.client.req({command: 'disconnect'}, function() { + // Intentionally blank. + }); + break; + default: + console.log('Unrecognized command.'); + callback(); + break; + } +}; + +/** + * Autocomplete user entries. + * Call callback with the suggestions. + * + * @public + * @param {string} line Initial user entry + * @param {function} callback + */ +DebuggerRepl.prototype.complete = function(line, callback) { + var suggestions = DBG_INITIAL_SUGGESTIONS.filter(function(suggestion) { + return suggestion.indexOf(line) === 0; + }); + callback(null, [suggestions, line]); +}; + +/** + * Print the controlflow. + * + * @private + * @param {function} callback + */ +DebuggerRepl.prototype.printControlFlow_ = function(callback) { + var self = this; + var onBreak_ = function() { + self.client.req({ + command: 'evaluate', + arguments: { + frame: 0, + maxStringLength: 2000, + expression: 'protractor.promise.controlFlow().getControlFlowText()' + } + }, function(err, controlFlowResponse) { + if (!err) { + self.client.req({ + command: 'evaluate', + arguments: { + frame: 0, + maxStringLength: 1000, + expression: 'command.getName()' + } + }, function(err, res) { + if (res.value) { + console.log('-- Next command: ' + res.value); + } + console.log(controlFlowResponse.value); + callback(); + }); + } + }); + }; + this.client.once('break', onBreak_); +}; + +module.exports = DebuggerRepl; diff --git a/lib/debugger/wddebugger.js b/lib/debugger/wddebugger.js new file mode 100644 index 000000000..f6ec41fe0 --- /dev/null +++ b/lib/debugger/wddebugger.js @@ -0,0 +1,126 @@ +var repl = require('repl'); +var baseDebugger = require('_debugger'); +var CommandRepl = require('./commandRepl'); +var DebuggerRepl = require('./debuggerRepl'); + +/** + * BETA BETA BETA + * Custom protractor debugger which steps through one control flow task + * at a time. + * + * @constructor + */ +var WdDebugger = function() { + this.client = new baseDebugger.Client(); + this.replServer; + + // repl is broken into 'command repl' and 'debugger repl'. + this.cmdRepl; + this.dbgRepl; + // currentRepl is a pointer to one of them. + this.currentRepl; +}; + +/** + * Initiate debugger client. + * @private + */ +WdDebugger.prototype.initClient_ = function() { + var client = this.client; + + client.once('ready', function() { + console.log(' ready\n'); + + client.setBreakpoint({ + type: 'scriptRegExp', + target: 'selenium-webdriver/executors.js', + line: 37 + }, function() { + console.log('press c to continue to the next webdriver command'); + console.log('press d to continue to the next debugger statement'); + console.log('type "repl" to enter interactive mode'); + console.log('type "exit" to break out of interactive mode'); + console.log('press ^C to exit'); + console.log(); + }); + }); + + var host = 'localhost'; + var port = process.argv[2] || 5858; + client.connect(port, host); // TODO - might want to add retries here. +}; + +/** + * Eval function for processing a single step in repl. + * @private + * @param {string} cmd + * @param {object} context + * @param {string} filename + * @param {function} callback + */ +WdDebugger.prototype.stepEval_ = function(cmd, context, filename, callback) { + // The loop won't come back until 'callback' is called. + // Strip out the () which the REPL adds and the new line. + // Note - node's debugger gets around this by adding custom objects + // named 'c', 's', etc to the REPL context. They have getters which + // perform the desired function, and the callback is stored for later use. + // Think about whether this is a better pattern. + cmd = cmd.slice(1, cmd.length - 2); + + if (this.currentRepl === this.dbgRepl && cmd === 'repl' || + this.currentRepl === this.cmdRepl && cmd === 'exit') { + // switch repl mode + this.currentRepl = + this.currentRepl === this.dbgRepl ? this.cmdRepl : this.dbgRepl; + this.replServer.prompt = this.currentRepl.prompt; + this.replServer.complete = this.currentRepl.complete.bind(this.currentRepl); + callback(); + } else { + this.currentRepl.stepEval(cmd, callback); + } +}; + +/** + * Instantiate all repl objects, and debuggerRepl as current and start repl. + * @private + */ +WdDebugger.prototype.initRepl_ = function() { + var self = this; + this.cmdRepl = new CommandRepl(this.client); + this.dbgRepl = new DebuggerRepl(this.client); + this.currentRepl = this.dbgRepl; + + // We want the prompt to show up only after the controlflow text prints. + this.dbgRepl.printControlFlow_(function() { + self.replServer = repl.start({ + prompt: self.currentRepl.prompt, + input: process.stdin, + output: process.stdout, + eval: self.stepEval_.bind(self), + useGlobal: false, + ignoreUndefined: true + }); + + self.replServer.complete = self.currentRepl.complete.bind(self.currentRepl); + + self.replServer.on('exit', function() { + console.log('Exiting debugger.'); + self.client.req({command: 'disconnect'}, function() { + // Intentionally blank. + }); + }); + }); +}; + +/** + * Initiate the debugger. + * @public + */ +WdDebugger.prototype.init = function() { + console.log('------- WebDriver Debugger -------'); + this.initClient_(); + this.initRepl_(); +}; + +var wdDebugger = new WdDebugger(); +wdDebugger.init(); diff --git a/lib/protractor.js b/lib/protractor.js index 21b3d007f..93e8cf41b 100644 --- a/lib/protractor.js +++ b/lib/protractor.js @@ -1493,6 +1493,8 @@ Protractor.prototype.debugger = function() { * browser.pause() in your test to enter the protractor debugger from that * point in the control flow. * Does not require changes to the command line (no need to add 'debug'). + * Note, if you are wrapping your own instance of Protractor, you must + * expose globals 'browser' and 'protractor' for pause to work. * * @example * element(by.id('foo')).click(); @@ -1548,17 +1550,102 @@ Protractor.prototype.pause = function(opt_debugPort) { // Call this private function instead of sending SIGUSR1 because Windows. process._debugProcess(process.pid); - var flow = webdriver.promise.controlFlow(); - flow.execute(function() { + var flow = webdriver.promise.controlFlow(); + var pausePromise = flow.execute(function() { log.puts('Starting WebDriver debugger in a child process. Pause is ' + 'still beta, please report issues at github.com/angular/protractor'); var nodedebug = require('child_process'). - fork(__dirname + '/wddebugger.js', [process.debugPort]); + fork(__dirname + '/debugger/wddebugger.js', [process.debugPort]); process.on('exit', function() { nodedebug.kill('SIGTERM'); }); }); + + var vm_ = require('vm'); + var browserUnderDebug = this; + + // Helper used only by './debugger/wddebugger.js' to insert code into the + // control flow. + // In order to achieve this, we maintain a promise at the top of the control + // flow, so that we can insert frames into it. + // To be able to simulate callback/asynchronous code, we poll this object + // for an result at every run of DeferredExecutor.execute. + this.dbgCodeExecutor_ = { + execPromise_: pausePromise, // Promise pointing to current stage of flow. + execPromiseResult_: undefined, // Return value of promise. + execPromiseError_: undefined, // Error from promise. + + // A dummy repl server to make use of its completion function. + replServer_: require('repl').start({ + input: {on: function() {}, resume: function() {}}, // dummy readable stream + output: {write: function() {}} // dummy writable stream + }), + + // Execute a function, which could yield a value or a promise, + // and allow its result to be accessed synchronously + execute_: function(execFn_) { + var self = this; + self.execPromiseResult_ = self.execPromiseError_ = undefined; + + self.execPromise_ = self.execPromise_. + then(execFn_). + then(function(result) { + self.execPromiseResult_ = result; + }, function(err) { + self.execPromiseError_ = err; + }); + + // This dummy command is necessary so that the DeferredExecutor.execute + // break point can find something to stop at instead of moving on to the + // next real command. + self.execPromise_.then(function() { + return browserUnderDebug.executeScript_('', 'empty debugger hook'); + }); + }, + + // Execute a piece of code. + execute: function(code) { + var execFn_ = function() { + // Run code through vm so that we can maintain a local scope which is + // isolated from the rest of the execution. + return vm_.runInThisContext(code); + }; + this.execute_(execFn_); + }, + + // Autocomplete for a line. + complete: function(line) { + var self = this; + var execFn_ = function() { + var deferred = webdriver.promise.defer(); + self.replServer_.complete(line, function(err, res) { + deferred.fulfill(res, err); + }); + return deferred; + }; + this.execute_(execFn_); + }, + + // Code finished executing. + resultReady: function() { + return !this.execPromise_.isPending(); + }, + + // Get asynchronous results synchronously. + // This will throw if result is not ready. + getResult: function() { + if (!this.resultReady()) { + throw new Error('Result not ready'); + } + if (this.execPromiseError_) { + throw this.execPromiseError_; + } + + return JSON.stringify(this.execPromiseResult_); + } + }; + flow.timeout(1000, 'waiting for debugger to attach'); }; diff --git a/lib/wddebugger.js b/lib/wddebugger.js deleted file mode 100644 index a348d0cea..000000000 --- a/lib/wddebugger.js +++ /dev/null @@ -1,155 +0,0 @@ -console.log('------- WebDriver Debugger -------'); - -var util = require('util'); -var repl = require('repl'); -/** - * BETA BETA BETA - * Custom protractor debugger which steps through one control flow task - * at a time. - */ - -// Leave in err, res function parameters. -// jshint unused: false -var baseDebugger = require('_debugger'); - -var client = new baseDebugger.Client(); - -var host = 'localhost'; -var port = 5858; - -if (process.argv[2]) { - port = process.argv[2]; -} -var debuggerRepl; - -var resumeReplCallback = null; -var resume = function() { - if (resumeReplCallback) { - resumeReplCallback(); - } - resumeReplCallback = null; -}; - - -var debugStepperEval = function(cmd, context, filename, callback) { - // The loop won't come back until 'callback' is called. - // Strip out the () which the REPL adds and the new line. - // Note - node's debugger gets around this by adding custom objects - // named 'c', 's', etc to the REPL context. They have getters which - // perform the desired function, and the callback is stored for later use. - // Think about whether this is a better pattern. - cmd = cmd.slice(1, cmd.length - 2); - switch (cmd) { - case 'c': - resumeReplCallback = callback; - client.reqContinue(function(err, res) { - // Intentionally blank. - }); - break; - case 'frame': - client.req({command: 'frame'}, function(err, res) { - console.log('frame response: ' + util.inspect(res)); - callback(null, 1); - }); - break; - case 'scopes': - client.req({command: 'scopes'}, function(err, res) { - console.log('scopes response: ' + util.inspect(res, {depth: 4})); - callback(null, 1); - }); - break; - case 'scripts': - client.req({command: 'scripts'}, function(err, res) { - console.log('scripts response: ' + util.inspect(res, {depth: 4})); - callback(null, 1); - }); - break; - case 'source': - client.req({command: 'source'}, function(err, res) { - console.log('source response: ' + util.inspect(res, {depth: 4})); - callback(null, 1); - }); - break; - case 'backtrace': - client.req({command: 'backtrace'}, function(err, res) { - console.log('backtrace response: ' + util.inspect(res, {depth: 4})); - callback(null, 1); - }); - break; - case 'd': - client.req({command: 'disconnect'}, function(err, res) {}); - callback(null, 1); - break; - default: - console.log('Unrecognized command.'); - callback(null, undefined); - break; - } -}; - -var replOpts = { - prompt: 'wd-debug> ', - input: process.stdin, - output: process.stdout, - eval: debugStepperEval, - useGlobal: false, - ignoreUndefined: true -}; - -var initializeRepl = function() { - debuggerRepl = repl.start(replOpts); - - debuggerRepl.on('exit', function() { - process.exit(0); - }); -}; - -client.once('ready', function() { - console.log(' ready\n'); - - client.setBreakpoint({ - type: 'scriptRegExp', - target: 'selenium-webdriver/executors.js', - line: 37 - }, - function(err, res) { - console.log('press c to continue to the next webdriver command'); - console.log('press d to continue to the next debugger statement'); - console.log('press ^C to exit'); - }); -}); - -// TODO - might want to add retries here. -client.connect(port, host); - -client.on('break', function(res) { - client.req({ - command: 'evaluate', - arguments: { - frame: 0, - maxStringLength: 2000, - expression: 'protractor.promise.controlFlow().getControlFlowText()' - } - }, function(err, controlFlowResponse) { - if (!err) { - client.req({ - command: 'evaluate', - arguments: { - frame: 0, - maxStringLength: 1000, - expression: 'command.getName()' - } - }, function (err, response) { - if (response.value) { - console.log('-- Next command: ' + response.value); - } - console.log(controlFlowResponse.value); - if (!debuggerRepl) { - initializeRepl(); - } - resume(); - }); - } - }); -}); -