Skip to content
This repository was archived by the owner on Dec 29, 2022. It is now read-only.

chore(refactor): Pull proxy logic out and add unit tests around it. #22

Merged
merged 12 commits into from
Jan 23, 2017
78 changes: 24 additions & 54 deletions lib/blockingproxy.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -12,20 +13,23 @@ 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.
rootSelector = '';
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() {
Expand Down Expand Up @@ -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<void> {
return new Promise<void>((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 = '';
Expand Down Expand Up @@ -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<void> {
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) {
Expand Down
44 changes: 33 additions & 11 deletions lib/webdriverCommands.ts → lib/webdriver_commands.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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');
}
}


Expand All @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions lib/webdriverLogger.ts → lib/webdriver_logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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}`;
}
}

Expand Down
87 changes: 87 additions & 0 deletions lib/webdriver_proxy.ts
Original file line number Diff line number Diff line change
@@ -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
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class-level comment for WebDriverBarrier would be nice.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

/**
* 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<void>; }
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions spec/e2e/logging_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/
Expand Down
Loading