lib/middleware/karma.js
/**
* Karma middleware is responsible for serving:
* - client.html (the entrypoint for capturing a browser)
* - debug.html
* - context.html (the execution context, loaded within an iframe)
* - karma.js
*
* The main part is generating context.html, as it contains:
* - generating mappings
* - including <script> and <link> tags
* - setting propert caching headers
*/
const url = require('url')
const log = require('../logger').create('middleware:karma')
const stripHost = require('./strip_host').stripHost
const common = require('./common')
const VERSION = require('../constants').VERSION
const SCRIPT_TYPE = {
js: 'text/javascript',
module: 'module'
}
const FILE_TYPES = [
'css',
'html',
'js',
'module',
'dom'
]
function filePathToUrlPath (filePath, basePath, urlRoot, proxyPath) {
if (filePath.startsWith(basePath)) {
return proxyPath + urlRoot.slice(1) + 'base' + filePath.slice(basePath.length)
}
return proxyPath + urlRoot.slice(1) + 'absolute' + filePath
}
function getQuery (urlStr) {
// eslint-disable-next-line node/no-deprecated-api
return url.parse(urlStr, true).query || {}
}
function getXUACompatibleMetaElement (url) {
const query = getQuery(url)
if (query['x-ua-compatible']) {
return `<meta http-equiv="X-UA-Compatible" content="${query['x-ua-compatible']}"/>`
}
return ''
}
function getXUACompatibleUrl (url) {
const query = getQuery(url)
if (query['x-ua-compatible']) {
return '?x-ua-compatible=' + encodeURIComponent(query['x-ua-compatible'])
}
return ''
}
function createKarmaMiddleware (
filesPromise,
serveStaticFile,
serveFile,
injector,
basePath,
urlRoot,
upstreamProxy,
browserSocketTimeout
) {
const proxyPath = upstreamProxy ? upstreamProxy.path : '/'
return function (request, response, next) {
// These config values should be up to date on every request
const client = injector.get('config.client')
const customContextFile = injector.get('config.customContextFile')
const customDebugFile = injector.get('config.customDebugFile')
const customClientContextFile = injector.get('config.customClientContextFile')
const includeCrossOriginAttribute = injector.get('config.crossOriginAttribute')
const normalizedUrl = stripHost(request.url) || request.url
// For backwards compatibility in middleware plugins, remove in v4.
request.normalizedUrl = normalizedUrl
let requestUrl = normalizedUrl.replace(/\?.*/, '')
const requestedRangeHeader = request.headers.range
// redirect /__karma__ to /__karma__ (trailing slash)
if (requestUrl === urlRoot.slice(0, -1)) {
response.setHeader('Location', proxyPath + urlRoot.slice(1))
response.writeHead(301)
return response.end('MOVED PERMANENTLY')
}
// ignore urls outside urlRoot
if (!requestUrl.startsWith(urlRoot)) {
return next()
}
// remove urlRoot prefix
requestUrl = requestUrl.slice(urlRoot.length - 1)
// serve client.html
if (requestUrl === '/') {
// redirect client_with_context.html
if (!client.useIframe && client.runInParent) {
requestUrl = '/client_with_context.html'
} else { // serve client.html
return serveStaticFile('/client.html', requestedRangeHeader, response, (data) =>
data
.replace('%X_UA_COMPATIBLE%', getXUACompatibleMetaElement(request.url))
.replace('%X_UA_COMPATIBLE_URL%', getXUACompatibleUrl(request.url)))
}
}
if (['/karma.js', '/context.js', '/debug.js'].includes(requestUrl)) {
return serveStaticFile(requestUrl, requestedRangeHeader, response, (data) =>
data
.replace('%KARMA_URL_ROOT%', urlRoot)
.replace('%KARMA_VERSION%', VERSION)
.replace('%KARMA_PROXY_PATH%', proxyPath)
.replace('%BROWSER_SOCKET_TIMEOUT%', browserSocketTimeout))
}
// serve the favicon
if (requestUrl === '/favicon.ico') {
return serveStaticFile(requestUrl, requestedRangeHeader, response)
}
// serve context.html - execution context within the iframe
// or debug.html - execution context without channel to the server
const isRequestingContextFile = requestUrl === '/context.html'
const isRequestingDebugFile = requestUrl === '/debug.html'
const isRequestingClientContextFile = requestUrl === '/client_with_context.html'
if (isRequestingContextFile || isRequestingDebugFile || isRequestingClientContextFile) {
return filesPromise.then((files) => {
let fileServer
let requestedFileUrl
log.debug('custom files', customContextFile, customDebugFile, customClientContextFile)
if (isRequestingContextFile && customContextFile) {
log.debug(`Serving customContextFile ${customContextFile}`)
fileServer = serveFile
requestedFileUrl = customContextFile
} else if (isRequestingDebugFile && customDebugFile) {
log.debug(`Serving customDebugFile ${customDebugFile}`)
fileServer = serveFile
requestedFileUrl = customDebugFile
} else if (isRequestingClientContextFile && customClientContextFile) {
log.debug(`Serving customClientContextFile ${customClientContextFile}`)
fileServer = serveFile
requestedFileUrl = customClientContextFile
} else {
log.debug(`Serving static request ${requestUrl}`)
fileServer = serveStaticFile
requestedFileUrl = requestUrl
}
fileServer(requestedFileUrl, requestedRangeHeader, response, function (data) {
common.setNoCacheHeaders(response)
const scriptTags = []
for (const file of files.included) {
let filePath = file.path
const fileType = file.type || file.detectType()
if (!FILE_TYPES.includes(fileType)) {
if (file.type == null) {
log.warn(
'Unable to determine file type from the file extension, defaulting to js.\n' +
` To silence the warning specify a valid type for ${file.originalPath} in the configuration file.\n` +
' See https://karma-runner.github.io/latest/config/files.html'
)
} else {
log.warn(`Invalid file type (${file.type || 'empty string'}), defaulting to js.`)
}
}
if (!file.isUrl) {
filePath = filePathToUrlPath(filePath, basePath, urlRoot, proxyPath)
if (requestUrl === '/context.html') {
filePath += '?' + file.sha
}
}
const integrityAttribute = file.integrity ? ` integrity="${file.integrity}"` : ''
const crossOriginAttribute = includeCrossOriginAttribute ? ' crossorigin="anonymous"' : ''
if (fileType === 'css') {
scriptTags.push(`<link type="text/css" href="${filePath}" rel="stylesheet"${integrityAttribute}${crossOriginAttribute}>`)
} else if (fileType === 'dom') {
scriptTags.push(file.content)
} else if (fileType === 'html') {
scriptTags.push(`<link href="${filePath}" rel="import"${integrityAttribute}${crossOriginAttribute}>`)
} else {
const scriptType = (SCRIPT_TYPE[fileType] || 'text/javascript')
if (fileType === 'module') {
scriptTags.push(`<script onerror="throw 'Error loading ${filePath}'" type="${scriptType}" src="${filePath}"${integrityAttribute}${crossOriginAttribute}></script>`)
} else {
scriptTags.push(`<script type="${scriptType}" src="${filePath}"${integrityAttribute}${crossOriginAttribute}></script>`)
}
}
}
const scriptUrls = []
// For client_with_context, html elements are not added directly through an iframe.
// Instead, scriptTags is stored to window.__karma__.scriptUrls first. Later, the
// client will read window.__karma__.scriptUrls and dynamically add them to the DOM
// using DOMParser.
if (requestUrl === '/client_with_context.html') {
for (const script of scriptTags) {
scriptUrls.push(
// Escape characters with special roles (tags) in HTML. Open angle brackets are parsed as tags
// immediately, even if it is within double quotations in browsers
script.replace(/</g, '\\x3C').replace(/>/g, '\\x3E'))
}
}
const mappings = data.includes('%MAPPINGS%') ? files.served.map((file) => {
const filePath = filePathToUrlPath(file.path, basePath, urlRoot, proxyPath)
.replace(/\\/g, '\\\\') // Windows paths contain backslashes and generate bad IDs if not escaped
.replace(/'/g, '\\\'') // Escape single quotes - double quotes should not be allowed!
return ` '${filePath}': '${file.sha}'`
}) : []
return data
.replace('%SCRIPTS%', () => scriptTags.join('\n'))
.replace('%CLIENT_CONFIG%', 'window.__karma__.config = ' + JSON.stringify(client) + ';\n')
.replace('%SCRIPT_URL_ARRAY%', () => 'window.__karma__.scriptUrls = ' + JSON.stringify(scriptUrls) + ';\n')
.replace('%MAPPINGS%', () => 'window.__karma__.files = {\n' + mappings.join(',\n') + '\n};\n')
.replace('%X_UA_COMPATIBLE%', getXUACompatibleMetaElement(request.url))
})
})
} else if (requestUrl === '/context.json') {
return filesPromise.then((files) => {
common.setNoCacheHeaders(response)
response.writeHead(200)
response.end(JSON.stringify({
files: files.included.map((file) => filePathToUrlPath(file.path + '?' + file.sha, basePath, urlRoot, proxyPath))
}))
})
}
return next()
}
}
createKarmaMiddleware.$inject = [
'filesPromise',
'serveStaticFile',
'serveFile',
'injector',
'config.basePath',
'config.urlRoot',
'config.upstreamProxy',
'config.browserSocketTimeout'
]
// PUBLIC API
exports.create = createKarmaMiddleware