blob: 4bcb98f802769b4809186587b8fabfdc19776d79 [file] [log] [blame]
/**
* 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 path = require('path')
const url = require('url')
const helper = require('../helper')
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',
'dart': 'application/dart',
'module': 'module'
}
const FILE_TYPES = [
'css',
'html',
'js',
'dart',
'module',
'dom'
]
function filePathToUrlPath (filePath, basePath, urlRoot, proxyPath) {
if (filePath.startsWith(basePath)) {
return proxyPath + urlRoot.substr(1) + 'base' + filePath.substr(basePath.length)
}
return proxyPath + urlRoot.substr(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 `\n<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,
readFilePromise,
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.substr(0, urlRoot.length - 1)) {
response.setHeader('Location', proxyPath + urlRoot.substr(1))
response.writeHead(301)
return response.end('MOVED PERMANENTLY')
}
// ignore urls outside urlRoot
if (!requestUrl.startsWith(urlRoot)) {
return next()
}
// remove urlRoot prefix
requestUrl = requestUrl.substr(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('\n%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'
const includedContent = new Map() // file.path -> content
if (isRequestingContextFile || isRequestingDebugFile || isRequestingClientContextFile) {
return filesPromise.then((files) => {
// Read any files.included that will be directly written into HTML before HTML is read.
const contentReads = []
for (const file of files.included) {
const fileType = file.type || path.extname(file.path).substring(1)
if (fileType === 'dom') {
contentReads.push(
readFilePromise(file.path).then((content) => includedContent.set(file.path, content))
)
}
}
return Promise.all(contentReads).then(() => files)
}).then(function (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 || path.extname(filePath).substring(1)
if (helper.isDefined(fileType) && !FILE_TYPES.includes(fileType)) {
log.warn(`Invalid file type (${fileType}), defaulting to js.`)
}
if (!file.isUrl) {
filePath = filePathToUrlPath(filePath, basePath, urlRoot, proxyPath)
if (requestUrl === '/context.html') {
filePath += '?' + file.sha
}
}
if (fileType === 'css') {
scriptTags.push(`<link type="text/css" href="${filePath}" rel="stylesheet">`)
} else if (fileType === 'dom') {
scriptTags.push(includedContent.get(file.path))
} else if (fileType === 'html') {
scriptTags.push(`<link href="${filePath}" rel="import">`)
} else {
const scriptType = (SCRIPT_TYPE[fileType] || 'text/javascript')
const crossOriginAttribute = includeCrossOriginAttribute ? 'crossorigin="anonymous"' : ''
scriptTags.push(`<script type="${scriptType}" src="${filePath}" ${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('\n%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',
'readFilePromise',
'injector',
'config.basePath',
'config.urlRoot',
'config.upstreamProxy',
'config.browserSocketTimeout'
]
// PUBLIC API
exports.create = createKarmaMiddleware