diff --git a/lib/blockingproxy.ts b/lib/blockingproxy.ts index 7060467..c697a83 100644 --- a/lib/blockingproxy.ts +++ b/lib/blockingproxy.ts @@ -1,8 +1,9 @@ import * as http from 'http'; import * as url from 'url'; -import {parseWebDriverCommand} from './webdriverCommands'; -import {WebDriverLogger} from './webdriverLogger'; +import {WebDriverCommand} from './webdriver_commands'; +import {WebDriverLogger} from './webdriver_logger'; +import {WebDriverBarrier, WebDriverProxy} from './webdriver_proxy'; let angularWaits = require('./angular/wait.js'); export const BP_PREFIX = 'bpproxy'; @@ -12,7 +13,7 @@ export const BP_PREFIX = 'bpproxy'; * JSON webdriver commands. It keeps track of whether the page under test * needs to wait for page stability, and initiates a wait if so. */ -export class BlockingProxy { +export class BlockingProxy implements WebDriverBarrier { seleniumAddress: string; // The ng-app root to use when waiting on the client. @@ -20,12 +21,15 @@ export class BlockingProxy { waitEnabled: boolean; server: http.Server; logger: WebDriverLogger; + private proxy: WebDriverProxy; constructor(seleniumAddress) { this.seleniumAddress = seleniumAddress; this.rootSelector = ''; this.waitEnabled = true; this.server = http.createServer(this.requestListener.bind(this)); + this.proxy = new WebDriverProxy(seleniumAddress); + this.proxy.addBarrier(this); } waitForAngularData() { @@ -194,11 +198,10 @@ export class BlockingProxy { } } - sendRequestToStabilize(originalRequest) { - let self = this; - let deferred = new Promise((resolve, reject) => { - let stabilityRequest = self.createSeleniumRequest( - 'POST', BlockingProxy.executeAsyncUrl(originalRequest.url), function(stabilityResponse) { + sendRequestToStabilize(url: string): Promise { + return new Promise((resolve, reject) => { + let stabilityRequest = this.createSeleniumRequest( + 'POST', BlockingProxy.executeAsyncUrl(url), function(stabilityResponse) { // TODO - If the response is that angular is not available on the // page, should we just go ahead and continue? let stabilityData = ''; @@ -227,68 +230,35 @@ export class BlockingProxy { stabilityRequest.write(this.waitForAngularData()); stabilityRequest.end(); }); - - return deferred; } requestListener(originalRequest: http.IncomingMessage, response: http.ServerResponse) { - let self = this; - let stabilized = Promise.resolve(null); - if (BlockingProxy.isProxyCommand(originalRequest.url)) { let commandData = ''; originalRequest.on('data', (d) => { commandData += d; }); originalRequest.on('end', () => { - self.handleProxyCommand(originalRequest, commandData, response); + this.handleProxyCommand(originalRequest, commandData, response); }); return; } - // If the command is not a proxy command, it's a regular webdriver command. - if (self.shouldStabilize(originalRequest.url)) { - stabilized = self.sendRequestToStabilize(originalRequest); + // OK to ignore the promise returned by this. + this.proxy.handleRequest(originalRequest, response); + } - // TODO: Log waiting for Angular. + onCommand(command: WebDriverCommand): Promise { + if (this.logger) { + command.on('data', () => { + this.logger.logWebDriverCommand(command); + }); } - stabilized.then( - () => { - let seleniumRequest = self.createSeleniumRequest( - originalRequest.method, originalRequest.url, function(seleniumResponse) { - response.writeHead(seleniumResponse.statusCode, seleniumResponse.headers); - seleniumResponse.pipe(response); - seleniumResponse.on('error', (err) => { - response.writeHead(500); - response.write(err); - response.end(); - }); - }); - let reqData = ''; - originalRequest.on('error', (err) => { - response.writeHead(500); - response.write(err); - response.end(); - }); - originalRequest.on('data', (d) => { - reqData += d; - seleniumRequest.write(d); - }); - originalRequest.on('end', () => { - let command = - parseWebDriverCommand(originalRequest.url, originalRequest.method, reqData); - if (this.logger) { - this.logger.logWebDriverCommand(command); - } - seleniumRequest.end(); - }); - }, - (err) => { - response.writeHead(500); - response.write(err); - response.end(); - }); + if (this.shouldStabilize(command.url)) { + return this.sendRequestToStabilize(command.url); + } + return Promise.resolve(null); } listen(port: number) { diff --git a/lib/webdriverCommands.ts b/lib/webdriver_commands.ts similarity index 78% rename from lib/webdriverCommands.ts rename to lib/webdriver_commands.ts index b96225c..be7b098 100644 --- a/lib/webdriverCommands.ts +++ b/lib/webdriver_commands.ts @@ -1,6 +1,7 @@ /** * Utilities for parsing WebDriver commands from HTTP Requests. */ +import * as events from 'events'; type HttpMethod = 'GET'|'POST'|'DELETE'; export type paramKey = 'sessionId' | 'elementId' | 'name' | 'propertyName'; @@ -86,16 +87,42 @@ class Endpoint { * @param params Parameters for the command taken from the request's url. * @param data Optional data included with the command, taken from the body of the request. */ -export class WebDriverCommand { +export class WebDriverCommand extends events.EventEmitter { private params: {[key: string]: string}; + data: any; + responseStatus: number; + responseData: number; + + // All WebDriver commands have a session Id, except for two. + // NewSession will have a session Id in the data + // Status just doesn't + get sessionId(): string { + return this.getParam('sessionId'); + } - constructor(public commandName: CommandName, params?, public data?: any) { + constructor(public commandName: CommandName, public url: string, params?) { + super(); this.params = params; } public getParam(key: paramKey) { return this.params[key]; } + + public handleData(data?: any) { + if (data) { + this.data = JSON.parse(data); + } + this.emit('data'); + } + + public handleResponse(statusCode: number, data?: any) { + this.responseStatus = statusCode; + if (data) { + this.responseData = JSON.parse(data); + } + this.emit('response'); + } } @@ -111,26 +138,21 @@ function addWebDriverCommand(command: CommandName, method: HttpMethod, pattern: /** * Returns a new WebdriverCommand object for the resource at the given URL. */ -export function parseWebDriverCommand(url, method, data: string) { - let parsedData = {}; - if (data) { - parsedData = JSON.parse(data); - } - +export function parseWebDriverCommand(url, method) { for (let endpoint of endpoints) { if (endpoint.matches(url, method)) { let params = endpoint.getParams(url); - return new WebDriverCommand(endpoint.name, params, parsedData); + return new WebDriverCommand(endpoint.name, url, params); } } - return new WebDriverCommand(CommandName.UNKNOWN, {}, {'url': url}); + return new WebDriverCommand(CommandName.UNKNOWN, url, {}); } let sessionPrefix = '/session/:sessionId'; addWebDriverCommand(CommandName.NewSession, 'POST', '/session'); addWebDriverCommand(CommandName.DeleteSession, 'DELETE', '/session/:sessionId'); -addWebDriverCommand(CommandName.Status, 'GET', sessionPrefix + '/status'); +addWebDriverCommand(CommandName.Status, 'GET', '/status'); addWebDriverCommand(CommandName.GetTimeouts, 'GET', sessionPrefix + '/timeouts'); addWebDriverCommand(CommandName.SetTimeouts, 'POST', sessionPrefix + '/timeouts'); addWebDriverCommand(CommandName.Go, 'POST', sessionPrefix + '/url'); diff --git a/lib/webdriverLogger.ts b/lib/webdriver_logger.ts similarity index 95% rename from lib/webdriverLogger.ts rename to lib/webdriver_logger.ts index 9051b68..7321cc9 100644 --- a/lib/webdriverLogger.ts +++ b/lib/webdriver_logger.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as stream from 'stream'; -import {CommandName, WebDriverCommand} from './webdriverCommands'; +import {CommandName, WebDriverCommand} from './webdriver_commands'; // Generate a random 8 character ID to avoid collisions. function getLogId() { @@ -65,7 +65,7 @@ export class WebDriverLogger { case CommandName.GetCurrentURL: return `Getting current URL`; default: - return `Unknown command ${command.data['url']}`; + return `Unknown command ${command.url}`; } } diff --git a/lib/webdriver_proxy.ts b/lib/webdriver_proxy.ts new file mode 100644 index 0000000..9fd763e --- /dev/null +++ b/lib/webdriver_proxy.ts @@ -0,0 +1,87 @@ +import * as http from 'http'; +import * as url from 'url'; + +import {parseWebDriverCommand, WebDriverCommand} from './webdriver_commands'; + +/** + * A proxy that understands WebDriver commands. Users can add barriers (similar to middleware in + * express) that will be called before forwarding the request to WebDriver. The proxy will wait for + * each barrier to finish, calling them in the order in which they were added. + */ +export class WebDriverProxy { + barriers: WebDriverBarrier[]; + seleniumAddress: string; + + constructor(seleniumAddress: string) { + this.barriers = []; + this.seleniumAddress = seleniumAddress; + } + + addBarrier(barrier: WebDriverBarrier) { + this.barriers.push(barrier); + } + + async handleRequest(originalRequest: http.IncomingMessage, response: http.ServerResponse) { + let command = parseWebDriverCommand(originalRequest.url, originalRequest.method); + + let replyWithError = (err) => { + response.writeHead(500); + response.write(err.toString()); + response.end(); + }; + + // Process barriers in order, one at a time. + try { + for (let barrier of this.barriers) { + await barrier.onCommand(command); + } + } catch (err) { + replyWithError(err); + // Don't call through if a barrier fails. + return; + } + + let parsedUrl = url.parse(this.seleniumAddress); + let options: http.RequestOptions = {}; + options.method = originalRequest.method; + options.path = parsedUrl.path + originalRequest.url; + options.hostname = parsedUrl.hostname; + options.port = parseInt(parsedUrl.port); + options.headers = originalRequest.headers; + + let forwardedRequest = http.request(options); + + // clang-format off + let reqData = ''; + originalRequest.on('data', (d) => { + reqData += d; + forwardedRequest.write(d); + }).on('end', () => { + command.handleData(reqData); + forwardedRequest.end(); + }).on('error', replyWithError); + + forwardedRequest.on('response', (seleniumResponse) => { + response.writeHead(seleniumResponse.statusCode, seleniumResponse.headers); + + let respData = ''; + seleniumResponse.on('data', (d) => { + respData += d; + response.write(d); + }).on('end', () => { + command.handleResponse(seleniumResponse.statusCode, respData); + response.end(); + }).on('error', replyWithError); + + }).on('error', replyWithError); + // clang-format on + } +} + +/** + * When the proxy receives a WebDriver command, it will call onCommand() for each of it's barriers. + * Barriers may return a promise for the proxy to wait for before proceeding. If the promise is + * rejected, the proxy will reply with an error code and the result of the promise and the command + * will not be forwarded to Selenium. + */ +export interface WebDriverBarrier { onCommand(command: WebDriverCommand): Promise; } diff --git a/package.json b/package.json index f130fd1..0d84af0 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "devDependencies": { "@types/jasmine": "^2.2.33", "@types/minimist": "^1.1.29", + "@types/nock": "^8.2.0", "@types/node": "^6.0.45", "@types/rimraf": "0.0.28", "@types/selenium-webdriver": "^2.53.39", @@ -31,12 +32,13 @@ "jasmine-co": "^1.2.2", "jasmine-ts": "0.0.3", "jshint": "2.9.1", + "nock": "^9.0.2", "rimraf": "^2.5.4", "run-sequence": "^1.2.2", "selenium-mock": "^0.1.5", "selenium-webdriver": "2.53.3", "ts-node": "^2.0.0", - "tslint": "^4.0.2", + "tslint": "^4.3.1", "tslint-eslint-rules": "^3.1.0", "typescript": "^2.0.3", "vrsource-tslint-rules": "^0.14.1", diff --git a/spec/e2e/logging_spec.ts b/spec/e2e/logging_spec.ts index c0aa59b..32d6911 100644 --- a/spec/e2e/logging_spec.ts +++ b/spec/e2e/logging_spec.ts @@ -12,9 +12,9 @@ import {BP_URL, getTestEnv} from './environment'; Example log of a test session: [12:51:30] Getting new "chrome" session -[12:51:33] [abcdef] Navigating to 'http://localhost/stuff' -[12:51:35] [abcdef] Wait for Angular -[12:51:36] [abcdef] Click on css '.test_element' +[12:51:33] [abcdef] [0.5s] Navigating to 'http://localhost/stuff' +[12:51:35] [abcdef] [0.3s] Wait for Angular +[12:51:36] [abcdef] [0.01s] Click on css '.test_element' [12:51:36] [abcdef] Move mouse by (0,50) [12:51:37] [abcdef] Click on binding 'thinger' */ diff --git a/spec/unit/util.ts b/spec/unit/util.ts new file mode 100644 index 0000000..3bb96be --- /dev/null +++ b/spec/unit/util.ts @@ -0,0 +1,66 @@ +import * as stream from 'stream'; + +import {CommandName, WebDriverCommand} from '../../lib/webdriver_commands'; +import {WebDriverBarrier} from '../../lib/webdriver_proxy'; + +/** + * Fakes and helpers for testing. + */ +export class TestBarrier implements WebDriverBarrier { + commands: WebDriverCommand[] = []; + + onCommand(command: WebDriverCommand): Promise { + this.commands.push(command); + return; + } + + getCommandNames(): CommandName[] { + return this.commands.map((c) => c.commandName); + } +} + +export class InMemoryWriter extends stream.Writable { + content: string; + doneCb: Function; + + constructor() { + super({decodeStrings: true}); + this.content = ''; + } + + _write(chunk: Buffer, encoding?, callback?) { + let data = chunk.toString(); + this.content += data; + callback(); + } + + onEnd(cb: Function) { + this.doneCb = cb; + } + + end() { + super.end(); + if (this.doneCb) { + this.doneCb(this.content); + } + } +} + +export class InMemoryReader extends stream.Readable { + content: string[]; + idx: number; + + constructor() { + super(); + this.content = []; + this.idx = 0; + } + + _read() { + if (this.idx < this.content.length) { + this.push(this.content[this.idx++]); + } else { + this.push(null); + } + } +} diff --git a/spec/unit/webdriver_commands_spec.ts b/spec/unit/webdriver_commands_spec.ts new file mode 100644 index 0000000..a0e2ffc --- /dev/null +++ b/spec/unit/webdriver_commands_spec.ts @@ -0,0 +1,65 @@ +import * as http from 'http'; +import {Server} from 'selenium-mock'; +import * as webdriver from 'selenium-webdriver'; + +import {CommandName} from '../../lib/webdriver_commands'; +import {WebDriverProxy} from '../../lib/webdriver_proxy'; +import {getMockSelenium, Session} from '../helpers/mock_selenium'; +import {TestBarrier} from './util'; + +describe('WebDriver command parser', () => { + let mockServer: Server; + let driver: webdriver.WebDriver; + let proxy: WebDriverProxy; + let server: http.Server; + let testBarrier: TestBarrier; + + beforeEach(async() => { + mockServer = getMockSelenium(); + mockServer.start(); + let mockPort = mockServer.handle.address().port; + + proxy = new WebDriverProxy(`http://localhost:${mockPort}/wd/hub`); + testBarrier = new TestBarrier; + proxy.addBarrier(testBarrier); + server = http.createServer(proxy.handleRequest.bind(proxy)); + server.listen(0); + let port = server.address().port; + + driver = new webdriver.Builder() + .usingServer(`http://localhost:${port}`) + .withCapabilities(webdriver.Capabilities.chrome()) + .build(); + + // Ensure WebDriver client has created a session by waiting on a command. + await driver.get('http://example.com'); + }); + + it('parses session commands', async() => { + let session = await driver.getSession(); + let sessionId = session.getId(); + await driver.quit(); + + let recentCommands = testBarrier.getCommandNames(); + expect(recentCommands.length).toBe(3); + expect(recentCommands).toEqual([ + CommandName.NewSession, CommandName.Go, CommandName.DeleteSession + ]); + expect(testBarrier.commands[1].sessionId).toEqual(sessionId); + }); + + it('parses url commands', async() => { + await driver.getCurrentUrl(); + + let recentCommands = testBarrier.getCommandNames(); + expect(recentCommands.length).toBe(3); + expect(recentCommands).toEqual([ + CommandName.NewSession, CommandName.Go, CommandName.GetCurrentURL + ]); + }); + + afterEach(() => { + server.close(); + mockServer.stop(); + }); +}); diff --git a/spec/unit/webdriverLogger_spec.ts b/spec/unit/webdriver_logger_spec.ts similarity index 97% rename from spec/unit/webdriverLogger_spec.ts rename to spec/unit/webdriver_logger_spec.ts index 20e26d9..a3cbc63 100644 --- a/spec/unit/webdriverLogger_spec.ts +++ b/spec/unit/webdriver_logger_spec.ts @@ -3,7 +3,7 @@ import * as webdriver from 'selenium-webdriver'; import * as stream from 'stream'; import {BlockingProxy} from '../../lib/blockingproxy'; -import {WebDriverLogger} from '../../lib/webdriverLogger'; +import {WebDriverLogger} from '../../lib/webdriver_logger'; import {getMockSelenium, Session} from '../helpers/mock_selenium'; const capabilities = webdriver.Capabilities.chrome(); diff --git a/spec/unit/webdriver_proxy_spec.ts b/spec/unit/webdriver_proxy_spec.ts new file mode 100644 index 0000000..6b9d2a5 --- /dev/null +++ b/spec/unit/webdriver_proxy_spec.ts @@ -0,0 +1,195 @@ +import * as nock from 'nock'; + +import {WebDriverCommand} from '../../lib/webdriver_commands'; +import {WebDriverProxy} from '../../lib/webdriver_proxy'; + +import {InMemoryReader, InMemoryWriter, TestBarrier} from './util'; + +describe('WebDriver Proxy', () => { + let proxy: WebDriverProxy; + + beforeEach(() => { + proxy = new WebDriverProxy('http://test_webdriver_url/wd/hub'); + }); + + it('proxies to WebDriver', (done) => { + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = '/session/sessionId/get'; + req.method = 'GET'; + const responseData = {value: 'selenium response'}; + + let scope = nock(proxy.seleniumAddress).get('/session/sessionId/get').reply(200, responseData); + + proxy.handleRequest(req, resp); + + resp.onEnd((data) => { + // Verify that all nock endpoints were called. + expect(resp.writeHead.calls.first().args[0]).toBe(200); + expect(data).toEqual(JSON.stringify(responseData)); + scope.done(); + done(); + }); + }); + + it('waits for barriers', (done) => { + const WD_URL = '/session/sessionId/url'; + + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = WD_URL; + req.method = 'POST'; + + let barrier = new TestBarrier(); + let barrierDone = false; + barrier.onCommand = (): Promise => { + return new Promise((res) => { + setTimeout(() => { + barrierDone = true; + res(); + }, 250); + }); + }; + + let scope = nock(proxy.seleniumAddress).post(WD_URL).reply(() => { + // Shouldn't see the command until the barrier is done. + expect(barrierDone).toBeTruthy(); + return [200]; + }); + + proxy.addBarrier(barrier); + proxy.handleRequest(req, resp); + + resp.onEnd(() => { + expect(barrierDone).toBeTruthy(); + scope.done(); + done(); + }); + }); + + it('waits for multiple barriers in order', (done) => { + const WD_URL = '/session/sessionId/url'; + + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = WD_URL; + req.method = 'POST'; + + let barrier1 = new TestBarrier(); + let barrier1Done = false; + barrier1.onCommand = (): Promise => { + return new Promise((res) => { + setTimeout(() => { + expect(barrier2Done).toBeFalsy(); + barrier1Done = true; + res(); + }, 150); + }); + }; + let barrier2 = new TestBarrier(); + let barrier2Done = false; + barrier2.onCommand = (): Promise => { + return new Promise((res) => { + setTimeout(() => { + expect(barrier1Done).toBeTruthy(); + barrier2Done = true; + res(); + }, 50); + }); + }; + + let scope = nock(proxy.seleniumAddress).post(WD_URL).reply(200); + + proxy.addBarrier(barrier1); + proxy.addBarrier(barrier2); + proxy.handleRequest(req, resp); + + resp.onEnd(() => { + expect(barrier2Done).toBeTruthy(); + scope.done(); + done(); + }); + }); + + it('returns an error if a barrier fails', (done) => { + const WD_URL = '/session/sessionId/url'; + + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = WD_URL; + req.method = 'GET'; + + let barrier = new TestBarrier(); + barrier.onCommand = (): Promise => { + return new Promise((res, rej) => { + rej('Barrier failed'); + }); + }; + + let scope = nock(proxy.seleniumAddress).get(WD_URL).reply(200); + + proxy.addBarrier(barrier); + proxy.handleRequest(req, resp); + + resp.onEnd((respData) => { + expect(resp.writeHead.calls.first().args[0]).toBe(500); + expect(respData).toEqual('Barrier failed'); + + // Should not call the selenium server. + expect(scope.isDone()).toBeFalsy(); + nock.cleanAll(); + done(); + }); + }); + + it('barriers get selenium responses', (done) => { + const WD_URL = '/session/sessionId/url'; + const RESPONSE = {url: 'http://example.com'}; + + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = WD_URL; + req.method = 'GET'; + + let scope = nock(proxy.seleniumAddress).get(WD_URL).reply(200, RESPONSE); + + let barrier = new TestBarrier(); + barrier.onCommand = (command: WebDriverCommand): Promise => { + command.on('response', () => { + expect(command.responseData['url']).toEqual(RESPONSE.url); + scope.done(); + done(); + }); + return undefined; + }; + proxy.addBarrier(barrier); + proxy.handleRequest(req, resp); + }); + + it('propagates http errors', (done) => { + const WD_URL = '/session/'; + const ERR = new Error('HTTP error'); + + let req = new InMemoryReader() as any; + let resp = new InMemoryWriter() as any; + resp.writeHead = jasmine.createSpy('spy'); + req.url = WD_URL; + req.method = 'POST'; + + let scope = nock(proxy.seleniumAddress).post(WD_URL).replyWithError(ERR); + + proxy.handleRequest(req, resp); + + resp.onEnd((data) => { + expect(resp.writeHead.calls.first().args[0]).toBe(500); + expect(data).toEqual(ERR.toString()); + scope.done(); + done(); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 65d98e3..c23dc49 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "target": "es6", "module": "commonjs", "moduleResolution": "node", + "noUnusedLocals": true, "sourceMap": true, "declaration": true, "removeComments": false,