From 45341c944bf60537849776c7f362ee0442b219e6 Mon Sep 17 00:00:00 2001 From: Hank Duan Date: Wed, 25 Mar 2015 15:46:54 -0700 Subject: [PATCH] feat(explorer): allow element explorer to start as a server If element explorer is run with a port (i.e. --debuggerServerPort 1234), it will start up a server that listens to command from the port instead of a repl that listens to process.stdin. --- lib/cli.js | 1 + lib/debugger/clients/explorer.js | 111 ++++++++++--- lib/debugger/clients/wddebugger.js | 13 +- lib/debugger/modes/commandRepl.js | 9 +- lib/protractor.js | 15 +- lib/runner.js | 3 + scripts/interactive_tests/interactive_test.js | 47 ++++++ .../interactive_test_util.js | 149 ++++++++++++++++++ scripts/interactive_tests/with_base_url.js | 9 ++ scripts/test.js | 4 +- 10 files changed, 332 insertions(+), 29 deletions(-) create mode 100644 scripts/interactive_tests/interactive_test.js create mode 100644 scripts/interactive_tests/interactive_test_util.js create mode 100644 scripts/interactive_tests/with_base_url.js diff --git a/lib/cli.js b/lib/cli.js index e7d13f13b..e6a7f2356 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -52,6 +52,7 @@ var optimist = require('optimist'). describe('resultJsonOutputFile', 'Path to save JSON test result'). describe('troubleshoot', 'Turn on troubleshooting output'). describe('elementExplorer', 'Interactively test Protractor commands'). + describe('debuggerServerPort', 'Start a debugger server at specified port instead of repl'). alias('browser', 'capabilities.browserName'). alias('name', 'capabilities.name'). alias('platform', 'capabilities.platform'). diff --git a/lib/debugger/clients/explorer.js b/lib/debugger/clients/explorer.js index d7df41f0b..88a4b5892 100644 --- a/lib/debugger/clients/explorer.js +++ b/lib/debugger/clients/explorer.js @@ -10,8 +10,6 @@ var CommandRepl = require('../modes/commandRepl'); */ var WdRepl = function() { this.client = new baseDebugger.Client(); - this.replServer; - this.cmdRepl; }; /** @@ -36,38 +34,97 @@ WdRepl.prototype.initClient_ = function() { }; /** - * Eval function for processing a single step in repl. + * Instantiate a server to handle IO. + * @param {number} port The port to start the server. * @private - * @param {string} cmd - * @param {object} context - * @param {string} filename - * @param {function} callback */ -WdRepl.prototype.stepEval_ = function(cmd, context, filename, callback) { - cmd = cmd.slice(1, cmd.length - 2); - this.cmdRepl.stepEval(cmd, callback); +WdRepl.prototype.initServer_ = function(port) { + var net = require('net'); + var self = this; + var cmdRepl = new CommandRepl(this.client); + + var received = ''; + net.createServer(function(sock) { + sock.on('data', function(data) { + received += data.toString(); + var eolIndex = received.indexOf('\r\n'); + if (eolIndex === 0) { + return; + } + var input = received.substring(0, eolIndex); + received = received.substring(eolIndex + 2); + if (data[0] === 0x1D) { + // '^]': term command + self.client.req({command: 'disconnect'}, function() { + // Intentionally blank. + }); + sock.end(); + } else if (input[input.length - 1] === '\t') { + // If the last character is the TAB key, this is an autocomplete + // request. We use everything before the TAB as the init data to feed + // into autocomplete. + input = input.substring(0, input.length - 1); + cmdRepl.complete(input, function(err, res) { + if (err) { + sock.write('ERROR: ' + err + '\r\n'); + } else { + sock.write(JSON.stringify(res) + '\r\n'); + } + }); + } else { + // Normal input + input = input.trim(); + cmdRepl.stepEval(input, function(err, res) { + if (err) { + sock.write('ERROR: ' + err + '\r\n'); + return; + } + if (res === undefined) { + res = ''; + } + sock.write(res + '\r\n'); + }); + } + }); + }).listen(port); + + console.log('Server listening on 127.0.0.1:' + port); }; /** - * Instantiate all repl objects, and debuggerRepl as current and start repl. + * Instantiate a repl to handle IO. * @private */ WdRepl.prototype.initRepl_ = function() { var self = this; - this.cmdRepl = new CommandRepl(this.client); + var cmdRepl = new CommandRepl(this.client); + + // Eval function for processing a single step in repl. + var stepEval = function(cmd, context, filename, callback) { + // The command that eval feeds is of the form '(CMD\n)', so we trim the + // double quotes and new line. + cmd = cmd.slice(1, cmd.length - 2); + cmdRepl.stepEval(cmd, function(err, res) { + // Result is a string representation of the evaluation. + if (res !== undefined) { + console.log(res); + } + callback(err, undefined); + }); + }; - self.replServer = repl.start({ - prompt: self.cmdRepl.prompt, + var replServer = repl.start({ + prompt: cmdRepl.prompt, input: process.stdin, output: process.stdout, - eval: self.stepEval_.bind(self), + eval: stepEval, useGlobal: false, ignoreUndefined: true }); - self.replServer.complete = self.cmdRepl.complete.bind(self.cmdRepl); + replServer.complete = cmdRepl.complete.bind(cmdRepl); - self.replServer.on('exit', function() { + replServer.on('exit', function() { console.log('Exiting...'); self.client.req({command: 'disconnect'}, function() { // Intentionally blank. @@ -75,6 +132,24 @@ WdRepl.prototype.initRepl_ = function() { }); }; +/** + * Instantiate a repl or a server. + * @private + */ +WdRepl.prototype.initReplOrServer_ = function() { + // Note instead of starting either repl or server, another approach is to + // feed the server socket into the repl as the input/output streams. The + // advantage is that the process becomes much more realistic because now we're + // using the normal repl. However, it was not possible to test autocomplete + // this way since we cannot immitate the TAB key over the wire. + var debuggerServerPort = process.argv[3]; + if (debuggerServerPort) { + this.initServer_(debuggerServerPort); + } else { + this.initRepl_(); + } +}; + /** * Initiate the debugger. * @public @@ -85,7 +160,7 @@ WdRepl.prototype.init = function() { console.log(' e.g., list(by.binding(\'\')) gets all bindings.'); this.initClient_(); - this.initRepl_(); + this.initReplOrServer_(); }; var wdRepl = new WdRepl(); diff --git a/lib/debugger/clients/wddebugger.js b/lib/debugger/clients/wddebugger.js index 86d3a36ac..cd723d905 100644 --- a/lib/debugger/clients/wddebugger.js +++ b/lib/debugger/clients/wddebugger.js @@ -75,8 +75,19 @@ WdDebugger.prototype.stepEval_ = function(cmd, context, filename, callback) { this.replServer.prompt = this.currentRepl.prompt; this.replServer.complete = this.currentRepl.complete.bind(this.currentRepl); callback(); + } else if (this.currentRepl === this.cmdRepl) { + // If we are currently in command repl mode. + this.cmdRepl.stepEval(cmd, function(err, res) { + // Result is a string representation of the evaluation, so we console.log + // the result to print it properly. Then we callback with undefined so + // that the result isn't printed twice. + if (res !== undefined) { + console.log(res); + } + callback(err, undefined); + }); } else { - this.currentRepl.stepEval(cmd, callback); + this.dbgRepl.stepEval(cmd, callback); } }; diff --git a/lib/debugger/modes/commandRepl.js b/lib/debugger/modes/commandRepl.js index 275c4c51c..06cccdde0 100644 --- a/lib/debugger/modes/commandRepl.js +++ b/lib/debugger/modes/commandRepl.js @@ -29,14 +29,9 @@ var CommandRepl = function(client) { */ CommandRepl.prototype.stepEval = function(expression, callback) { expression = expression.replace(/"/g, '\\\"'); + var expr = 'browser.dbgCodeExecutor_.execute("' + expression + '")'; - this.evaluate_(expr, function(err, res) { - // Result is a string representation of the evaluation. - if (res !== undefined) { - console.log(res); - } - callback(err, undefined); - }); + this.evaluate_(expr, callback); }; /** diff --git a/lib/protractor.js b/lib/protractor.js index cd4fc466e..29726453d 100644 --- a/lib/protractor.js +++ b/lib/protractor.js @@ -206,6 +206,13 @@ var Protractor = function(webdriverInstance, opt_baseUrl, opt_rootElement) { this.mockModules_ = []; this.addBaseMockModules_(); + + /** + * If specified, start a debugger server at specified port instead of repl + * when running element explorer. + * @private {number} + */ + this.debuggerServerPort_; }; /** @@ -662,11 +669,15 @@ Protractor.prototype.initDebugger_ = function(debuggerClientPath, opt_debugPort) process._debugProcess(process.pid); var flow = webdriver.promise.controlFlow(); + var self = this; 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\n'); - var nodedebug = require('child_process'). - fork(debuggerClientPath, [process.debugPort]); + var args = [process.debugPort]; + if (self.debuggerServerPort_) { + args.push(self.debuggerServerPort_); + } + var nodedebug = require('child_process').fork(debuggerClientPath, args); process.on('exit', function() { nodedebug.kill('SIGTERM'); }); diff --git a/lib/runner.js b/lib/runner.js index eb5bbf681..3c6114690 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -185,6 +185,9 @@ Runner.prototype.createBrowser = function() { if (config.getPageTimeout) { browser_.getPageTimeout = config.getPageTimeout; } + if (config.debuggerServerPort) { + browser_.debuggerServerPort_ = config.debuggerServerPort; + } var self = this; /** diff --git a/scripts/interactive_tests/interactive_test.js b/scripts/interactive_tests/interactive_test.js new file mode 100644 index 000000000..d42def2c2 --- /dev/null +++ b/scripts/interactive_tests/interactive_test.js @@ -0,0 +1,47 @@ +var InteractiveTest = require('./interactive_test_util').InteractiveTest; +var port = 6969; +var test = new InteractiveTest('node lib/cli.js --elementExplorer true', port); + +// Check state persists. +test.addCommandExpectation('var x = 3'); +test.addCommandExpectation('x', '3'); + +// Check can return functions. +test.addCommandExpectation('var y = function(param) {return param;}'); +test.addCommandExpectation('y', 'function (param) {return param;}'); + +// Check promises complete. +test.addCommandExpectation('browser.driver.getCurrentUrl()', 'data:,'); +test.addCommandExpectation('browser.get("http://localhost:8081")'); +test.addCommandExpectation('browser.getCurrentUrl()', + 'http://localhost:8081/#/form'); + +// Check promises are resolved before being returned. +test.addCommandExpectation('var greetings = element(by.binding("greeting"))'); +test.addCommandExpectation('greetings.getText()', 'Hiya'); + +// Check require is injected. +test.addCommandExpectation('var q = require("q")'); +test.addCommandExpectation( + 'var deferred = q.defer(); ' + + 'setTimeout(function() {deferred.resolve(1)}, 100); ' + + 'deferred.promise', + '1'); + +// Check errors are handled gracefully +test.addCommandExpectation('element(by.binding("nonexistent"))'); +test.addCommandExpectation('element(by.binding("nonexistent")).getText()', + 'ERROR: NoSuchElementError: No element found using locator: ' + + 'by.binding("nonexistent")'); + +// Check complete calls +test.addCommandExpectation('\t', + '[["element(by.id(\'\'))","element(by.css(\'\'))",' + + '"element(by.name(\'\'))","element(by.binding(\'\'))",' + + '"element(by.xpath(\'\'))","element(by.tagName(\'\'))",' + + '"element(by.className(\'\'))"],""]'); +test.addCommandExpectation('ele\t', '[["element"],"ele"]'); +test.addCommandExpectation('br\t', '[["break","","browser"],"br"]'); + +test.run(); + diff --git a/scripts/interactive_tests/interactive_test_util.js b/scripts/interactive_tests/interactive_test_util.js new file mode 100644 index 000000000..4cb872b12 --- /dev/null +++ b/scripts/interactive_tests/interactive_test_util.js @@ -0,0 +1,149 @@ +var child_process = require('child_process'), + q = require('q'), + net = require('net'); + +var TIMEOUT = 10000; + +// An instance of a protractor debugger server. +var Server = function(serverStartCmd, port) { + // Start protractor and its debugger server as a child process. + this.start = function() { + var deferred = q.defer(); + var received = ''; + + serverStartCmd += ' --debuggerServerPort ' + port; + serverStartCmd = serverStartCmd.split(/\s/); + var test_process = child_process.spawn(serverStartCmd[0], + serverStartCmd.slice(1)); + + var timeoutObj = setTimeout(function() { + var errMsg = 'Did not start interactive server in ' + TIMEOUT/1000 + 's.'; + if (received) { + errMsg += ' Server startup output: ' + received; + } + deferred.reject(errMsg); + }, TIMEOUT); + + test_process.stdout.on('data', function(data) { + received += data; + if (received.indexOf('Server listening on 127.0.0.1:' + port) >= 0) { + clearTimeout(timeoutObj); + // Add a small time for browser to get ready + setTimeout(deferred.resolve, 2000); + } + }); + + test_process.stderr.on('data', function(data) { + received += data; + }); + + return deferred.promise; + }; +}; + +// A client to attach to Protractor's debugger server and exchange data. +var Client = function(port) { + var socket; + + // Connect to the server. + this.connect = function() { + var deferred = q.defer(); + socket = net.connect({port: port}, function() { + deferred.resolve(); + }); + return deferred.promise; + }; + + // Disconnect from the server. + this.disconnect = function() { + socket.end(); + }; + + // Send a command to the server and wait for a response. Return response as a + // promise. + this.sendCommand = function(cmd) { + var deferred = q.defer(); + var received = ''; + var timeoutObj = setTimeout(function() { + var errMsg = 'Command <' + JSON.stringify(cmd) + + '> did not receive a response in ' + TIMEOUT/1000 + 's.'; + if (received) { + errMsg += ' Received messages so far: ' + received; + } + deferred.reject(errMsg); + }, TIMEOUT); + + var ondata = function(data) { + received += data.toString(); + var i = received.indexOf('\r\n'); + if (i >= 0) { + clearTimeout(timeoutObj); + var response = received.substring(0, i).trim(); + deferred.resolve(response); + } + }; + socket.on('data', ondata); + + var onerror = function(data) { + deferred.reject('Received error: ' + data); + }; + socket.on('error', onerror); + + socket.write(cmd + '\r\n'); + return deferred.promise.fin(function() { + clearTimeout(timeoutObj); + socket.removeListener('data', ondata); + socket.removeListener('error', onerror); + }); + }; +}; + +/** + * Util for running an interactive Protractor test. + */ +exports.InteractiveTest = function(interactiveServerStartCmd, port) { + var expectations = []; + + // Adds a command to send as well as the response to verify against. + // If opt_expectedResult is undefined, the test will still wait for the server + // to respond after sending the command, but will not verify against it. + // Note, this does not actually interact with the server, but simply adds the + // command to a queue. + this.addCommandExpectation = function(command, opt_expectedResult) { + expectations.push({ + command: command, + expectedResult: opt_expectedResult + }); + }; + + // Execute the interactive test. This will first start Protractor and its + // debugger server. Then it will connect to the server. Finally, it will + // send the queue of commands against the server sequentially and verify the + // response from the server if needed. + this.run = function() { + var server = new Server(interactiveServerStartCmd, port); + return server.start().then(function() { + var client = new Client(port); + return client.connect().then(function() { + var verifyAll = function(i) { + if (i < expectations.length) { + var expectedResult = expectations[i].expectedResult; + var command = expectations[i].command; + return client.sendCommand(command).then(function(response) { + if (expectedResult !== undefined && expectedResult !== response) { + throw ('Command <' + JSON.stringify(command) + '> received: ' + + response + ', but expects: ' + expectedResult); + } else { + return verifyAll(i + 1); + } + }); + } + }; + return verifyAll(0); + }).fin(function() { + client.sendCommand(0x1D); // '^]' This is the term signal. + client.disconnect(); + }); + }).done(); + }; +}; diff --git a/scripts/interactive_tests/with_base_url.js b/scripts/interactive_tests/with_base_url.js new file mode 100644 index 000000000..8616a30c4 --- /dev/null +++ b/scripts/interactive_tests/with_base_url.js @@ -0,0 +1,9 @@ +var InteractiveTest = require('./interactive_test_util').InteractiveTest; +var port = 6969; +var test = new InteractiveTest( + 'node lib/cli.js --baseUrl http://localhost:8081 --elementExplorer true', + port); + +// Check we automatically go to to baseUrl. +test.addCommandExpectation('browser.driver.getCurrentUrl()', 'http://localhost:8081/#/form'); +test.run(); diff --git a/scripts/test.js b/scripts/test.js index b1dc30431..0a50bc09e 100755 --- a/scripts/test.js +++ b/scripts/test.js @@ -31,7 +31,9 @@ var passingTests = [ 'node lib/cli.js spec/getCapabilitiesConf.js', 'node lib/cli.js spec/controlLockConf.js', 'node lib/cli.js spec/customFramework.js', - 'node node_modules/.bin/jasmine JASMINE_CONFIG_PATH=scripts/unit_test.json' + 'node node_modules/.bin/jasmine JASMINE_CONFIG_PATH=scripts/unit_test.json', + 'node scripts/interactive_tests/interactive_test.js', + 'node scripts/interactive_tests/with_base_url.js' ]; // Plugins