karma-runner/karma

View on GitHub
lib/middleware/karma.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * 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