attester/attester

View on GitHub
lib/test-server/test-server.js

Summary

Maintainability
C
1 day
Test Coverage
/*
 * Copyright 2012 Amadeus s.a.s.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

var url = require('url');
var path = require('path');
var util = require("util");
var events = require("events");

var connect = require('connect');
var compression = require('compression');
var favicon = require('serve-favicon');
var serveStatic = require('serve-static');
var sockjs = require('sockjs');
var _ = require("lodash");

var attesterResultsUI = require('attester-results-ui');

var http = require('./http-server.js');
var Slave = require('./slave-server.js');
var Viewer = require('./viewer-server.js');
var SlaveController = require('./slave-controller.js');
var Logger = require('../logging/logger.js');

// middlewares
var index = require("../middlewares/index");
var template = require("../middlewares/template");

var detectHostname = require("../util/detectHostname");
var coverageDisplay = require("./coverage-display");

var arrayRemove = function (array, item) {
    var index = array.indexOf(item);
    if (index >= 0) {
        array.splice(index, 1);
        return true;
    }
    return false;
};

var clientTypes = {
    "slave": function (socket, data) {
        var newSlave = new Slave(socket, data, this.config, this.logger);
        this.addSlave(newSlave);
    },
    "viewer": function (socket, data) {
        // creates a new Viewer, which will register to campaign results
        new Viewer(socket, data, this);
    },
    "slaveController": function (socket, data) {
        // creates a new slave controller, which will be notified when its slaves
        // are connected, disconnected, and idle
        new SlaveController(socket, data, this);
    }
};

var socketConnection = function (socket) {
    var testServer = this;
    socket.once('data', function (message) {
        try {
            var data = JSON.parse(message);
            var fn = clientTypes[data.type];
            if (fn) {
                fn.call(testServer, socket, data);
            } else {
                throw new Error();
            }
        } catch (e) {
            // unknown type or incorrect JSON: close the connection so that the remote
            // program knows it is not supported
            socket.close();
        }
    });
};

var routeToCampaign = function (req, res, next) {
    var testServer = this;
    if (req.path == '/') {
        // root of the server, redirect to welcome page:
        res.statusCode = 301;
        res.setHeader('Location', '/__attester__/index.html');
        res.end();
        return;
    }

    var campaignIdMatch = req.url.match(/^\/campaign([0-9]+)(\/|$)/);
    var campaign = campaignIdMatch ? testServer.findCampaign(campaignIdMatch[1]) : null;
    if (!campaign) {
        return next();
    }
    if (!campaignIdMatch[2]) {
        // campaign root, display its configuration
        // it could be later improved by adding a home page for each campaign
        res.statusCode = 301;
        res.setHeader('Location', '/__attester__/index.html#campaign' + campaign.id);
        res.end();
        return;
    }
    // Remove the campaign baseURL
    req.url = req.url.substr(campaign.baseURL.length);
    campaign.handleRequest(req, res, next);
};

var campaignFinished = function (campaign) {
    var testServer = this;
    if (this.config.shutdownOnCampaignEnd) {
        arrayRemove(testServer.campaigns, campaign);
        testServer.slaves.forEach(testServer.updateMatchingCampaignBrowsers.bind(testServer));
    }
};

var slaveDisconnected = function (slave) {
    var testServer = this;
    arrayRemove(testServer.slaves, slave);
    arrayRemove(testServer.availableSlaves, slave);
    testServer.updateMatchingCampaignBrowsers(slave);
    testServer.logger.logInfo("Slave disconnected: " + slave.toString());
};

var slaveAvailable = function (slave) {
    var testServer = this;
    // TODO: do an assert to check that this slave is not already available and is correctly in the slaves array
    testServer.availableSlaves.push(slave);

    // a slave is now available, it is time to assign it a task
    testServer.assignTasks();
};

var slaveUnavailable = function (slave) {
    var testServer = this;
    arrayRemove(testServer.availableSlaves, slave);
};

var routeCoverage = function (req, res, next) {
    var testServer = this;
    var match = /\/([0-9]+)\/([0-9]+)/.exec(req.path);
    var campaignId;
    var taskId;
    var campaign;
    if (match) {
        campaignId = match[1];
        taskId = match[2];
        if (!isNaN(campaignId) && !isNaN(taskId)) {
            campaign = testServer.findCampaign(campaignId);
        }
    }
    if (!campaign) {
        res.statusCode = 404;
        res.write('Not found');
        res.end();
        return;
    }
    var data = [];
    req.setEncoding('utf-8');
    req.on('data', function (chunk) {
        data.push(chunk);
    });
    req.on('end', function () {
        res.write('OK');
        res.end();
        var json = JSON.parse(data.join(''));
        campaign.addCoverageResult(taskId, json);
    });
};

var jsonApi = function (req, res, next) {
    var testServer = this;
    if (req.path !== "/status.json") {
        return next();
    }
    var status = testServer.getStatus();
    var jsonResponse = JSON.stringify(status);
    var parsedUrl = url.parse(req.url, true);
    var jsonpCallback = parsedUrl.query.callback;
    if (jsonpCallback) {
        res.header("Content-Type", "application/javascript");
        jsonResponse = [jsonpCallback, "(", jsonResponse, ");"].join("");
    } else {
        res.header("Content-Type", "application/json");
    }
    res.write(jsonResponse);
    res.end();
};

var attesterResultsUIConfig = function () {
    var testServer = this;
    return {
        serverURL: "{CURRENTHOST}",
        loadServerURLs: testServer.campaigns.map(function (campaign) {
            return "{CURRENTHOST}/campaign" + campaign.id;
        })
    };
};

var createSockJSLogger = function (parentLogger) {
    var logger = new Logger("sockjs", parentLogger);

    var severityMap = {
        "debug": Logger.LEVEL_DEBUG,
        "info": Logger.LEVEL_INFO,
        "error": Logger.LEVEL_ERROR
    };

    return function (severity, message) {
        var level = severityMap[severity] || Logger.LEVEL_TRACE;
        logger.log(level, message);
    };
};

/**
 * A test server is a web server which browsers can connect to and become its slaves, ready to execute some tests.
 * Browsers connect to it through a sockjs channel.
 * @param {Object} config Has the following properties:
 * <ul>
 * <li></li>
 * </ul>
 */
