siesta-lite
Version:
Stress-free JavaScript unit testing and functional testing tool, works in NodeJS and browsers
567 lines (416 loc) • 20.9 kB
JavaScript
/*
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)
}
}
})
}();