UNPKG

testem

Version:

Test'em 'scripts! Javascript Unit testing made easy.

410 lines (359 loc) 11.5 kB
/* server.js ========= Testem's server. Serves up the HTML, JS, and CSS required for running the tests in a browser. */ 'use strict'; var express = require('express'); var socketIO = require('socket.io'); var compression = require('compression'); var fs = require('fs'); var path = require('path'); var log = require('npmlog'); var EventEmitter = require('events').EventEmitter; var Mustache = require('consolidate').mustache; var http = require('http'); var https = require('https'); var httpProxy = require('http-proxy'); var Bluebird = require('bluebird'); var readFileAsync = Bluebird.promisify(fs.readFile); class Server extends EventEmitter { constructor(config) { super(); this.config = config; this.ieCompatMode = null; // Maintain a hash of all connected sockets to close them manually // Workaround https://github.com/joyent/node/issues/9066 this.sockets = {}; this.nextSocketId = 0; } start(callback) { this.createExpress(); // Start the server! // Create socket.io sockets this.server.on('connection', socket => { var socketId = this.nextSocketId++; this.sockets[socketId] = socket; socket.on('close', () => { delete this.sockets[socketId]; }); }); return new Bluebird.Promise((resolve, reject) => { this.server.on('listening', () => { this.config.set('port', this.server.address().port); resolve(); this.emit('server-start'); }); this.server.on('error', e => { this.stopped = true; reject(e); this.emit('server-error', e); }); this.server.listen(this.config.get('port')); }).asCallback(callback); } stop(callback) { if (this.server && !this.stopped) { this.stopped = true; return Bluebird.fromCallback(closeCallback => { this.server.close(closeCallback); // Destroy all open sockets for (var socketId in this.sockets) { this.sockets[socketId].destroy(); } }).asCallback(callback); } else { return Bluebird.resolve().asCallback(callback); } } createExpress() { var app = this.express = express(); app.use( compression({ filter(req, res) { if (res.getHeader('x-no-compression')) { // don't compress responses with this response header return false; } let type = res.getHeader('Content-Type'); if (type && type.indexOf('text/event-stream') > -1) { // don't attempt to compress server sent events return false; } else { return compression.filter(req, res); } }, }) ); var serveStaticFile = (req, res) => { this.serveStaticFile(req.params[0], req, res); }; if (this.config.get('key') || this.config.get('pfx')) { var options = {}; if (this.config.get('key')) { options.key = fs.readFileSync(this.config.get('key')); options.cert = fs.readFileSync(this.config.get('cert')); } else { options.pfx = fs.readFileSync(this.config.get('pfx')); } this.server = https.createServer(options, this.express); } else { this.server = http.createServer(this.express); } let ioOptions = {}; if (this.config.get('socket_server_options')) { ioOptions = this.config.get('socket_server_options'); } if (this.config.get('socket_heartbeat_timeout')) { ioOptions.pingTimeout = this.config.get('socket_heartbeat_timeout') * 1000; } this.io = socketIO(this.server, ioOptions); this.io.on('connection', this.onClientConnected.bind(this)); this.configureExpress(app); this.injectMiddleware(app); app.use('/testem', express.static(`${__dirname}/../../public/testem`)); this.configureProxy(app); app.get('/', (req, res) => { res.redirect(`/${String(Math.floor(Math.random() * 10000))}`); }); app.get(/\/(-?[0-9]+)$/, (req, res) => { this.serveHomePage(req, res); }); app.get('/testem.js', (req, res) => { this.serveTestemClientJs(req, res); }); app.all(/^\/(?:-?[0-9]+)(\/.+)$/, serveStaticFile); app.all(/^(.+)$/, serveStaticFile); app.use((err, req, res, next) => { if (err) { log.error(err.message); if (err.code === 'ENOENT') { res.status(404).send(`Not found: ${req.url}`); } else { res.status(500).send(err.message); } } else { next(); } }); } configureExpress(app) { app.engine('mustache', Mustache); app.set('view options', { layout: false }); app.set('etag', 'strong'); app.use((req, res, next) => { if (this.ieCompatMode) { res.setHeader('X-UA-Compatible', `IE=${this.ieCompatMode}`); } next(); }); } injectMiddleware(app) { var middlewares = this.config.get('middleware'); if (middlewares) { middlewares.forEach(middleware => { middleware(app); }); } } shouldProxy(req, opts) { var accepts; var acceptCheck = [ 'html', 'css', 'javascript' ]; //Only apply filtering logic if 'onlyContentTypes' key is present if (!('onlyContentTypes' in opts)) { return true; } acceptCheck = acceptCheck.concat(opts.onlyContentTypes); acceptCheck.push('text'); accepts = req.accepts(acceptCheck); if (accepts.indexOf(opts.onlyContentTypes) !== -1) { return true; } return false; } configureProxy(app) { var proxies = this.config.get('proxies'); if (proxies) { this.proxy = new httpProxy.createProxyServer({ changeOrigin: true }); this.proxy.on('error', (err, req, res) => { res && res.status && res.status(500).json(err); }); Object.keys(proxies).forEach((url) => { if (proxies[url].ws === true) { // set up proxy for WebSocket this.server.on('upgrade', (req, socket, head) => { if (req.url.startsWith(url)) { this.proxy.ws(req, socket, head, proxies[url]); } }); return; } app.all(`${url}*`, (req, res, next) => { var opts = proxies[url]; if (this.shouldProxy(req, opts)) { if (opts.host) { opts.target = `http://${opts.host}:${opts.port}`; delete opts.host; delete opts.port; } this.proxy.web(req, res, opts); } else { next(); } }); }); } } renderRunnerPage(err, files, footerScripts, res) { var config = this.config; var framework = config.get('framework') || 'jasmine'; var css_files = config.get('css_files'); var templateFile = { jasmine: 'jasminerunner', jasmine2: 'jasmine2runner', qunit: 'qunitrunner', mocha: 'mocharunner', 'mocha+chai': 'mochachairunner', custom: 'customrunner', tap: 'taprunner' }[framework] + '.mustache'; res.render(`${__dirname}/../../views/${templateFile}`, { scripts: files, styles: css_files, footer_scripts: footerScripts }); } renderDefaultTestPage(req, res) { var config = this.config; var test_page = config.get('test_page')[0]; if (test_page) { if (test_page[0] === '/') { test_page = encodeURIComponent(test_page); } var base = req.path === '/' ? req.path : `${req.path}/`; var url = base + test_page; res.redirect(url); } else { config.getServeFiles((err, files) => { config.getFooterScripts((err, footerScripts) => { this.renderRunnerPage(err, files, footerScripts, res); }); }); } } serveHomePage(req, res) { var config = this.config; var routes = config.get('routes') || config.get('route') || {}; if (routes['/']) { this.serveStaticFile('/', req, res); } else { this.renderDefaultTestPage(req, res); } } serveTestemClientJs(req, res) { res.setHeader('Content-Type', 'text/javascript'); res.write(';(function(){'); res.write('\n//============== config ==================\n\n'); res.write(`var TestemConfig = ${JSON.stringify(this.config.client())};`); var files = [ 'decycle.js', 'jasmine_adapter.js', 'jasmine2_adapter.js', 'qunit_adapter.js', 'mocha_adapter.js', 'testem_client.js' ]; Bluebird.each(files, file => { if (file.indexOf(path.sep) === -1) { file = `${__dirname}/../../public/testem/${file}`; } return readFileAsync(file).then(data => { res.write(`\n//============== ${path.basename(file)} ==================\n\n`); res.write(data); }).catch(err => { res.write(`// Error reading ${file}: ${err}`); }); }).then(() => { res.write('}());'); res.end(); }); } route(uri) { var config = this.config; var routes = config.get('routes') || config.get('route') || {}; var bestMatchLength = 0; var bestMatch = null; var prefixes = Object.keys(routes); prefixes.forEach(prefix => { if (uri.substring(0, prefix.length) === prefix) { if (bestMatchLength < prefix.length) { if (routes[prefix] instanceof Array) { routes[prefix].some(folder => { bestMatch = `${folder}/${uri.substring(prefix.length)}`; return fs.existsSync(config.resolvePath(bestMatch)); }); } else { bestMatch = `${routes[prefix]}/${uri.substring(prefix.length)}`; } bestMatchLength = prefix.length; } } }); return { routed: !!bestMatch, uri: bestMatch || uri.substring(1) }; } serveStaticFile(uri, req, res) { var config = this.config; var routeRes = this.route(uri); uri = routeRes.uri; var wasRouted = routeRes.routed; var allowUnsafeDirs = config.get('unsafe_file_serving'); var filePath = path.resolve(config.resolvePath(uri)); var ext = path.extname(filePath); var isPathPermitted = filePath.indexOf(path.resolve(config.cwd())) !== -1; if (!wasRouted && !allowUnsafeDirs && !isPathPermitted) { res.status(403); res.write('403 Forbidden'); res.end(); } else if (ext === '.mustache') { config.getTemplateData((err, data) => { res.render(filePath, data); this.emit('file-requested', filePath); }); } else { fs.stat(filePath, (err, stat) => { this.emit('file-requested', filePath); if (err) { return res.sendFile(filePath); } if (stat.isDirectory()) { fs.readdir(filePath, (err, files) => { var dirListingPage = `${__dirname}/../../views/directorylisting.mustache`; res.render(dirListingPage, { files: files }); }); } else { res.sendFile(filePath); } }); } } onClientConnected(client) { client.once('browser-login', (browserName, id) => { log.info(`New client connected: ${browserName} ${id} ${client.id}`); this.emit('browser-login', browserName, id, client); }); client.once('browser-relogin', (browserName, id) => { log.info(`Client reconnected: ${browserName} ${id} ${client.id}`); this.emit('browser-relogin', browserName, id, client); }); } } module.exports = Server;