var TestServer = function (config, logger) {
    this.logger = new Logger("TestServer", logger);
    this.config = config;
    var app = connect();
    app.use(compression());
    app.use(index);
    app.use(favicon(path.join(__dirname, "client", "favicon.ico")));
    // Template pages (before the static folder)
    app.use('/__attester__', template.bind({
        data: this,
        page: "/index.html",
        path: path.join(__dirname, "client", "index.html")
    }));
    app.use('/__attester__', template.bind({
        data: this,
        page: "/status.html",
        path: path.join(__dirname, "client", "status.html")
    }));
    app.use('/__attester__', template.bind({
        data: this,
        page: "/slave.html",
        path: path.join(__dirname, "client", "slave.html")
    }));
    app.use('/__attester__', serveStatic(__dirname + '/client'));
    app.use('/__attester__/json3', serveStatic(path.dirname(require.resolve("json3/lib/json3.js"))));
    app.use('/__attester__/sockjs', serveStatic(path.dirname(require.resolve("sockjs-client/dist/sockjs.js"))));
    app.use('/__attester__/stacktrace', serveStatic(path.dirname(require.resolve("error-stack-parser/dist/error-stack-parser.js"))));
    app.use('/__attester__/coverage/display', coverageDisplay(this, '/__attester__/coverage/display'));
    app.use('/__attester__/coverage/data', routeCoverage.bind(this));
    app.use('/__attester__/results-ui', attesterResultsUI({
        toJSON: attesterResultsUIConfig.bind(this)
    }));
    app.use(routeToCampaign.bind(this));
    app.use('/__attester__', jsonApi.bind(this));
    this.app = app;
    this.server = http.createServer(app);
    this.sockjs = sockjs.createServer();
    this.sockjs.installHandlers(this.server, {
        prefix: '/sockjs',
        sockjs_url: '/__attester__/sockjs/sockjs.js',
        disconnect_delay: 60000,
        log: createSockJSLogger(this.logger)
    });
    this.sockjs.on('connection', socketConnection.bind(this));
    this.slaves = []; // array of all slaves
    this.availableSlaves = []; // array of available slaves
    this.campaigns = []; // array of campaigns
    this.frozen = config.frozen; // if frozen, then don't assign tasks
};

util.inherits(TestServer, events.EventEmitter);

var serverURLPathnames = {
    'slave': '/__attester__/slave.html',
    'home': '/'
};

TestServer.prototype.getURL = function (urlType) {
    var pathname = serverURLPathnames[urlType];
    if (pathname == null) {
        return;
    }
    return url.format({
        protocol: 'http',
        port: this.config.publicPort || this.port,
        hostname: this.config.publicHost || this.hostname,
        pathname: pathname
    });
};

TestServer.prototype.listen = function (port, host, callback) {
    var self = this;
    this.server.listen(port, host, function () {
        var address = self.server.address();
        self.port = address.port;
        host = self.hostname = address.address;
        var anyIPv4 = (host == "0.0.0.0");
        var anyIPv6 = (host == "::");
        if (anyIPv4 || anyIPv6) {
            detectHostname(anyIPv4).then(function (hostname) {
                self.hostname = hostname;
            })["finally"](callback);
        } else {
            callback();
        }
    });
};

