发布于

Karma源码解析

Authors
  • avatar
    Name
    田中原
    Twitter

Karma源码解析

1. 简介

Karma本质上是一个工具,它生成一个Web服务器,该Web服务器针对每个连接的浏览器针对测试代码执行源代码。对每个浏览器进行的每个测试的结果都会经过检查,并通过命令行显示给开发人员,以便可以查看哪些浏览器和测试通过或失败。可以通过以下方式捕获浏览器:通过访问Karma服务器正在侦听的URL手动捕获(通常是http://localhost:9876),也可以通过让Karma知道运行Karma时启动哪些浏览器来自动捕获。 Karma还监视配置文件中指定的所有文件,并且只要任何文件发生更改,它都会通过向测试服务器发送信号通知所有捕获的浏览器再次运行测试代码来触发测试运行。然后,每个浏览器都将源文件加载到IFrame中,执行测试并将结果报告给服务器。服务器从所有捕获的浏览器中收集结果,并将其提供给开发人员。

2. 架构图

架构图

Karma总体架构模型是Client-Server(C/S)结构 (客户端-服务器)。 通过socket进行双向通信。

3. 工作流程概述

启动后,Karma加载插件和配置文件,然后先运行预处理,预处理结束后启动其本地Web服务器以监听连接,测试报告组件注册相关的“浏览器”事件,以便当测试结束后输出测试报告。然后,Karma启动零个,一个或多个浏览器,并将其起始页面设置为Karma服务器URL。 当浏览器连接时,Karma将提供client.html页面;当该页面在浏览器中运行时,它将通过websocket连接回服务器。服务器看到websocket连接后,便指示客户端(通过websocket)执行测试。 客户端页面会从服务器打开带有context.html页面的iframe。服务器使用配置生成此context.html页面。该页面包括测试框架适配器,要测试的代码和测试代码。 当浏览器加载此上下文页面时,onload事件处理程序通过postMessage将上下文页面连接到客户端页面。 框架适配器此时负责:运行测试,通过通过客户端页面进行消息传递来报告错误或成功。发送到客户端页面的消息通过WebSocket转发到Karma服务器。 服务器将这些消息重新分配为“浏览器”事件。收听“浏览器”事件的reporter获取数据。他们可以打印,保存到文件或将数据转发到其他服务。

4. 主要代码结构

karma主要代码结构

.
├── bin
│   └── karma               # 命令运行入口
├── client                  # 浏览器内运行
│   ├── constants.js        # 变量模板,运行时由Node替换
│   ├── karma.js            # 主类:运行单元测试
│   ├── main.js             # 浏览器端入口文件
│   └── updater.js          # 更新运行状态到页面banner等
├── context                 # 用于iframe和parentWindow的沟通
│   ├── karma.js
│   └── main.js
├── lib                     # 服务端主要代码
│   ├── browser.js          # 控制浏览器状态 接收socket信息,发送EventEmit触发相应的事件
│   ├── browser_collection.js # 浏览器集合
│   ├── browser_result.js
│   ├── cli.js              # 入口文件
│   ├── completion.js
│   ├── config.js
│   ├── constants.js
│   ├── detached.js         # 分离运行karma
│   ├── emitter_wrapper.js
│   ├── events.js           # KarmaEventEmitter 事件
│   ├── executor.js         # 通知浏览器执行测试
│   ├── file-list.js        # 文件管理系统
│   ├── file.js
│   ├── helper.js
│   ├── index.js
│   ├── init
│   │   ├── color_schemes.js
│   │   ├── formatters.js
│   │   ├── log-queue.js
│   │   └── state_machine.js
│   ├── init.js             # karma init命令
│   ├── launcher.js         # 启动浏览器
│   ├── launchers
│   │   ├── base.js
│   │   ├── capture_timeout.js
│   │   ├── process.js
│   │   └── retry.js
│   ├── logger.js           # log4js 日志模块
│   ├── middleware          # http服务中间件
│   │   ├── common.js
│   │   ├── karma.js
│   │   ├── proxy.js
│   │   ├── runner.js
│   │   ├── source_files.js
│   │   ├── stopper.js
│   │   └── strip_host.js
│   ├── plugin.js
│   ├── preprocessor.js     # 预处理
│   ├── reporter.js         # 测试报告
│   ├── reporters
│   │   ├── base.js
│   │   ├── base_color.js
│   │   ├── dots.js
│   │   ├── dots_color.js
│   │   ├── multi.js
│   │   ├── progress.js
│   │   └── progress_color.js
│   ├── runner.js         # run命令使用
│   ├── server.js         # 主流程 Manager
│   ├── stopper.js
│   ├── temp_dir.js
│   ├── watcher.js        # 文件监控
│   └── web-server.js     # http服务
├── static
│   ├── client.html
│   ├── client_with_context.html # runInParent为false时,不用iframe
│   ├── context.html
│   ├── debug.html
│   ├── debug.js
│   └── favicon.ico

5. 主要模块的实现

5.1 服务端

服务端架构图 服务端架构图

5.1.1 文件监控Watcher

文件监控使用的是包装Node原生API(fs.watchfs.watchFile)库:chokidar。 Watcher通过chokidar监视特定文件和目录。 每次这些文件或目录更改时,chokidar会发出事件。Watcher监听这些事件并在内部调用FS模块。

我们项目遇到的相关问题: 我们项目在yarn run test:watch时,如果测试报告包含测试覆盖率coverage。入口文件glob能匹配coverage生成的文件,会导致 Watcher的change事件不停触发。

function watch(patterns, excludes, fileList, usePolling, emitter) {
  const watchedPatterns = getWatchedPatterns(patterns)
  const chokidar = require('chokidar')
  const watcher = new chokidar.FSWatcher({
    usePolling: usePolling,
    ignorePermissionErrors: true,
    ignoreInitial: true,
    ignored: createIgnore(watchedPatterns, excludes),
  })

  watchPatterns(watchedPatterns, watcher)

  watcher
    .on('add', (path) => fileList.addFile(helper.normalizeWinPath(path)))
    .on('change', (path) => fileList.changeFile(helper.normalizeWinPath(path)))
    .on('unlink', (path) => fileList.removeFile(helper.normalizeWinPath(path)))
    .on('error', log.debug.bind(log))

  emitter.on('exit', (done) => {
    watcher.close()
    done()
  })
  return watcher
}

5.1.2 文件系统 FileList

文件系统模块的主要目的是最大程度地减少对实际文件的访问,以及减少网络流量。 FileList实例包含有关被测项目的元数据。这些文件是通过配置文件中glob规则匹配的(例如test/**/*.spec.js

FileList在内部维护存储bucket列表,一个bucket代表了一个可以匹配多个文件的单个glob。 一个bucket包含了一个File对象列表。每个对象代表一个通过glob匹配的真实文件。

class FileList {
  constructor(patterns, excludes, emitter, preprocess, autoWatchBatchDelay) {
    this._patterns = patterns || []
    this._excludes = excludes || []
    this._emitter = emitter
    this._preprocess = preprocess

    this.buckets = new Map()
  }

  _refresh() {
    // ... 之前代码省略
    this._patterns.map(async ({ pattern, type, nocache, isBinary }) => {
      if (helper.isUrlAbsolute(pattern)) {
        this.buckets.set(pattern, [new Url(pattern, type)])
        return
      }

      const mg = new Glob(pathLib.normalize(pattern), {
        cwd: '/',
        follow: true,
        nodir: true,
        sync: true,
      })

      const files = mg.found
        .filter((path) => {
          if (this._findExcluded(path)) {
            log.debug(`Excluded file "${path}"`)
            return false
          } else if (matchedFiles.has(path)) {
            return false
          } else {
            matchedFiles.add(path)
            return true
          }
        })
        .map((path) => new File(path, mg.statCache[path].mtime, nocache, type, isBinary))

      if (nocache) {
        log.debug(`Not preprocessing "${pattern}" due to nocache`)
      } else {
        // NOTE: preprocess
        await Promise.all(files.map((file) => this._preprocess(file)))
      }

      // NOTE: buckets用于存储build完的文件
      this.buckets.set(pattern, files)

      if (_.isEmpty(mg.found)) {
        log.warn(`Pattern "${pattern}" does not match any file.`)
      } else if (_.isEmpty(files)) {
        log.warn(`All files matched by "${pattern}" were excluded or matched by prior matchers.`)
      }
    })
    // ... 之后代码省略
  }
}

FileList会根据在配置文件中的顺序对存储桶中的文件进行排序,由单个glob匹配的结果按字母顺序。 当文件发生任何变化时,例如添加新文件,删除文件或更改文件时,FileList会将这些变更传达给系统的其余部分。

示例:changeFile

async changeFile (path, force) {
    const pattern = this._findIncluded(path)
    const file = this._findFile(path, pattern)

    if (!file) {
      log.debug(`Changed file "${path}" ignored. Does not match any file in the list.`)
      return this.files
    }

    const [stat] = await Promise.all([statAsync(path), this._refreshing])
    if (force || stat.mtime > file.mtime) {
      file.mtime = stat.mtime
      await this._preprocess(file)
      // 预处理(webpack处理) lib/preprocessor.js createPriorityPreprocessor返回的preprocess方法
      log.info(`Changed file "${path}".`)
      this._emitModified(force)
      // 发出file_list_modified事件
    }
    return this.files
    // file-list的 get files () 取出buckets中的文件
  }

它发出file_list_modified事件并与事件一起传递单个参数-一个文件列表。有两个Watcher在监听事件:

file_list_modified事件

const emit = () => {
  this._emitter.emit('file_list_modified', this.files)
}
  1. web server 接收文件列表并返回一个Promise参数为文件列表,用于中间件生成context.html。

lib/web-server.js

function createFilesPromise(emitter, fileList) {
  // Set an empty list of files to avoid race issues with
  // file_list_modified not having been emitted yet
  let files = fileList.files
  emitter.on('file_list_modified', (filesParam) => {
    files = filesParam
  })

  return {
    then(...args) {
      return Promise.resolve(files).then(...args)
    },
  }
}
  1. Manager 触发测试运行。

lib/server.js

if (config.autoWatch) {
  this.on('file_list_modified', () => {
    this.log.debug('List of files has changed, trying to execute')
    if (config.restartOnFileChange) {
      socketServer.sockets.emit('stop')
      // 停止正在运行的单元测试
    }
    executor.schedule()
    // 通过socket,重新运行新一轮单元测试
  })
}

我们遇到的问题: 之前匹配入口文件的规则是./**/*.spec.ts,这样每个被匹配到的文件都会作为一个入口。 而每个入口文件都会打一成个包,被存储在FileList在内部维护存储的bucket中去,所以内存被撑爆了。

运行时的bucket,打包出来的代码是直接存在File对象上的content上的 实际运行时的bucket

5.1.3 web-server

web-server被实现成一个handlers列表。 当接收到请求时,第一个handler被调用。每个处理程序可以处理请求并生成响应,也可以调用下一个处理程序。 第一个参数是http.IncomingMessage包含有关请求信息的对象。 第二个参数是http.ServerResponse对象,本质上是可写流。如果handler知道如何处理该请求,可以将响应写入该流中。 最后一个参数是一个函数-下一个处理程序。如果此处理程序不知道如何处理该请求,它将通过调用此函数将请求传递给下一个请求。

示例:lib/middleware/stopper.js收到请求为/stop时关闭程序

function createStopperMiddleware(urlRoot) {
  return function (request, response, next) {
    if (request.url !== urlRoot + 'stop') return next()
    response.writeHead(200)
    log.info('Stopping server')
    response.end('OK')
    process.kill(process.pid, 'SIGINT')
  }
}

5.2 客户端

客户端架构图

5.2.1 Manager

客户端Manager是通过socket.io实现的,是客户端的最顶层。 它为特测试框架适配器提供客户端API,与服务器进行通信。 客户端Manager在主HTML中运行,但API暴露给实际执行测试代码的iframe。 客户Manager的生命周期是多个测试套件运行-持续到客户端关闭或由开发人员手动重新启动为止。

5.2.2 Iframe

客户端包含一个用来运行所有测试的iframe。 触发测试运行会重新加载此iframe。 iframe 的来源是context.html,这是一个包含一堆<script>和所有包含文件的HTML文件。 所有测试和源文件,测试框架及其适配器都已加载到此iframe中。

5.2.3 Adapter 适配器

适配器Adapter 基本上是一个包装了测试框架的代码,可转换测试框架与Karma客户ManagerAPI之间的通信。 适配器必须实现方法__karma__.start.Karma 在开始执行测试时调用此方法。 测试框架运行结束后调用Manager.result方法。

karma-mocha示例:karma-mocha/lib/adapter.js

;(function (window) {
  var reportTestResult = function (karma, test) {
    // 调用karma的result方法
    karma.result(result)
  }

  var createMochaReporterConstructor = function (tc, pathname) {
    return function (runner) {
      // mocha运行结束
      runner.on('test end', function (test) {
        reportTestResult(tc, test)
      })
    }
  }

  var createMochaStartFn = function (mocha) {
    // mocha启动
    return function (config) {
      mocha.run()
    }
  }

  // karma 开始测试
  window.__karma__.start = createMochaStartFn(window.mocha)
})(window)

5.3 客户端和服务端通讯

客户端和服务器之间的通信是基于socket.io库。

5.3.1 客户端向服务端通讯

客户端事件列表

事件名描述
register发送客户端的信息(例如:id,name,version)
result测试结束
complete客户端执行了所有测试
error客户端发生了错误
info数据 (如:日志,数量)
start开始测试 (如:日志,数量)

5.3.2 服务端向客户端通讯

服务端事件列表

事件名描述
execute让客户端开始执行测试
stop服务器停止

5.4 优化

5.4.1 缓存

主要优化了两点:网络操作和文件访问。

网络操作:会给根据文件内容用sha1为文件添加sha的属性来标识哪些文件发生了变更。从而减少网络请求。 Web服务器,会根据url设置高速缓存的header,以使浏览器不再要请求该文件。 文件访问:服务端会把文件内容加载到内存中去,除了变更的文件,不会重复读取文件。

示例:根据文件内容增加hashlib/preprocessor.js

async function runProcessors(preprocessors, file, content) {
  try {
    for (const process of preprocessors) {
      content = await executeProcessor(process, file, content)
    }
  } catch (error) {
    file.contentPath = null
    file.content = null
    throw error
  }

  file.contentPath = null
  file.content = content
  file.sha = CryptoUtils.sha1(content)
}

web-server设置缓存lib/middleware/common.js

function setNoCacheHeaders(response) {
  response.setHeader('Cache-Control', 'no-cache')
  response.setHeader('Pragma', 'no-cache')
  response.setHeader('Expires', new Date(0).toUTCString())
}

function setHeavyCacheHeaders(response) {
  // 狠人给设置了1年的缓存。
  response.setHeader('Cache-Control', 'public, max-age=31536000')
}
 运行时的缓存。只有format-date单元测试的资源被重新加载 运行时的缓存

6. 其他知识点

6.1 最小延迟时间,以及safai的特异行为

6.1.2 我们遇到的问题

safai浏览器中未被激活的tab页运行时间会超时。

如何复现: 当我们的单元测试中有多个测试用例有定时器时。 打开safai浏览器 打开2个tab页 http://localhost:9876 回到vscode中单元测试文件,按下保存,触发单元测试重新运行

单个测试用例包含一个定时器通过且无延迟 成功且无延迟
单个包含多个定时器通过但有延迟 单个包含多个wait成功有延迟
第一个用例包含多个wait测试通过,但第二个超时 第一个用例包含多个wait测试通过,但第二个超时
测试通过,但超时 测试通过,但超时

未被激活的tabs的定时最小延迟>=1000ms 为了优化后台tab的加载损耗(以及降低耗电量),在未被激活的tab中定时器的最小延时限制为1S(1000ms)。

stackoverflow 上类似情况的提问,回答中让用一个立即执行的 setInterval 代替,亲测无效。

我发现setInterval和setTimeout之间没有区别,我只是看到Safari持续增加执行延迟,而不是保持> = 1000ms值或任何一致的值,而是将该值从2s增加到10,有时会超过一分钟。

apple 官方社区的提问(无回复): safari timeout when tab/browser goes inactive

6.2 websocket是如何建立连接的

协议升级机制: HTTP协议 提供了一种特殊的机制,这一机制允许将一个已建立的连接升级成新的、不相容的协议。 通常来说这一机制总是由客户端发起的 (不过也有例外,比如说可以由服务端发起升级到传输层安全协议(TLS)), 服务端可以选择是否要升级到新协议。借助这一技术,连接可以以常用的协议启动(如HTTP/1.1),随后再升级到HTTP2甚至是WebSockets. 注意:HTTP/2 明确禁止使用此机制,这个机制只属于HTTP/1.1

客户端通过HTTP请求与WebSocket服务端协商升级协议。协议升级完成后,后续的数据交换则遵照WebSocket的协议。

6.2.1 具体步骤

1、客户端:申请协议升级 首先,客户端发起协议升级请求。可以看到,采用的是标准的HTTP报文格式,且只支持GET方法。

2、服务端:响应协议升级 服务端返回内容如下,状态代码101表示协议切换。到此完成协议升级,后续的数据交互都按照新的协议来。

协议升级

看代码:

client/karma.js中通知服务端浏览器注册的代码 此处socket.emit('register', info)其实是http请求

socket.on('connect', function () {
  socket.io.engine.on('upgrade', function () {
    resultsBufferLimit = 1
    if (resultsBuffer.length > 0) {
      socket.emit('result', resultsBuffer)
      resultsBuffer = []
    }
  })
  var info = {
    name: navigator.userAgent,
    id: browserId,
    isSocketReconnect: socketReconnect,
  }
  if (displayName) {
    info.displayName = displayName
  }
  socket.emit('register', info)
})
协议升级

只有在协议升级完成后socket.emit发送的信息才走的websocket

6.3 如何在vscode里debug Karma服务

  1. .vscode/launch.json配置
{
  // 使用 IntelliSense 了解相关属性。
  // 悬停以查看现有属性的描述。
  // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Debug karma Tests",
      "type": "node",
      "request": "launch",
      "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/karma"],
      "args": [
        "start",
        "tests/unit/karma.conf.js",
        "--browsers",
        "Chrome",
        "--glob=${fileBasename}",
        "--auto-watch",
        "--reporters",
        "spec",
        "--log-level",
        "debug"
      ],
      "console": "integratedTerminal",
      "internalConsoleOptions": "neverOpen",
      "port": 9229
    }
  ]
}
  1. vscode打开一个单元测试文件按下F5。