From 4a90430d707c6a63adc44f1b7a6166c11f0df910 Mon Sep 17 00:00:00 2001 From: overlookmotel Date: Thu, 29 Dec 2022 18:56:22 +0000 Subject: [PATCH] Dev: Fix coverage Closes #471. --- README.md | 1 + jest.config.js | 2 +- package-lock.json | 2 + package.json | 2 + test/support/coverage.js | 109 +++++++++++++++++++++++++++++++++++++++ test/support/runner.mjs | 49 ++++++++++++++++++ 6 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 test/support/coverage.js create mode 100644 test/support/runner.mjs diff --git a/README.md b/README.md index 14eb0fbc..8cc36cf0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ [![NPM version](https://img.shields.io/npm/v/livepack.svg)](https://www.npmjs.com/package/livepack) [![Build Status](https://img.shields.io/github/actions/workflow/status/overlookmotel/livepack/test.yml?branch=master)](https://github.com/overlookmotel/livepack/actions) +[![Coverage Status](https://img.shields.io/coveralls/overlookmotel/livepack/master.svg)](https://coveralls.io/r/overlookmotel/livepack) # Serialize live running code to Javascript diff --git a/jest.config.js b/jest.config.js index abc218bf..90acef36 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,7 @@ module.exports = { testEnvironment: 'node', - runner: 'jest-light-runner', + runner: '/test/support/runner.mjs', coverageDirectory: 'coverage', coverageProvider: 'v8', collectCoverageFrom: ['*.js', '!.eslintrc.js', '!jest.config.js', 'lib/**/*.js'], diff --git a/package-lock.json b/package-lock.json index 923eb70f..97e9b5c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,12 +45,14 @@ "@overlookmotel/eslint-config-jest": "^6.0.1", "@overlookmotel/eslint-config-node": "^4.1.0", "@overlookmotel/jest-extended": "^3.2.0", + "collect-v8-coverage": "^1.0.1", "eslint": "^8.30.0", "expect": "^29.3.1", "jest": "^29.3.1", "jest-expect-arguments": "^1.0.0", "jest-light-runner": "^0.4.1", "jest-matcher-utils": "^29.3.1", + "jest-util": "^29.3.1", "npm-run-all": "^4.1.5" }, "engines": { diff --git a/package.json b/package.json index 14ea99bf..46955812 100644 --- a/package.json +++ b/package.json @@ -68,12 +68,14 @@ "@overlookmotel/eslint-config-jest": "^6.0.1", "@overlookmotel/eslint-config-node": "^4.1.0", "@overlookmotel/jest-extended": "^3.2.0", + "collect-v8-coverage": "^1.0.1", "eslint": "^8.30.0", "expect": "^29.3.1", "jest": "^29.3.1", "jest-expect-arguments": "^1.0.0", "jest-light-runner": "^0.4.1", "jest-matcher-utils": "^29.3.1", + "jest-util": "^29.3.1", "npm-run-all": "^4.1.5" }, "keywords": [ diff --git a/test/support/coverage.js b/test/support/coverage.js new file mode 100644 index 00000000..1c6848b4 --- /dev/null +++ b/test/support/coverage.js @@ -0,0 +1,109 @@ +/* -------------------- + * livepack + * Capture V8 coverage + * ------------------*/ + +/* eslint-disable import/order, import/newline-after-import */ + +'use strict'; + +// Use internal module cache because some of same modules (e.g. `pirates`) +// are also used by `register.js`. This avoids loading them twice. +const { + useInternalModuleCache, useGlobalModuleCache, usingInternalModuleCache +} = require('../../lib/shared/moduleCache.js'); +useInternalModuleCache(); + +// Modules +const {join: pathJoin, relative: relativePath} = require('path'), + {fileURLToPath} = require('url'), + {CoverageInstrumenter} = require('collect-v8-coverage'), + {globsToMatcher, replacePathSepForGlob} = require('jest-util'), + {addHook} = require('pirates'), + EXTS = require('@babel/core').DEFAULT_EXTENSIONS; + +// Imports +const {collectCoverageFrom} = require('../../jest.config.js'); + +useGlobalModuleCache(); + +// Constants +const TESTS_DIR = pathJoin(__dirname, '../'), + ROOT_DIR = pathJoin(TESTS_DIR, '../'); + +// Exports + +module.exports = startCoverage; +startCoverage.applyAfterAllHook = applyAfterAllHook; + +let instrumenter; + +/** + * Run by `jest-light-runner` before `./register.js`. + * Then run again in `afterAll` hook at end of each test file. + * Start capturing V8 coverage. + * @async + * @returns {undefined} + */ +async function startCoverage() { + instrumenter = new CoverageInstrumenter(); + await instrumenter.startInstrumenting(); +} + +/** + * Register `afterAll` test hook to record V8 coverage data to `global.__coverage__`. + * `jest-light-runner` collects this and records it in the test result object as Babel coverage data. + * Custom runner `./runner.mjs` then moves it to the `.v8Coverage` property of result object. + * + * Code to call this function is added to bottom of every test file by pirates hook below. + * + * V8 coverage data is filtered to only files being assessed for coverage. + * This is done here rather than in the custom runner to minimise data transfer between + * worker thread running the test and the runner main thread. + * + * @returns {undefined} + */ +function applyAfterAllHook() { + afterAll(async () => { + // Record coverage data + global.__coverage__ = filterCoverageData(await instrumenter.stopInstrumenting()); + + // Start coverage inspector again for next test file + startCoverage(true); + }); +} + +/** + * Filter coverage data to files being monitored for coverage only. + * Based on: + * https://github.com/facebook/jest/blob/fb2de8a10f8e808b080af67aa771f67b5ea537ce/packages/jest-runtime/src/index.ts#L1217 + * + * @param {Array} coverage - Coverage data captured by V8 + * @returns {Array|undefined} - Conformed coverage data + */ +function filterCoverageData(coverage) { + if (collectCoverageFrom && collectCoverageFrom.length === 0) return undefined; + + const filenameMatcher = globsToMatcher(collectCoverageFrom); + + coverage = coverage + .filter(res => res.url.startsWith('file://')) + .map(res => ({...res, url: fileURLToPath(res.url)})) + .filter( + res => res.url.startsWith(ROOT_DIR) + && filenameMatcher(replacePathSepForGlob(relativePath(ROOT_DIR, res.url))) + ) + .map(result => ({result})); + + return coverage.length > 0 ? coverage : undefined; +} + +// Install extra require hook to add code to end of test files to install `afterAll` hook. +addHook( + (code, path) => ( + (!usingInternalModuleCache() && path.startsWith(TESTS_DIR) && path.endsWith('.test.js')) + ? `${code}\nrequire(${JSON.stringify(__filename)}).applyAfterAllHook()\n` + : code + ), + {ignoreNodeModules: false, exts: EXTS} +); diff --git a/test/support/runner.mjs b/test/support/runner.mjs new file mode 100644 index 00000000..2fc8301f --- /dev/null +++ b/test/support/runner.mjs @@ -0,0 +1,49 @@ +/* -------------------- + * livepack + * Custom test runner to capture V8 coverage + * ------------------*/ + +// Modules +import {join as pathJoin} from 'path'; +import {fileURLToPath} from 'url'; +import LightRunner from 'jest-light-runner'; // eslint-disable-line import/no-unresolved +import assert from 'simple-invariant'; + +// Constants +const COVERAGE_PATH = pathJoin(fileURLToPath(import.meta.url), '../coverage.js'); + +// Exports + +/** + * Modification of `jest-light-runner`. + * If coverage enabled: + * - Adds `./coverage.js` to setup files before `./register.js` + * - Captures V8 coverage data which `./coverage.js` smuggled out via `global.__coverage__` + * and passes it back to Jest. + */ +export default class CustomRunner extends LightRunner { + runTests(tests, watcher, onStart, onResult, onFailure) { + if (this._config.collectCoverage) { + const onResultOriginal = onResult; + onResult = (test, result) => { + result.v8Coverage = result.coverage; + result.coverage = undefined; + return onResultOriginal(test, result); + }; + + if (tests.length > 0) { + const {context} = tests[0]; + const setupFilesAfterEnv = [...context.config.setupFilesAfterEnv]; + setupFilesAfterEnv.splice(setupFilesAfterEnv.length - 1, 0, COVERAGE_PATH); + context.config = {...context.config, setupFilesAfterEnv}; + + assert( + tests.length === 1 || tests[1].context === tests[0].context, + 'Differing context objects between tests' + ); + } + } + + return super.runTests(tests, watcher, onStart, onResult, onFailure); + } +}