TestServer.prototype.getRemainingTasks = function () {
    var result = 0;
    this.campaigns.forEach(function (campaign) {
        result += campaign.remainingTasks;
    });
    return result;
};

TestServer.prototype.getStatus = function () {
    return {
        slaves: this.slaves.map(function (slave) {
            return {
                address: slave.address,
                addressName: slave.addressName,
                port: slave.port,
                displayName: slave.displayName,
                userAgent: slave.userAgent,
                paused: slave.paused,
                idle: slave.idle,
                currentCampaign: slave.currentCampaign ? slave.currentCampaign.id : null,
                currentTask: slave.currentTask ? slave.currentTask.test.name : null
            };
        }),
        campaigns: this.campaigns.map(function (campaign) {
            return {
                campaignNumber: campaign.campaignNumber,
                id: campaign.id,
                totalTasks: campaign.tasks.length,
                remainingTasks: campaign.remainingTasks,
                browsers: campaign.browsers.map(function (browser) {
                    return browser.getJsonInfo();
                })
            };
        })
    };
};

TestServer.prototype.close = function (callback) {
    var slaves = this.slaves;
    var slaveDisposeBack = _.after(slaves.length + 1, function () {
        try {
            this.server.close(callback);
        } catch (e) {}
    }.bind(this));

    for (var i = 0, l = slaves.length; i < l; i++) {
        this.logger.logDebug("Disposing the slave: " + slaves[i].toString());
        try {
            slaves[i].dispose(slaveDisposeBack);
        } catch (e) {}
    }
    slaveDisposeBack();
};

TestServer.prototype.addSlave = function (slave) {
    this.logger.logInfo("New slave connected: " + slave.toString());
    this.updateMatchingCampaignBrowsers(slave);
    this.slaves.push(slave);
    slave.once('disconnect', slaveDisconnected.bind(this, slave));
    slave.on('available', slaveAvailable.bind(this, slave));
    slave.on('unavailable', slaveUnavailable.bind(this, slave));
    if (slave.id) {
        var listeners = this.emit('slave-added-' + slave.id, slave);
        if (!listeners) {
            // disconnects any slave which has an unregistered id
            this.logger.logInfo("Id " + slave.id + " is not registered, slave will be disconnected.");
            slave.disconnect();
            return;
        }
    }
    slave.emitAvailable(); // this will trigger assignTasks if the slave is available
};

TestServer.prototype.addCampaign = function (campaign) {
    this.campaigns.push(campaign);
    campaign.once('finished', campaignFinished.bind(this, campaign));
    var slaveURL = this.getURL("slave");
    campaign.addResult({
        event: "serverAttached",
        homeURL: this.getURL("home"),
        slaveURL: slaveURL
    });
    this.slaves.forEach(this.updateMatchingCampaignBrowsers.bind(this));
    this.assignTasks();
};

TestServer.prototype.updateMatchingCampaignBrowsers = function (slave) {
    var res = [];
    // checks that the slave is connected:
    if (slave.socket) {
        this.campaigns.forEach(function (currentCampaign) {
            currentCampaign.browsers.forEach(function (currentBrowser) {
                if (currentBrowser.matches(slave)) {
                    res.push({
                        campaign: currentCampaign,
                        browser: currentBrowser
                    });
                }
            });
        });
    }
    slave.matchingCampaignBrowsers = res;
};

TestServer.prototype.assignTasks = function () {
    if (this.frozen) {
        return;
    }
    // automatically called when tasks could be assigned to slaves
    var campaigns = this.campaigns;
    for (var i = 0, l = campaigns.length; i < l; i++) {
        var currentCampaign = campaigns[i];
        currentCampaign.checkFinished();
    }
    var availableSlaves = this.availableSlaves;
    for (var k = availableSlaves.length - 1; k >= 0; k--) {
        var currentSlave = availableSlaves[k];
        currentSlave.findTask();
    }
};

TestServer.prototype.findCampaign = function (campaignId) {
    var campaigns = this.campaigns;
    for (var i = 0, l = campaigns.length; i < l; i++) {
        var curCampaign = campaigns[i];
        // campaignNumber is used if predictableUrls == true. The values are 1, 2, 3...
        // campaign id's values OTOH are timestamps, so there's no risk of collision.
        if (curCampaign.id == campaignId || curCampaign.campaignNumber == campaignId) {
            return curCampaign;
        }
    }
    return null;
};

TestServer.prototype.dispose = function (callback) {
    this.logger.logDebug("Disposing test server");
    var self = this;
    this.close(function () {
        self.logger.dispose();
        callback();
    });
};

module.exports = TestServer;