UNPKG

siesta-lite

Version:

Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers

567 lines (416 loc) 20.9 kB
/* Siesta 5.6.1 Copyright(c) 2009-2022 Bryntum AB https://bryntum.com/contact https://bryntum.com/products/siesta/license */ !function () { const zlib = require('zlib') const URL = require('url') const http = require('http') const https = require('https') const tls = require('tls') const HttpProxy = require('http-proxy') const stream = require('stream') const net = require('net') const constants = require('constants') const NYC = require('nyc') const TestExclude = require('test-exclude') const EasyCert = require('node-easy-cert-secure') const certManager = new EasyCert({ defaultCertAttrs : [ { name : 'countryName', value : 'SE' }, { name : 'organizationName', value : 'Bryntum' }, { shortName : 'ST', value : 'Stockholm' }, { shortName : 'OU', value : 'Siesta' } ] }) // enable the debug output from the `nyc` - this will output the instrumentation failures process.env.NODE_DEBUG = 'nyc' + (process.env.NODE_DEBUG ? '' + process.env.NODE_DEBUG : '') process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' // PATCH /* the original `extractAndRegister` method is written like this: var sourceMap = convertSourceMap.fromSource(code) || convertSourceMap.fromMapFileSource(code, path.dirname(filename)) so, if `convertSourceMap.fromSource(code)` fails, it retries with loading from file, which we strictly don't want, as there's no filesystem in the browser case. so we just silence exceptions from this method */ const SourceMaps = require('nyc/lib/source-maps') const originalExtractAndRegister = SourceMaps.prototype.extractAndRegister SourceMaps.prototype.extractAndRegister = function () { try { return originalExtractAndRegister.apply(this, arguments) } catch (e) { return null } } // EOF PATCH Role('Siesta.Launcher.Role.CanLaunchInstrumentationProxy', { does : [ 'Siesta.Launcher.Role.CanWorkWithNyc' ], requires : [ 'debug' ], has : { // pick random free port by default instrumentationProxyPort : 0, instrumentationHttpsServerPort : 0, shouldInstrumentChecker : null, nyc : { is : 'ro' }, // cache of the original sources - will be used during reports generation instrumentedSources : Joose.I.Object, httpServer : null, httpsServer : null }, methods : { urlToCoverageFilename : function (url) { // we just strip the protocol and query string, and then treat the remaining string `/domain.com/path` as the filename return (url || '').replace(/^(\w+):\/\//, '/$1/').replace(/\?.*/, '') }, shouldInstrumentResponse : function (url, response) { const contentType = response.headers[ 'content-type' ] return Boolean( (contentType && (contentType.match(/application\/(x-)?javascript/) || contentType.match(/text\/javascript/))) && response.statusCode == 200 && this.shouldInstrumentChecker.shouldInstrument(this.urlToCoverageFilename(url)) ) }, instrumentJavaScriptCode : function (code, filename, relFile) { const nyc = this.getNyc() let ext, transform for (ext in nyc.transforms) { if (filename.toLowerCase().substr(-ext.length) === ext) { transform = nyc.transforms[ ext ] break } } // the `transform` function is equiped with results caching return transform ? transform(code, { filename : filename, relFile : relFile }) : null }, fetchFullResponse : function (res) { let parts = [] res.on('data', (data) => { parts.push(data) }) return new Promise((resolve, reject) => { res.on('end', () => resolve(Buffer.concat(parts))) res.on('error', (e) => reject(e)) }) }, decompressResponseBuffer : function (buffer, contentEncoding) { const gzipped = /gzip/i.test(contentEncoding) const deflated = /deflate/i.test(contentEncoding) const brotlied = /br/i.test(contentEncoding) if (gzipped) { return zlib.gunzipSync(buffer).toString() } else if (deflated) { return zlib.inflateRawSync(buffer).toString() } else if (brotlied) { // 'brotli' module, once "required", behaves __really weird__ // its code somehow randomly pops up in the error messages, and in debugger // so require it lazily, only if needed return Buffer.from(require('brotli').decompress(buffer).buffer).toString() } else return buffer.toString() }, compressResponseBuffer : function (string, contentEncoding) { const gzipped = /gzip/i.test(contentEncoding) const deflated = /deflate/i.test(contentEncoding) const brotlied = /br/i.test(contentEncoding) if (gzipped) { return zlib.gzipSync(string) } else if (deflated) { return zlib.deflateRawSync(string) } else if (brotlied) { // 'brotli' module, once "required", behaves __really weird__ // its code somehow randomly pops up in the error messages, and in debugger // so require it lazily, only if needed return Buffer.from(require('brotli').compress(Buffer.from(string)).buffer) } else return Buffer.from(string) }, // `upstreamHttpProxy` should be an object like : { host : 'somehost', port : 1111 } // `nycArgs` should be a result of `buildNycArgv` call setupInstrumentationProxy : async function (upstreamProxy, nycArgs) { nycArgs = nycArgs || this.buildNycArgv([]) if (!nycArgs.exclude) nycArgs.exclude = [] if (!Array.isArray(nycArgs.exclude)) nycArgs.exclude = [ nycArgs.exclude ] nycArgs.exclude.push( '**/siesta-all.js', '**/siesta-no-ui-all.js', '**/*.t.js' ) this.nyc = await this.buildNyc(nycArgs) const shouldInstrumentChecker = this.shouldInstrumentChecker = new TestExclude({ relativePath : false, include : nycArgs.include, exclude : nycArgs.exclude }) // override the `TestExclude` instance in Nyc to be the same as we use in other places this.nyc.exclude = shouldInstrumentChecker const httpProxy = HttpProxy.createProxyServer({ secure : false, selfHandleResponse : true }) httpProxy.on('proxyReq', (proxyReq, req, res, options) => { const port = this.instrumentationProxyPort + '' // console.log(`PROXYING request for ${req.url}, headers: ${proxyReq.headers}`, req.headers) if (/do_not_proxy/i.test(req.url) || req.headers.host.slice(-port.length) == port) { res.end('Test request completed successfully') return } }) httpProxy.on('proxyRes', (proxyRes, req, res) => { const url = this.getRequestFullUrl(req) const shouldInstrument = this.nyc.config.instrument && this.shouldInstrumentResponse(url, proxyRes) this.debug(`PROXYING request for ${url} will instrument: ${shouldInstrument}`) if (shouldInstrument) { this.fetchFullResponse(proxyRes).then( (buffer) => { const responseBuff = this.processResponseFromProxy(buffer, proxyRes, req, res) // fix the `content-length` header value const headers = Object.assign({}, proxyRes.headers, { 'content-length' : Buffer.byteLength(responseBuff) }) res.writeHead(proxyRes.statusCode, proxyRes.statusMessage, headers) res.end(responseBuff) }, (e) => { res.end(`Exception while fetching proxied response: ${e}`) } ) } else { res.writeHead(proxyRes.statusCode, proxyRes.statusMessage, proxyRes.headers) proxyRes.pipe(res) } }) // this is just to silence the built-in exception throwing from http-proxy httpProxy.on('error', (e) => { // console.log("Error from http-node-proxy, should be handled somewhere else: ", e, e.stack) }) this.debug("Starting instrumentation proxy") return Promise.all([ new Promise((resolve, reject) => { const server = this.httpServer = this.createHttpServer(upstreamProxy, httpProxy) server.listen(this.instrumentationProxyPort, () => { const port = server.address().port this.instrumentationProxyPort = port this.debug(`HTTP Instrumentation proxy has started on ${server.address().address}:${port}`) resolve() }) }), this.ensureRootCertificateGenerated().then(() => { return new Promise((resolve, reject) => { const server = this.httpsServer = this.createHttpsServer(upstreamProxy, httpProxy) server.listen(this.instrumentationHttpsServerPort, () => { const port = server.address().port this.instrumentationHttpsServerPort = port this.debug(`HTTPS Instrumentation proxy has started on ${server.address().address}:${port}`) resolve() }) }) }) ]) }, ensureRootCertificateGenerated : function () { if (certManager.isRootCAFileExists()) return Promise.resolve() else return new Promise((resolve, reject) => { certManager.generateRootCA( { commonName : 'SiestaInstrumentationProxy' }, (e) => e ? reject(e) : resolve() ) }) }, createHttpsServer : function (upstreamProxy, httpProxy) { const server = https.createServer( { secureOptions : constants.SSL_OP_NO_SSLv3 || constants.SSL_OP_NO_TLSv1, SNICallback : (serverName, callback) => { certManager.getCertificate(serverName, (e, key, crt) => { if (e) { this.debug(`Error while retrieving certificate for ${serverName}`) callback(e) return } let ctx try { ctx = tls.createSecureContext({ key : key, cert : crt }) } catch (e) { this.debug(`Error while creating HTTPS secure context for ${serverName}`) callback(e) return } this.debug(`HTTPS secure context initialized for ${serverName} created`) callback(null, ctx) }) } }, this.getHttpRequestHandler(upstreamProxy, httpProxy) ) server.on('upgrade', this.getUpgradeRequestHandler(upstreamProxy, httpProxy)) return server }, createHttpServer : function (upstreamProxy, httpProxy) { const server = http.createServer(this.getHttpRequestHandler(upstreamProxy, httpProxy)) server.on('connect', (req, socket, head) => this.onHttpServerConnectEvent(req, socket, head, upstreamProxy, httpProxy)) server.on('upgrade', this.getUpgradeRequestHandler(upstreamProxy, httpProxy)) return server }, // in http and https servers, the `req.url` property is different // in http it contains full url with protocol and host // in https it contains only the "path" part // normalizing this getRequestFullUrl : function (req) { if (/^\w+:\//.test(req.url)) return req.url return (req.connection.encrypted ? 'https://' : 'http://') + req.headers.host + req.url }, createSocketOverHttpProxy : function (upstreamProxy, targetHost) { // this.debug(`Establishing CONNECT tunnel for ${targetHost} via ${upstreamProxy.host}:${upstreamProxy.port}`) const request = http.request({ method : 'CONNECT', hostname : upstreamProxy.host, port : upstreamProxy.port, path : targetHost }) return new Promise((resolve, reject) => { request.on('connect', (response, socket, head) => { if (response.statusCode == 200) { socket.setTimeout(0) socket.setNoDelay(true) socket.setKeepAlive(true, 0) resolve(socket) } else { reject({ response : response }) } }) request.on('error', (e) => { reject({ error : e }) }) request.end() }) }, createSocketFor : function (port, host, upstreamProxy) { if (upstreamProxy) { return this.createSocketOverHttpProxy(upstreamProxy, host + ':' + port) } else { return new Promise((resolve, reject) => { const proxySocket = net.connect(port, host, () => { resolve(proxySocket) }) proxySocket.on('error', reject) }) } }, getRegularProxyOptions : function (req) { const url = URL.parse(req.url) return { target : { protocol : url.protocol || (req.connection.encrypted ? 'https:' : 'http:'), host : url.hostname || req.headers.host.split(':')[ 0 ], port : url.port || req.headers.host.split(':')[ 1 ], }, changeOrigin : true } }, getUpgradeRequestHandler : function (upstreamProxy, httpProxy) { return (req, socket, head) => { this.debug(`Upgrading connection for request: ${req.url}`) if (upstreamProxy) { this.createSocketOverHttpProxy(upstreamProxy, req.headers.host).then((tunnelSocket) => { socket.unshift(head) const options = this.getRegularProxyOptions(req) options.createConnection = (options, cb) => cb(null, tunnelSocket) httpProxy.ws(req, socket, head, options) }, (e) => { this.debug(`Could not establish CONNECT tunnel with upstream proxy: ${upstreamProxy.host}:${upstreamProxy.port}\n`) }) } else { httpProxy.ws(req, socket, head, this.getRegularProxyOptions(req)) } } }, getTargetHostOfRequest : function (req) { const host = req.headers.host if (host.match(/:\d+$/)) return host return req.connection.encrypted ? host + ':443' : host + ':80' }, getHttpRequestHandler : function (upstreamProxy, httpProxy) { return (req, res) => { if (upstreamProxy) { // req.headers.host may not contain port sometimes this.createSocketOverHttpProxy(upstreamProxy, this.getTargetHostOfRequest(req)).then((socket) => { const options = this.getRegularProxyOptions(req) options.createConnection = (options, cb) => cb(null, socket) httpProxy.web(req, res, options) }, (e) => { this.debug(`Could not establish CONNECT tunnel with upstream proxy: ${upstreamProxy.host}`) const response = e.response if (response) { this.debug(`statusCode=${response.statusCode}, message=${response.statusMessage}`) res.writeHead(response.statusCode, response.statusMessage, response.headers) response.pipe(res) } else { this.debug(JSON.stringify(e.error)) res.writeHead(500, `Could not establish CONNECT tunnel with upstream proxy ${upstreamProxy.host}-${upstreamProxy.port}`) res.end(JSON.stringify(e.error)) } }) } else { httpProxy.web(req, res, this.getRegularProxyOptions(req)) } } }, onHttpServerConnectEvent : function (req, socket, head, upstreamHttpProxy, httpProxy) { this.debug(`Received CONNECT request for: ${req.url}`) const targetHost = req.url.split(':')[ 0 ] const targetPort = req.url.split(':')[ 1 ] return new Promise((resolve, reject) => { socket.write('HTTP/' + req.httpVersion + ' 200 OK\r\n\r\n', 'utf8', resolve); }).then(() => { socket.unshift(head) // pass-through the CONNECT to saucelabs and browserstack, to avoid certificate errors if (targetHost.match(/saucelabs\.com$/i) || targetHost.match(/browserstack\.com$/i)) { return this.createSocketFor(targetPort, targetHost, upstreamHttpProxy).then((proxySocket) => { socket.pipe(proxySocket) proxySocket.pipe(socket) }) } else { if (targetPort === '80') { this.httpServer.emit('connection', socket) } else { this.httpsServer.emit('connection', socket) } } }).catch((error) => { this.debug(`Error during request proxying: ${error.stack}`) try { socket.write([ 'HTTP/1.1 502', 'Content-Type: text/html', '', '', error, error.stack ].join('\r\n')) } catch (e) { } }) }, processResponseFromProxy : function (buffer, proxyRes, req, res) { const contentEncoding = proxyRes.headers[ 'content-encoding' ] const code = this.decompressResponseBuffer(buffer, contentEncoding) const fileName = this.urlToCoverageFilename(this.getRequestFullUrl(req)) const instrumented = this.instrumentJavaScriptCode(code, fileName) if (instrumented) { this.instrumentedSources[ fileName ] = code } const response = instrumented || code return this.compressResponseBuffer(response, contentEncoding) } } }) }();