├── .editorconfig ├── .eslintrc.json ├── .github ├── funding.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc.json ├── .vscode └── settings.json ├── CliError.mjs ├── CliError.test.mjs ├── analyseCoverage.mjs ├── analyseCoverage.test.mjs ├── changelog.md ├── childProcessPromise.mjs ├── childProcessPromise.test.mjs ├── coverage-node.mjs ├── coverage-node.test.mjs ├── errorConsole.mjs ├── jsconfig.json ├── license.md ├── package.json ├── readme.md ├── reportCliError.mjs ├── reportCliError.test.mjs ├── reportCoverage.mjs ├── sourceRange.mjs ├── sourceRange.test.mjs ├── test.mjs └── test └── snapshots ├── coverage-node ├── 1-covered-file-stdout.ans ├── 1-ignored-file-stdout.ans ├── 1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stderr.ans ├── 1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stdout.ans ├── 1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stderr.ans ├── 1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stdout.ans ├── 2-covered-ignored-uncovered-files-stderr.ans ├── 2-covered-ignored-uncovered-files-stdout.ans ├── node-option-invalid-stderr.ans ├── script-console-log-stdout.ans ├── script-error-stderr.ans └── without-arguments-stderr.ans └── reportCliError ├── CliError-instance-stderr.ans ├── Error-instance-with-stack-stderr.ans ├── Error-instance-without-stack-stderr.ans └── primitive-value-stderr.ans /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 2 8 | max_line_length = 80 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended"], 3 | "env": { 4 | "es2022": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "ecmaVersion": "latest" 9 | }, 10 | "plugins": ["simple-import-sort"], 11 | "rules": { 12 | "simple-import-sort/imports": "error", 13 | "simple-import-sort/exports": "error" 14 | }, 15 | "overrides": [ 16 | { 17 | "files": ["*.mjs"], 18 | "parserOptions": { 19 | "sourceType": "module" 20 | }, 21 | "globals": { 22 | "__dirname": "off", 23 | "__filename": "off", 24 | "exports": "off", 25 | "module": "off", 26 | "require": "off" 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.github/funding.yml: -------------------------------------------------------------------------------- 1 | github: jaydenseric 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Test with Node.js v${{ matrix.node }} and ${{ matrix.os }} 6 | runs-on: ${{ matrix.os }} 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, macos-latest] 10 | node: ["14", "16", "18"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Setup Node.js v${{ matrix.node }} 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: ${{ matrix.node }} 17 | - name: npm install and test 18 | run: npm install-test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | /test/snapshots 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "proseWrap": "never" 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.disableAutomaticTypeAcquisition": true, 3 | "typescript.enablePromptUseWorkspaceTsdk": true, 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /CliError.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * A CLI error. Useful for anticipated CLI errors (such as invalid CLI 5 | * arguments) that don’t need to be displayed with a stack trace, vs unexpected 6 | * internal errors. 7 | */ 8 | export default class CliError extends Error { 9 | /** @param {string} message Error message. */ 10 | constructor(message) { 11 | if (typeof message !== "string") 12 | throw new TypeError("Argument 1 `message` must be a string."); 13 | 14 | super(message); 15 | 16 | this.name = this.constructor.name; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CliError.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { strictEqual, throws } from "node:assert"; 4 | 5 | import CliError from "./CliError.mjs"; 6 | 7 | /** 8 | * Adds `CliError` tests. 9 | * @param {import("test-director").default} tests Test director. 10 | */ 11 | export default (tests) => { 12 | tests.add("`CliError` with argument 1 `message` not a string.", () => { 13 | throws(() => { 14 | new CliError( 15 | // @ts-expect-error Testing invalid. 16 | true 17 | ); 18 | }, new TypeError("Argument 1 `message` must be a string.")); 19 | }); 20 | 21 | tests.add("`CliError` with arguments valid.", () => { 22 | const message = "Message."; 23 | const error = new CliError(message); 24 | 25 | strictEqual(error instanceof Error, true); 26 | strictEqual(error.name, "CliError"); 27 | strictEqual(error.message, message); 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /analyseCoverage.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import v8Coverage from "@bcoe/v8-coverage"; 4 | import { readdir, readFile } from "node:fs/promises"; 5 | import { join } from "node:path"; 6 | import { fileURLToPath } from "node:url"; 7 | 8 | import sourceRange from "./sourceRange.mjs"; 9 | 10 | /** 11 | * Analyzes 12 | * [Node.js generated V8 JavaScript code coverage data](https://nodejs.org/api/cli.html#cli_node_v8_coverage_dir) 13 | * in a directory; useful for reporting. 14 | * @param {string} coverageDirPath Code coverage data directory path. 15 | * @returns {Promise} Resolves the coverage analysis. 16 | */ 17 | export default async function analyseCoverage(coverageDirPath) { 18 | if (typeof coverageDirPath !== "string") 19 | throw new TypeError("Argument 1 `coverageDirPath` must be a string."); 20 | 21 | const coverageDirFileNames = await readdir(coverageDirPath); 22 | const filteredProcessCoverages = []; 23 | 24 | for (const fileName of coverageDirFileNames) 25 | if (fileName.startsWith("coverage-")) 26 | filteredProcessCoverages.push( 27 | readFile(join(coverageDirPath, fileName), "utf8").then( 28 | (coverageFileJson) => { 29 | /** @type {import("@bcoe/v8-coverage").ProcessCov} */ 30 | const { result } = JSON.parse(coverageFileJson); 31 | return { 32 | // For performance, filtering happens as early as possible. 33 | result: result.filter( 34 | ({ url }) => 35 | // Exclude Node.js internals, keeping only files. 36 | url.startsWith("file://") && 37 | // Exclude `node_modules` directory files. 38 | !url.includes("/node_modules/") && 39 | // Exclude `test` directory files. 40 | !url.includes("/test/") && 41 | // Exclude files with `.test` prefixed before the extension. 42 | !/\.test\.\w+$/u.test(url) && 43 | // Exclude files named `test` (regardless of extension). 44 | !/\/test\.\w+$/u.test(url) 45 | ), 46 | }; 47 | } 48 | ) 49 | ); 50 | 51 | const mergedCoverage = v8Coverage.mergeProcessCovs( 52 | await Promise.all(filteredProcessCoverages) 53 | ); 54 | 55 | /** @type {CoverageAnalysis} */ 56 | const analysis = { 57 | filesCount: 0, 58 | covered: [], 59 | ignored: [], 60 | uncovered: [], 61 | }; 62 | 63 | for (const { url, functions } of mergedCoverage.result) { 64 | analysis.filesCount++; 65 | 66 | const path = fileURLToPath(url); 67 | const uncoveredRanges = []; 68 | 69 | for (const { ranges } of functions) 70 | for (const range of ranges) if (!range.count) uncoveredRanges.push(range); 71 | 72 | if (uncoveredRanges.length) { 73 | const source = await readFile(path, "utf8"); 74 | const ignored = []; 75 | const uncovered = []; 76 | 77 | for (const range of uncoveredRanges) { 78 | const sourceCodeRange = sourceRange( 79 | source, 80 | range.startOffset, 81 | // The coverage data end offset is the first character after the 82 | // range. For reporting to a user, it’s better to show the range as 83 | // only the included characters. 84 | range.endOffset - 1 85 | ); 86 | 87 | if (sourceCodeRange.ignore) ignored.push(sourceCodeRange); 88 | else uncovered.push(sourceCodeRange); 89 | } 90 | 91 | if (ignored.length) analysis.ignored.push({ path, ranges: ignored }); 92 | if (uncovered.length) 93 | analysis.uncovered.push({ path, ranges: uncovered }); 94 | } else analysis.covered.push(path); 95 | } 96 | 97 | return analysis; 98 | } 99 | 100 | /** 101 | * [Node.js generated V8 JavaScript code coverage data](https://nodejs.org/api/cli.html#cli_node_v8_coverage_dir) 102 | * analysis; useful for reporting. 103 | * @typedef {object} CoverageAnalysis 104 | * @prop {number} filesCount Number of files analyzed. 105 | * @prop {Array} covered Covered file absolute paths. 106 | * @prop {Array} ignored Ignored source code ranges. 107 | * @prop {Array} uncovered Uncovered source code ranges. 108 | */ 109 | 110 | /** 111 | * A source code file with ranges of interest. 112 | * @typedef {object} SourceCodeRanges 113 | * @prop {string} path File absolute path. 114 | * @prop {Array} ranges Ranges of 115 | * interest. 116 | */ 117 | -------------------------------------------------------------------------------- /analyseCoverage.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import disposableDirectory from "disposable-directory"; 4 | import { deepStrictEqual, rejects } from "node:assert"; 5 | import { spawn } from "node:child_process"; 6 | import { mkdir, writeFile } from "node:fs/promises"; 7 | import { join } from "node:path"; 8 | 9 | import analyseCoverage from "./analyseCoverage.mjs"; 10 | import childProcessPromise from "./childProcessPromise.mjs"; 11 | 12 | /** 13 | * Adds `reportCliError` tests. 14 | * @param {import("test-director").default} tests Test director. 15 | */ 16 | export default (tests) => { 17 | tests.add( 18 | "`reportCliError` with argument 1 `coverageDirPath` not a string.", 19 | async () => { 20 | await rejects( 21 | analyseCoverage( 22 | // @ts-expect-error Testing invalid. 23 | true 24 | ), 25 | new TypeError("Argument 1 `coverageDirPath` must be a string.") 26 | ); 27 | } 28 | ); 29 | 30 | tests.add( 31 | "`analyseCoverage` ignores `node_modules` directory files.", 32 | async () => { 33 | await disposableDirectory(async (tempDirPath) => { 34 | const coverageDirPath = join(tempDirPath, "coverage"); 35 | const nodeModulesDirPath = join(tempDirPath, "node_modules"); 36 | const nodeModulesModuleName = "a"; 37 | const nodeModulesModuleMainFileName = "index.mjs"; 38 | const nodeModulesModuleMainFilePath = join( 39 | nodeModulesDirPath, 40 | nodeModulesModuleName, 41 | nodeModulesModuleMainFileName 42 | ); 43 | 44 | await mkdir(nodeModulesDirPath); 45 | await mkdir(join(nodeModulesDirPath, nodeModulesModuleName)); 46 | await writeFile(nodeModulesModuleMainFilePath, "1;"); 47 | 48 | await childProcessPromise( 49 | spawn("node", [nodeModulesModuleMainFilePath], { 50 | stdio: "inherit", 51 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 52 | }) 53 | ); 54 | 55 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 56 | filesCount: 0, 57 | covered: [], 58 | ignored: [], 59 | uncovered: [], 60 | }); 61 | }); 62 | } 63 | ); 64 | 65 | tests.add("`analyseCoverage` ignores `test` directory files.", async () => { 66 | await disposableDirectory(async (tempDirPath) => { 67 | const coverageDirPath = join(tempDirPath, "coverage"); 68 | const dirPath = join(tempDirPath, "test"); 69 | const filePath = join(dirPath, "index.mjs"); 70 | 71 | await mkdir(dirPath); 72 | await writeFile(filePath, "1;"); 73 | 74 | await childProcessPromise( 75 | spawn("node", [filePath], { 76 | stdio: "inherit", 77 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 78 | }) 79 | ); 80 | 81 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 82 | filesCount: 0, 83 | covered: [], 84 | ignored: [], 85 | uncovered: [], 86 | }); 87 | }); 88 | }); 89 | 90 | tests.add( 91 | "`analyseCoverage` ignores files with `.test` prefixed before the extension.", 92 | async () => { 93 | await disposableDirectory(async (tempDirPath) => { 94 | const coverageDirPath = join(tempDirPath, "coverage"); 95 | const filePath = join(tempDirPath, "index.test.mjs"); 96 | 97 | await writeFile(filePath, "1;"); 98 | 99 | await childProcessPromise( 100 | spawn("node", [filePath], { 101 | stdio: "inherit", 102 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 103 | }) 104 | ); 105 | 106 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 107 | filesCount: 0, 108 | covered: [], 109 | ignored: [], 110 | uncovered: [], 111 | }); 112 | }); 113 | } 114 | ); 115 | 116 | tests.add("`analyseCoverage` ignores files named `test`.", async () => { 117 | await disposableDirectory(async (tempDirPath) => { 118 | const coverageDirPath = join(tempDirPath, "coverage"); 119 | const filePath = join(tempDirPath, "test.mjs"); 120 | 121 | await writeFile(filePath, "1;"); 122 | 123 | await childProcessPromise( 124 | spawn("node", [filePath], { 125 | stdio: "inherit", 126 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 127 | }) 128 | ); 129 | 130 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 131 | filesCount: 0, 132 | covered: [], 133 | ignored: [], 134 | uncovered: [], 135 | }); 136 | }); 137 | }); 138 | 139 | tests.add("`analyseCoverage` with 1 covered file.", async () => { 140 | await disposableDirectory(async (tempDirPath) => { 141 | const coverageDirPath = join(tempDirPath, "coverage"); 142 | const filePath = join(tempDirPath, "index.mjs"); 143 | 144 | await writeFile(filePath, "1;"); 145 | 146 | await childProcessPromise( 147 | spawn("node", [filePath], { 148 | stdio: "inherit", 149 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 150 | }) 151 | ); 152 | 153 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 154 | filesCount: 1, 155 | covered: [filePath], 156 | ignored: [], 157 | uncovered: [], 158 | }); 159 | }); 160 | }); 161 | 162 | tests.add("`analyseCoverage` with 1 uncovered file.", async () => { 163 | await disposableDirectory(async (tempDirPath) => { 164 | const coverageDirPath = join(tempDirPath, "coverage"); 165 | const filePath = join(tempDirPath, "index.mjs"); 166 | 167 | await writeFile(filePath, "function a() {}; function b() {};"); 168 | 169 | await childProcessPromise( 170 | spawn("node", [filePath], { 171 | stdio: "inherit", 172 | env: { ...process.env, NODE_V8_COVERAGE: coverageDirPath }, 173 | }) 174 | ); 175 | 176 | deepStrictEqual(await analyseCoverage(coverageDirPath), { 177 | filesCount: 1, 178 | covered: [], 179 | ignored: [], 180 | uncovered: [ 181 | { 182 | path: filePath, 183 | ranges: [ 184 | { 185 | ignore: false, 186 | start: { 187 | offset: 0, 188 | line: 1, 189 | column: 1, 190 | }, 191 | end: { 192 | offset: 14, 193 | line: 1, 194 | column: 15, 195 | }, 196 | }, 197 | { 198 | ignore: false, 199 | start: { 200 | offset: 17, 201 | line: 1, 202 | column: 18, 203 | }, 204 | end: { 205 | offset: 31, 206 | line: 1, 207 | column: 32, 208 | }, 209 | }, 210 | ], 211 | }, 212 | ], 213 | }); 214 | }); 215 | }); 216 | }; 217 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # coverage-node changelog 2 | 3 | ## Next 4 | 5 | ### Patch 6 | 7 | - Updated dev dependencies. 8 | 9 | ## 8.0.0 10 | 11 | ### Major 12 | 13 | - Use the `node:` URL scheme for Node.js builtin module imports. 14 | - Migrated from the Node.js builtin module `fs` to `node:fs/promises`. 15 | 16 | ### Patch 17 | 18 | - Updated dependencies. 19 | 20 | ## 7.0.0 21 | 22 | ### Major 23 | 24 | - Updated Node.js support to `^14.17.0 || ^16.0.0 || >= 18.0.0`. 25 | - Updated dev dependencies, some of which require newer Node.js versions than previously supported. 26 | - The command `coverage-node` no longer skips code coverage and logs a warning for Node.js versions < v13.3 that produce unreliable coverage data as they are no longer supported. 27 | - Removed these modules that were previously exported: 28 | - `coverageSupported.mjs` 29 | - `coverageSupportedMinNodeVersion.mjs` 30 | 31 | ### Patch 32 | 33 | - Updated dependencies. 34 | - Updated `jsconfig.json`: 35 | - Set `compilerOptions.maxNodeModuleJsDepth` to `10`. 36 | - Set `compilerOptions.module` to `nodenext`. 37 | - Updated ESLint config. 38 | - Updated GitHub Actions CI config: 39 | - Run tests with Node.js v14, v16, v18. 40 | - Updated `actions/checkout` to v3. 41 | - Updated `actions/setup-node` to v3. 42 | - Fixed a broken JSDoc link. 43 | - Fixed a comment typo. 44 | - Revamped the readme: 45 | - Removed the badges. 46 | - Updated install size comparisons. 47 | - Added information about TypeScript config and [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). 48 | 49 | ## 6.1.0 50 | 51 | ### Minor 52 | 53 | - A truthy `ALLOW_MISSING_COVERAGE` environment variable can now be used with the `coverage-node` CLI to prevent missing coverage from causing the process to exit with code `1`, via [#2](https://github.com/jaydenseric/coverage-node/pull/2). 54 | 55 | ### Patch 56 | 57 | - Updated dependencies. 58 | - Amended the v6.0.0 changelog entry. 59 | 60 | ## 6.0.1 61 | 62 | ### Patch 63 | 64 | - Simplified dev dependencies and config for ESLint. 65 | - The private `reportCliError` function now explicitly calls `.toString()` for errors that don’t have a `stack` property. 66 | - Stopped using the [`kleur`](https://npm.im/kleur) chaining API. 67 | - Use a new [`replace-stack-traces`](https://npm.im/replace-stack-traces) dev dependency in tests. 68 | 69 | ## 6.0.0 70 | 71 | ### Major 72 | 73 | - Updated Node.js support to `^12.22.0 || ^14.17.0 || >= 16.0.0`. 74 | - Updated dependencies, some of which require newer Node.js versions than previously supported. 75 | - Public modules are now individually listed in the package `files` and `exports` fields. 76 | - Removed `./package` from the package `exports` field; the full `package.json` filename must be used in a `require` path. 77 | - Removed the package main index module; deep imports must be used. 78 | - Shortened public module deep import paths, removing the `/public/`. 79 | - Implemented TypeScript types via JSDoc comments. 80 | 81 | ### Patch 82 | 83 | - Simplified package scripts. 84 | - Check TypeScript types via a new package `types` script. 85 | - Fixed various type related issues. 86 | - Also run GitHub Actions CI with Node.js v17, and drop v15. 87 | - Removed the [`jsdoc-md`](https://npm.im/jsdoc-md) dev dependency and the related package scripts, replacing the readme “API” section with a manually written “Exports” section. 88 | - Fixed snapshot tests for the Node.js v17.0.0+ behavior of adding the Node.js version after trace for errors that cause the process to exit. 89 | - Reorganized the test file structure. 90 | - Renamed imports in the test index module. 91 | - Added `CliError` class tests. 92 | - Runtime type check `CLiError` constructor arguments. 93 | - Simplified runtime type error messages. 94 | - Configured Prettier option `singleQuote` to the default, `false`. 95 | - Added a `license.md` MIT License file. 96 | - Readme tweaks. 97 | 98 | ## 5.0.1 99 | 100 | ### Patch 101 | 102 | - Updated dependencies. 103 | - Added a package `test:jsdoc` script that checks the readme API docs are up to date with the source JSDoc. 104 | - Readme tweaks. 105 | 106 | ## 5.0.0 107 | 108 | ### Major 109 | 110 | - Updated supported Node.js versions to `^12.20 || >= 14.13`. 111 | - Updated dependencies, some of which require newer Node.js versions than previously supported. 112 | - The API is now ESM in `.mjs` files instead of CJS in `.js` files, [accessible via `import` but not `require`](https://nodejs.org/dist/latest/docs/api/esm.html#esm_require). 113 | - Replaced the the `package.json` `exports` field public [subpath folder mapping](https://nodejs.org/api/packages.html#packages_subpath_folder_mappings) (deprecated by Node.js) with a [subpath pattern](https://nodejs.org/api/packages.html#packages_subpath_patterns). 114 | 115 | ### Patch 116 | 117 | - Simplified the package scripts now that [`jsdoc-md`](https://npm.im/jsdoc-md) v10 automatically generates a Prettier formatted readme. 118 | - Always use regex `u` mode. 119 | - Stop using [`hard-rejection`](https://npm.im/hard-rejection) to detect unhandled `Promise` rejections in tests, as Node.js v15+ does this natively. 120 | - Improved the test helper function `replaceStackTraces`. 121 | - Refactored unnecessary template strings. 122 | - Updated GitHub Actions CI config: 123 | - Also run tests with Node.js v16. 124 | - Updated `actions/checkout` to v2. 125 | - Updated `actions/setup-node` to v2. 126 | - Don’t specify the `CI` environment variable as it’s set by default. 127 | 128 | ## 4.0.0 129 | 130 | ### Major 131 | 132 | - The updated [`kleur`](https://npm.im/kleur) dependency causes subtle differences in which environments get colored console output. 133 | 134 | ### Minor 135 | 136 | - Added runtime argument type checks for various private and public functions. 137 | - Improved console output for `coverage-node` CLI errors. 138 | 139 | ### Patch 140 | 141 | - Updated dependencies. 142 | - Also test Node.js v15 in GitHub Actions CI. 143 | - Simplified the GitHub Actions CI config with the [`npm install-test`](https://docs.npmjs.com/cli/v7/commands/npm-install-test) command. 144 | - Removed `npm-debug.log` from the `.gitignore` file as npm [v4.2.0](https://github.com/npm/npm/releases/tag/v4.2.0)+ doesn’t create it in the current working directory. 145 | - Removed an extra space character from the `coverage-node` CLI error message when coverage is enabled. 146 | - Use the `FORCE_COLOR` environment variable in tests to ensure output is colorized. 147 | - Use a new [`snapshot-assertion`](https://npm.im/snapshot-assertion) dev dependency to snapshot test CLI output. 148 | - Replaced the `stripStackTraces` test helper with a smarter `replaceStackTraces` helper that allows tests to detect a missing stack trace. 149 | - Use `spawnSync` from the Node.js `child_process` API instead of the `execFilePromise` helper in tests. 150 | - Added more `coverage-node` CLI tests. 151 | - Improved internal JSDoc. 152 | - Updated SLOC and install size related documentation. 153 | 154 | ## 3.0.0 155 | 156 | ### Major 157 | 158 | - Updated supported Node.js versions to `^10.17.0 || ^12.0.0 || >= 13.7.0`. 159 | - Updated dependencies, some of which require newer Node.js versions than were previously supported. 160 | - Added a [package `exports` field](https://nodejs.org/api/esm.html#esm_package_entry_points) with [conditional exports](https://nodejs.org/api/esm.html#esm_conditional_exports) to support native ESM in Node.js and keep internal code private, [whilst avoiding the dual package hazard](https://nodejs.org/api/esm.html#esm_approach_1_use_an_es_module_wrapper). Published files have been reorganized, so previously undocumented deep imports will need to be rewritten according to the newly documented paths. 161 | 162 | ### Patch 163 | 164 | - Updated Prettier config and scripts. 165 | - Added ESM related keywords to the package `keywords` field. 166 | - Updated ESLint config to match the new Node.js version support. 167 | - Moved reading `process.argv` into the `coverageNode` function scope. 168 | - Improved a JSDoc return type. 169 | - Ensure GitHub Actions run on pull request. 170 | - Test with Node.js v14 instead of v13. 171 | - Updated EditorConfig. 172 | 173 | ## 2.0.3 174 | 175 | ### Patch 176 | 177 | - Updated dev dependencies. 178 | - Added a new [`hard-rejection`](https://npm.im/hard-rejection) dev dependency to ensure unhandled rejections in tests exit the process with an error. 179 | - Destructured `assert` imports. 180 | - Moved the `execFilePromise` helper from the `/lib` directory to `/test`, reducing the install size. 181 | 182 | ## 2.0.2 183 | 184 | ### Patch 185 | 186 | - Allow a line to contain both code and a coverage ignore next line comment. 187 | - Simplified `test/index.js`. 188 | 189 | ## 2.0.1 190 | 191 | ### Patch 192 | 193 | - Updated dev dependencies. 194 | - Added a new [`disposable-directory`](https://npm.im/disposable-directory) dependency to simplify the implementation and tests. 195 | - Moved JSDoc comments above module exports code. 196 | - Updated the compared [`c8` install size](https://packagephobia.com/result?p=c8@7.0.0), fixing [#1](https://github.com/jaydenseric/coverage-node/issues/1). 197 | 198 | ## 2.0.0 199 | 200 | ### Major 201 | 202 | - The `coverage-node` CLI now skips code coverage when Node.js < v13.3 and displays an explanatory message in place of a code coverage report. 203 | - Removed the `nodeWithCoverage` function. 204 | 205 | ### Minor 206 | 207 | - New `coverageSupported` and `coverageSupportedMinNodeVersion` constants are exported. 208 | 209 | ### Patch 210 | 211 | - Additionally test Node.js v10 and v12 in CI. 212 | - Updated the comparison install size in the readme for [`nyc@15.0.0`](https://packagephobia.com/result?p=nyc@15.0.0). 213 | 214 | ## 1.1.0 215 | 216 | ### Minor 217 | 218 | - Added a new `filesCount` field to the code coverage analysis resolved by the `analyseCoverage` function. 219 | 220 | ### Patch 221 | 222 | - Fixed the total number of files in the report summary sometimes being too big. 223 | 224 | ## 1.0.1 225 | 226 | ### Patch 227 | 228 | - Updated the [`eslint-config-env`](https://npm.im/eslint-config-env) dev dependency. 229 | - Only test Node.js v13 in CI as earlier versions produce flawed coverage data. 230 | - Updated the support documentation to recommend Node.js v13.3+. 231 | - Corrected the temporary directory paths created by the `createTempDir` function. 232 | - The `tempDirOperation` function now checks the temporary directory path was created before attempting to remove it in the cleanup phase. 233 | - `fsPathRemove` function improvements: 234 | - Reject with an error if the provided path is not a string. 235 | - Use `rm -rf` instead of `rm -r` so that it doesn’t error when the path doesn’t exist. 236 | - Tweaked test fixture formatting. 237 | 238 | ## 1.0.0 239 | 240 | Initial release. 241 | -------------------------------------------------------------------------------- /childProcessPromise.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { ChildProcess } from "node:child_process"; 4 | 5 | /** 6 | * Promisifies a Node.js child process. 7 | * @param {ChildProcess} childProcess Node.js child process. 8 | * @returns {Promise<{ 9 | * exitCode: number | null, 10 | * signal: NodeJS.Signals | null 11 | * }>} Resolves the exit code if the child exited on its own, or the signal by 12 | * which the child process was terminated. 13 | */ 14 | export default async function childProcessPromise(childProcess) { 15 | if (!(childProcess instanceof ChildProcess)) 16 | throw new TypeError( 17 | "Argument 1 `childProcess` must be a `ChildProcess` instance." 18 | ); 19 | 20 | return new Promise((resolve, reject) => { 21 | childProcess.once("error", reject); 22 | childProcess.once("close", (exitCode, signal) => 23 | resolve({ exitCode, signal }) 24 | ); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /childProcessPromise.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { rejects } from "node:assert"; 4 | 5 | import childProcessPromise from "./childProcessPromise.mjs"; 6 | 7 | /** 8 | * Adds `childProcessPromise` tests. 9 | * @param {import("test-director").default} tests Test director. 10 | */ 11 | export default (tests) => { 12 | tests.add( 13 | "`childProcessPromise` with argument 1 `childProcess` not a `ChildProcess` instance.", 14 | async () => { 15 | await rejects( 16 | childProcessPromise( 17 | // @ts-expect-error Testing invalid. 18 | true 19 | ), 20 | new TypeError( 21 | "Argument 1 `childProcess` must be a `ChildProcess` instance." 22 | ) 23 | ); 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /coverage-node.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // @ts-check 3 | 4 | import disposableDirectory from "disposable-directory"; 5 | import { spawn } from "node:child_process"; 6 | 7 | import analyseCoverage from "./analyseCoverage.mjs"; 8 | import childProcessPromise from "./childProcessPromise.mjs"; 9 | import CliError from "./CliError.mjs"; 10 | import reportCliError from "./reportCliError.mjs"; 11 | import reportCoverage from "./reportCoverage.mjs"; 12 | 13 | /** 14 | * Powers the `coverage-node` CLI. Runs Node.js with the given arguments and 15 | * coverage enabled. An analysis of the coverage is reported to the console, and 16 | * if coverage is incomplete and there isn’t a truthy `ALLOW_MISSING_COVERAGE` 17 | * environment variable the process exits with code `1`. 18 | * @returns {Promise} Resolves when all work is complete. 19 | */ 20 | async function coverageNode() { 21 | try { 22 | const [, , ...nodeArgs] = process.argv; 23 | 24 | if (!nodeArgs.length) 25 | throw new CliError("Node.js CLI arguments are required."); 26 | 27 | await disposableDirectory(async (tempDirPath) => { 28 | const { exitCode } = await childProcessPromise( 29 | spawn("node", nodeArgs, { 30 | stdio: "inherit", 31 | env: { ...process.env, NODE_V8_COVERAGE: tempDirPath }, 32 | }) 33 | ); 34 | 35 | // Only show a code coverage report if the Node.js script didn’t error, 36 | // to reduce distraction from the priority to solve errors. 37 | if (exitCode === 0) { 38 | const analysis = await analyseCoverage(tempDirPath); 39 | reportCoverage(analysis); 40 | if (analysis.uncovered.length && !process.env.ALLOW_MISSING_COVERAGE) 41 | process.exitCode = 1; 42 | } else if (exitCode !== null) process.exitCode = exitCode; 43 | }); 44 | } catch (error) { 45 | reportCliError("Node.js with coverage", error); 46 | 47 | process.exitCode = 1; 48 | } 49 | } 50 | 51 | coverageNode(); 52 | -------------------------------------------------------------------------------- /coverage-node.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import disposableDirectory from "disposable-directory"; 4 | import { strictEqual } from "node:assert"; 5 | import { spawnSync } from "node:child_process"; 6 | import { writeFile } from "node:fs/promises"; 7 | import { join, relative } from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | import replaceStackTraces from "replace-stack-traces"; 10 | import snapshot from "snapshot-assertion"; 11 | 12 | const COVERAGE_NODE_CLI_PATH = fileURLToPath( 13 | new URL("./coverage-node.mjs", import.meta.url) 14 | ); 15 | const SNAPSHOT_REPLACEMENT_FILE_PATH = ""; 16 | 17 | /** 18 | * Adds `coverage-node` tests. 19 | * @param {import("test-director").default} tests Test director. 20 | */ 21 | export default (tests) => { 22 | tests.add("`coverage-node` CLI with 1 covered file.", async () => { 23 | await disposableDirectory(async (tempDirPath) => { 24 | const filePath = join(tempDirPath, "index.mjs"); 25 | 26 | await writeFile(filePath, "1;"); 27 | 28 | const { stdout, stderr, status, error } = spawnSync( 29 | "node", 30 | [COVERAGE_NODE_CLI_PATH, filePath], 31 | { 32 | env: { 33 | ...process.env, 34 | FORCE_COLOR: "1", 35 | }, 36 | } 37 | ); 38 | 39 | if (error) throw error; 40 | 41 | await snapshot( 42 | stdout 43 | .toString() 44 | .replace(relative("", filePath), SNAPSHOT_REPLACEMENT_FILE_PATH), 45 | new URL( 46 | `./test/snapshots/coverage-node/1-covered-file-stdout.ans`, 47 | import.meta.url 48 | ) 49 | ); 50 | 51 | strictEqual(stderr.toString(), ""); 52 | strictEqual(status, 0); 53 | }); 54 | }); 55 | 56 | tests.add("`coverage-node` CLI with 1 ignored file.", async () => { 57 | await disposableDirectory(async (tempDirPath) => { 58 | const filePath = join(tempDirPath, "index.mjs"); 59 | 60 | await writeFile( 61 | filePath, 62 | `// coverage ignore next line 63 | () => {}; 64 | ` 65 | ); 66 | 67 | const { stdout, stderr, status, error } = spawnSync( 68 | "node", 69 | [COVERAGE_NODE_CLI_PATH, filePath], 70 | { 71 | env: { 72 | ...process.env, 73 | FORCE_COLOR: "1", 74 | }, 75 | } 76 | ); 77 | 78 | if (error) throw error; 79 | 80 | await snapshot( 81 | stdout 82 | .toString() 83 | .replace(relative("", filePath), SNAPSHOT_REPLACEMENT_FILE_PATH), 84 | new URL( 85 | `./test/snapshots/coverage-node/1-ignored-file-stdout.ans`, 86 | import.meta.url 87 | ) 88 | ); 89 | 90 | strictEqual(stderr.toString(), ""); 91 | strictEqual(status, 0); 92 | }); 93 | }); 94 | 95 | tests.add( 96 | "`coverage-node` CLI with 1 uncovered file, `ALLOW_MISSING_COVERAGE` environment variable falsy.", 97 | async () => { 98 | await disposableDirectory(async (tempDirPath) => { 99 | const filePath = join(tempDirPath, "index.mjs"); 100 | 101 | await writeFile(filePath, "() => {};"); 102 | 103 | const { stdout, stderr, status, error } = spawnSync( 104 | "node", 105 | [COVERAGE_NODE_CLI_PATH, filePath], 106 | { 107 | env: { 108 | ...process.env, 109 | FORCE_COLOR: "1", 110 | }, 111 | } 112 | ); 113 | 114 | if (error) throw error; 115 | 116 | await snapshot( 117 | stdout.toString(), 118 | new URL( 119 | `./test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stdout.ans`, 120 | import.meta.url 121 | ) 122 | ); 123 | 124 | await snapshot( 125 | stderr 126 | .toString() 127 | .replace(relative("", filePath), SNAPSHOT_REPLACEMENT_FILE_PATH), 128 | new URL( 129 | "./test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stderr.ans", 130 | import.meta.url 131 | ) 132 | ); 133 | 134 | strictEqual(status, 1); 135 | }); 136 | } 137 | ); 138 | 139 | tests.add( 140 | "`coverage-node` CLI with 1 uncovered file, `ALLOW_MISSING_COVERAGE` environment variable truthy.", 141 | async () => { 142 | await disposableDirectory(async (tempDirPath) => { 143 | const filePath = join(tempDirPath, "index.mjs"); 144 | 145 | await writeFile(filePath, "() => {};"); 146 | 147 | const { stdout, stderr, status, error } = spawnSync( 148 | "node", 149 | [COVERAGE_NODE_CLI_PATH, filePath], 150 | { 151 | env: { 152 | ...process.env, 153 | FORCE_COLOR: "1", 154 | ALLOW_MISSING_COVERAGE: "1", 155 | }, 156 | } 157 | ); 158 | 159 | if (error) throw error; 160 | 161 | await snapshot( 162 | stdout.toString(), 163 | new URL( 164 | `./test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stdout.ans`, 165 | import.meta.url 166 | ) 167 | ); 168 | 169 | await snapshot( 170 | stderr 171 | .toString() 172 | .replace(relative("", filePath), SNAPSHOT_REPLACEMENT_FILE_PATH), 173 | new URL( 174 | "./test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stderr.ans", 175 | import.meta.url 176 | ) 177 | ); 178 | 179 | strictEqual(status, 0); 180 | }); 181 | } 182 | ); 183 | 184 | tests.add( 185 | "`coverage-node` CLI with 2 covered, ignored and uncovered files.", 186 | async () => { 187 | await disposableDirectory(async (tempDirPath) => { 188 | const fileAPath = join(tempDirPath, "a.mjs"); 189 | const fileBPath = join(tempDirPath, "b.mjs"); 190 | const fileCPath = join(tempDirPath, "c.mjs"); 191 | const fileDPath = join(tempDirPath, "d.mjs"); 192 | const fileEPath = join(tempDirPath, "e.mjs"); 193 | const fileFPath = join(tempDirPath, "f.mjs"); 194 | 195 | await writeFile( 196 | fileAPath, 197 | `import "${fileBPath}"; 198 | import "${fileCPath}"; 199 | import "${fileDPath}"; 200 | import "${fileEPath}"; 201 | import "${fileFPath}"; 202 | ` 203 | ); 204 | await writeFile(fileBPath, "function a() {}; a();"); 205 | await writeFile( 206 | fileCPath, 207 | `// coverage ignore next line 208 | () => {};` 209 | ); 210 | await writeFile( 211 | fileDPath, 212 | `// coverage ignore next line 213 | () => {};` 214 | ); 215 | await writeFile(fileEPath, "() => {};"); 216 | await writeFile(fileFPath, "() => {};"); 217 | 218 | const { stdout, stderr, status, error } = spawnSync( 219 | "node", 220 | [COVERAGE_NODE_CLI_PATH, fileAPath], 221 | { 222 | env: { 223 | ...process.env, 224 | FORCE_COLOR: "1", 225 | }, 226 | } 227 | ); 228 | 229 | if (error) throw error; 230 | 231 | await snapshot( 232 | stdout 233 | .toString() 234 | .replace(relative("", fileAPath), "") 235 | .replace(relative("", fileBPath), "") 236 | .replace(relative("", fileCPath), "") 237 | .replace(relative("", fileDPath), ""), 238 | new URL( 239 | `./test/snapshots/coverage-node/2-covered-ignored-uncovered-files-stdout.ans`, 240 | import.meta.url 241 | ) 242 | ); 243 | 244 | await snapshot( 245 | stderr 246 | .toString() 247 | .replace(relative("", fileEPath), "") 248 | .replace(relative("", fileFPath), ""), 249 | new URL( 250 | "./test/snapshots/coverage-node/2-covered-ignored-uncovered-files-stderr.ans", 251 | import.meta.url 252 | ) 253 | ); 254 | 255 | strictEqual(status, 1); 256 | }); 257 | } 258 | ); 259 | 260 | tests.add("`coverage-node` CLI with a script console log.", async () => { 261 | await disposableDirectory(async (tempDirPath) => { 262 | const filePath = join(tempDirPath, "index.mjs"); 263 | 264 | await writeFile(filePath, 'console.log("Message.");'); 265 | 266 | const { stdout, stderr, status, error } = spawnSync( 267 | "node", 268 | [COVERAGE_NODE_CLI_PATH, filePath], 269 | { 270 | env: { 271 | ...process.env, 272 | FORCE_COLOR: "1", 273 | }, 274 | } 275 | ); 276 | 277 | if (error) throw error; 278 | 279 | await snapshot( 280 | stdout 281 | .toString() 282 | .replace(relative("", filePath), SNAPSHOT_REPLACEMENT_FILE_PATH), 283 | new URL( 284 | `./test/snapshots/coverage-node/script-console-log-stdout.ans`, 285 | import.meta.url 286 | ) 287 | ); 288 | 289 | strictEqual(stderr.toString(), ""); 290 | strictEqual(status, 0); 291 | }); 292 | }); 293 | 294 | tests.add("`coverage-node` CLI with a script error.", async () => { 295 | await disposableDirectory(async (tempDirPath) => { 296 | const filePath = join(tempDirPath, "index.mjs"); 297 | 298 | await writeFile(filePath, 'throw new Error("Error.");'); 299 | 300 | const { stdout, stderr, status, error } = spawnSync( 301 | "node", 302 | [COVERAGE_NODE_CLI_PATH, filePath], 303 | { 304 | env: { 305 | ...process.env, 306 | FORCE_COLOR: "1", 307 | }, 308 | } 309 | ); 310 | 311 | if (error) throw error; 312 | 313 | strictEqual(stdout.toString(), ""); 314 | 315 | await snapshot( 316 | replaceStackTraces(stderr.toString()).replace( 317 | filePath, 318 | SNAPSHOT_REPLACEMENT_FILE_PATH 319 | ), 320 | new URL( 321 | "./test/snapshots/coverage-node/script-error-stderr.ans", 322 | import.meta.url 323 | ) 324 | ); 325 | 326 | strictEqual(status, 1); 327 | }); 328 | }); 329 | 330 | tests.add("`coverage-node` CLI with a Node.js option, valid.", async () => { 331 | await disposableDirectory(async (tempDirPath) => { 332 | const filePath = join(tempDirPath, "index.mjs"); 333 | 334 | await writeFile( 335 | filePath, 336 | `import { deprecate } from "node:util"; 337 | 338 | const deprecated = deprecate(() => {}, "Deprecated!"); 339 | deprecated(); 340 | ` 341 | ); 342 | 343 | const { stdout, stderr, status, error } = spawnSync( 344 | "node", 345 | [COVERAGE_NODE_CLI_PATH, "--throw-deprecation", filePath], 346 | { 347 | env: { 348 | ...process.env, 349 | FORCE_COLOR: "1", 350 | }, 351 | } 352 | ); 353 | 354 | if (error) throw error; 355 | 356 | strictEqual(stdout.toString(), ""); 357 | strictEqual( 358 | stderr.toString().includes("DeprecationWarning: Deprecated!"), 359 | true 360 | ); 361 | strictEqual(status, 1); 362 | }); 363 | }); 364 | 365 | tests.add("`coverage-node` CLI with a Node.js option, invalid.", async () => { 366 | await disposableDirectory(async (tempDirPath) => { 367 | const filePath = join(tempDirPath, "index.mjs"); 368 | 369 | await writeFile(filePath, "1;"); 370 | 371 | const { stdout, stderr, status, error } = spawnSync( 372 | "node", 373 | [COVERAGE_NODE_CLI_PATH, "--not-a-real-option", filePath], 374 | { 375 | env: { 376 | ...process.env, 377 | FORCE_COLOR: "1", 378 | }, 379 | } 380 | ); 381 | 382 | if (error) throw error; 383 | 384 | strictEqual(stdout.toString(), ""); 385 | 386 | await snapshot( 387 | replaceStackTraces(stderr.toString()), 388 | new URL( 389 | "./test/snapshots/coverage-node/node-option-invalid-stderr.ans", 390 | import.meta.url 391 | ) 392 | ); 393 | 394 | strictEqual(status, 9); 395 | }); 396 | }); 397 | 398 | tests.add("`coverage-node` CLI with a missing file.", async () => { 399 | await disposableDirectory(async (tempDirPath) => { 400 | const filePath = join(tempDirPath, "index.mjs"); 401 | 402 | const { stdout, stderr, status, error } = spawnSync( 403 | "node", 404 | [COVERAGE_NODE_CLI_PATH, filePath], 405 | { 406 | env: { 407 | ...process.env, 408 | FORCE_COLOR: "1", 409 | }, 410 | } 411 | ); 412 | 413 | if (error) throw error; 414 | 415 | strictEqual(stdout.toString(), ""); 416 | strictEqual( 417 | stderr.toString().includes(`Error: Cannot find module '${filePath}'`), 418 | true 419 | ); 420 | strictEqual(status, 1); 421 | }); 422 | }); 423 | 424 | tests.add("`coverage-node` CLI without arguments.", async () => { 425 | const { stdout, stderr, status, error } = spawnSync( 426 | "node", 427 | ["coverage-node.mjs"], 428 | { 429 | env: { 430 | ...process.env, 431 | FORCE_COLOR: "1", 432 | }, 433 | } 434 | ); 435 | 436 | if (error) throw error; 437 | 438 | strictEqual(stdout.toString(), ""); 439 | 440 | await snapshot( 441 | replaceStackTraces(stderr.toString()), 442 | new URL( 443 | `./test/snapshots/coverage-node/without-arguments-stderr.ans`, 444 | import.meta.url 445 | ) 446 | ); 447 | 448 | strictEqual(status, 1); 449 | }); 450 | }; 451 | -------------------------------------------------------------------------------- /errorConsole.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { Console } from "node:console"; 4 | 5 | /** 6 | * The `console` API, but all output is to `stderr`. This allows `console.group` 7 | * to be used with `console.error`. 8 | */ 9 | export default new Console({ 10 | stdout: process.stderr, 11 | stderr: process.stderr, 12 | }); 13 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "maxNodeModuleJsDepth": 10, 4 | "module": "nodenext", 5 | "noEmit": true, 6 | "strict": true 7 | }, 8 | "typeAcquisition": { 9 | "enable": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright Jayden Seric 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "coverage-node", 3 | "version": "8.0.0", 4 | "description": "A simple CLI to run Node.js and report code coverage.", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Jayden Seric", 8 | "email": "me@jaydenseric.com", 9 | "url": "https://jaydenseric.com" 10 | }, 11 | "repository": "github:jaydenseric/coverage-node", 12 | "homepage": "https://github.com/jaydenseric/coverage-node#readme", 13 | "bugs": "https://github.com/jaydenseric/coverage-node/issues", 14 | "funding": "https://github.com/sponsors/jaydenseric", 15 | "keywords": [ 16 | "node", 17 | "v8", 18 | "check", 19 | "report", 20 | "code", 21 | "coverage", 22 | "esm", 23 | "mjs" 24 | ], 25 | "files": [ 26 | "analyseCoverage.mjs", 27 | "childProcessPromise.mjs", 28 | "CliError.mjs", 29 | "coverage-node.mjs", 30 | "errorConsole.mjs", 31 | "reportCliError.mjs", 32 | "reportCoverage.mjs", 33 | "sourceRange.mjs" 34 | ], 35 | "exports": { 36 | "./analyseCoverage.mjs": "./analyseCoverage.mjs", 37 | "./package.json": "./package.json", 38 | "./reportCoverage.mjs": "./reportCoverage.mjs" 39 | }, 40 | "bin": { 41 | "coverage-node": "coverage-node.mjs" 42 | }, 43 | "engines": { 44 | "node": "^14.17.0 || ^16.0.0 || >= 18.0.0" 45 | }, 46 | "dependencies": { 47 | "@bcoe/v8-coverage": "^0.2.3", 48 | "@types/node": "*", 49 | "disposable-directory": "^6.0.0", 50 | "kleur": "^4.1.5" 51 | }, 52 | "devDependencies": { 53 | "eslint": "^8.22.0", 54 | "eslint-plugin-simple-import-sort": "^7.0.0", 55 | "prettier": "^2.7.1", 56 | "replace-stack-traces": "^2.0.0", 57 | "snapshot-assertion": "^5.0.0", 58 | "test-director": "^10.0.0", 59 | "typescript": "^4.7.4" 60 | }, 61 | "scripts": { 62 | "eslint": "eslint .", 63 | "prettier": "prettier -c .", 64 | "types": "tsc -p jsconfig.json", 65 | "tests": "node coverage-node.mjs test.mjs", 66 | "test": "npm run eslint && npm run prettier && npm run types && npm run tests", 67 | "prepublishOnly": "npm test" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # coverage-node 2 | 3 | A simple CLI to run [Node.js](https://nodejs.org) and report code coverage. 4 | 5 | - ✨ Zero config. 6 | - 🏁 Tiny source, written from scratch to use [code coverage features](https://nodejs.org/api/cli.html#cli_node_v8_coverage_dir) built into Node.js. 7 | - 📦 [Lean install size](https://packagephobia.com/result?p=coverage-node), compared to [1.94 MB for `c8` v7.11.1](https://packagephobia.com/result?p=c8@7.11.1) or [8.84 MB for `nyc` v15.1.0](https://packagephobia.com/result?p=nyc@15.1.0). 8 | - 🖱 Displays ignored or uncovered source code ranges as paths, clickable in IDEs such as [VS Code](https://code.visualstudio.com). 9 | 10 | ## Installation 11 | 12 | To install [`coverage-node`](https://npm.im/coverage-node) with [npm](https://npmjs.com/get-npm), run: 13 | 14 | ```sh 15 | npm install coverage-node --save-dev 16 | ``` 17 | 18 | In a [`package.json` script](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#scripts), replace the `node` command with [`coverage-node`](#command-coverage-node): 19 | 20 | ```diff 21 | { 22 | "scripts": { 23 | - "test": "node test.mjs" 24 | + "test": "coverage-node test.mjs" 25 | } 26 | } 27 | ``` 28 | 29 | ## Requirements 30 | 31 | Supported operating systems: 32 | 33 | - Linux 34 | - macOS 35 | 36 | Supported runtime environments: 37 | 38 | - [Node.js](https://nodejs.org) versions `^14.17.0 || ^16.0.0 || >= 18.0.0`. 39 | 40 | Projects must configure [TypeScript](https://typescriptlang.org) to use types from the ECMAScript modules that have a `// @ts-check` comment: 41 | 42 | - [`compilerOptions.allowJs`](https://typescriptlang.org/tsconfig#allowJs) should be `true`. 43 | - [`compilerOptions.maxNodeModuleJsDepth`](https://typescriptlang.org/tsconfig#maxNodeModuleJsDepth) should be reasonably large, e.g. `10`. 44 | - [`compilerOptions.module`](https://typescriptlang.org/tsconfig#module) should be `"node16"` or `"nodenext"`. 45 | 46 | ## Ignored files 47 | 48 | Code coverage analysis ignores: 49 | 50 | - `node_modules` directory files, e.g. `node_modules/foo/index.mjs`. 51 | - `test` directory files, e.g. `test/index.mjs`. 52 | - Files with `.test` prefixed before the extension, e.g. `foo.test.mjs`. 53 | - Files named `test` (regardless of extension), e.g. `test.mjs`. 54 | 55 | ## Ignored lines 56 | 57 | In source code, a comment (case insensitive) can be used to ignore code coverage ranges that start on the the next line: 58 | 59 | ```js 60 | // coverage ignore next line 61 | if (false) console.log("Never runs."); 62 | ``` 63 | 64 | ## CLI 65 | 66 | ### Command `coverage-node` 67 | 68 | Substitutes the normal `node` command; any [`node` CLI options](https://nodejs.org/api/cli.html#cli_options) can be used to run a test script. If the script doesn’t error a code coverage analysis is reported to the console, and if coverage is incomplete and there isn’t a truthy `ALLOW_MISSING_COVERAGE` environment variable the process exits with code `1`. 69 | 70 | #### Examples 71 | 72 | [`npx`](https://docs.npmjs.com/cli/v8/commands/npx) can be used to quickly check code coverage for a script: 73 | 74 | ```sh 75 | npx coverage-node test.mjs 76 | ``` 77 | 78 | A [`package.json` script](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#scripts): 79 | 80 | ```json 81 | { 82 | "scripts": { 83 | "test": "coverage-node test.mjs" 84 | } 85 | } 86 | ``` 87 | 88 | A [`package.json` script](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#scripts) that allows missing coverage: 89 | 90 | ```json 91 | { 92 | "scripts": { 93 | "test": "ALLOW_MISSING_COVERAGE=1 coverage-node test.mjs" 94 | } 95 | } 96 | ``` 97 | 98 | ## Exports 99 | 100 | The [npm](https://npmjs.com) package [`coverage-node`](https://npm.im/coverage-node) features [optimal JavaScript module design](https://jaydenseric.com/blog/optimal-javascript-module-design). It doesn’t have a main index module, so use deep imports from the ECMAScript modules that are exported via the [`package.json`](./package.json) field [`exports`](https://nodejs.org/api/packages.html#exports): 101 | 102 | - [`analyseCoverage.mjs`](./analyseCoverage.mjs) 103 | - [`reportCoverage.mjs`](./reportCoverage.mjs) 104 | -------------------------------------------------------------------------------- /reportCliError.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { bold, red } from "kleur/colors"; 4 | import { inspect } from "node:util"; 5 | 6 | import CliError from "./CliError.mjs"; 7 | import errorConsole from "./errorConsole.mjs"; 8 | 9 | /** 10 | * Reports a CLI error via the process `stderr`. 11 | * @param {string} cliDescription CLI description. 12 | * @param {unknown} error Error to report. 13 | */ 14 | export default function reportCliError(cliDescription, error) { 15 | if (typeof cliDescription !== "string") 16 | throw new TypeError("Argument 1 `cliDescription` must be a string."); 17 | 18 | errorConsole.group( 19 | // Whitespace blank lines shouldn’t have redundant indentation or color. 20 | `\n${bold(red(`Error running ${cliDescription}:`))}\n` 21 | ); 22 | 23 | errorConsole.error( 24 | red( 25 | error instanceof CliError 26 | ? error.message 27 | : error instanceof Error 28 | ? // Rarely, an error doesn’t have a stack. In that case, the standard 29 | // `toString` method returns the error’s `name` + `: ` + the 30 | // `message`. This is consistent with the first part of a standard 31 | // Node.js error’s `stack`. 32 | error.stack || error.toString() 33 | : inspect(error) 34 | ) 35 | ); 36 | 37 | errorConsole.groupEnd(); 38 | 39 | // Whitespace blank line. 40 | errorConsole.error(); 41 | } 42 | -------------------------------------------------------------------------------- /reportCliError.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import disposableDirectory from "disposable-directory"; 4 | import { strictEqual, throws } from "node:assert"; 5 | import { spawnSync } from "node:child_process"; 6 | import { writeFile } from "node:fs/promises"; 7 | import { join } from "node:path"; 8 | import { fileURLToPath } from "node:url"; 9 | import replaceStackTraces from "replace-stack-traces"; 10 | import snapshot from "snapshot-assertion"; 11 | 12 | import reportCliError from "./reportCliError.mjs"; 13 | 14 | const REPORT_CLI_ERROR_PATH = fileURLToPath( 15 | new URL("./reportCliError.mjs", import.meta.url) 16 | ); 17 | 18 | /** 19 | * Adds `reportCliError` tests. 20 | * @param {import("test-director").default} tests Test director. 21 | */ 22 | export default (tests) => { 23 | tests.add( 24 | "`reportCliError` with argument 1 `cliDescription` not a string.", 25 | () => { 26 | throws(() => { 27 | reportCliError( 28 | // @ts-expect-error Testing invalid. 29 | true, 30 | new Error("Message.") 31 | ); 32 | }, new TypeError("Argument 1 `cliDescription` must be a string.")); 33 | } 34 | ); 35 | 36 | tests.add( 37 | "`reportCliError` with a `Error` instance, with stack.", 38 | async () => { 39 | await disposableDirectory(async (tempDirPath) => { 40 | const filePath = join(tempDirPath, "test.mjs"); 41 | 42 | await writeFile( 43 | filePath, 44 | `import reportCliError from "${REPORT_CLI_ERROR_PATH}"; 45 | 46 | reportCliError("CLI", new Error("Message.")); 47 | ` 48 | ); 49 | 50 | const { stdout, stderr, status, error } = spawnSync( 51 | "node", 52 | [filePath], 53 | { 54 | env: { 55 | ...process.env, 56 | FORCE_COLOR: "1", 57 | }, 58 | } 59 | ); 60 | 61 | if (error) throw error; 62 | 63 | strictEqual(stdout.toString(), ""); 64 | 65 | await snapshot( 66 | replaceStackTraces(stderr.toString()), 67 | new URL( 68 | "./test/snapshots/reportCliError/Error-instance-with-stack-stderr.ans", 69 | import.meta.url 70 | ) 71 | ); 72 | 73 | strictEqual(status, 0); 74 | }); 75 | } 76 | ); 77 | 78 | tests.add( 79 | "`reportCliError` with a `Error` instance, without stack.", 80 | async () => { 81 | await disposableDirectory(async (tempDirPath) => { 82 | const filePath = join(tempDirPath, "test.mjs"); 83 | 84 | await writeFile( 85 | filePath, 86 | `import reportCliError from "${REPORT_CLI_ERROR_PATH}"; 87 | 88 | const error = new Error("Message."); 89 | delete error.stack; 90 | reportCliError("CLI", error); 91 | ` 92 | ); 93 | 94 | const { stdout, stderr, status, error } = spawnSync( 95 | "node", 96 | [filePath], 97 | { 98 | env: { 99 | ...process.env, 100 | FORCE_COLOR: "1", 101 | }, 102 | } 103 | ); 104 | 105 | if (error) throw error; 106 | 107 | strictEqual(stdout.toString(), ""); 108 | 109 | await snapshot( 110 | replaceStackTraces(stderr.toString()), 111 | new URL( 112 | "./test/snapshots/reportCliError/Error-instance-without-stack-stderr.ans", 113 | import.meta.url 114 | ) 115 | ); 116 | 117 | strictEqual(status, 0); 118 | }); 119 | } 120 | ); 121 | 122 | tests.add("`reportCliError` with a `CliError` instance.", async () => { 123 | await disposableDirectory(async (tempDirPath) => { 124 | const filePath = join(tempDirPath, "test.mjs"); 125 | const cliErrorPath = fileURLToPath( 126 | new URL("./CliError.mjs", import.meta.url) 127 | ); 128 | 129 | await writeFile( 130 | filePath, 131 | `import CliError from "${cliErrorPath}"; 132 | import reportCliError from "${REPORT_CLI_ERROR_PATH}"; 133 | 134 | reportCliError("CLI", new CliError("Message.")); 135 | ` 136 | ); 137 | 138 | const { stdout, stderr, status, error } = spawnSync("node", [filePath], { 139 | env: { 140 | ...process.env, 141 | FORCE_COLOR: "1", 142 | }, 143 | }); 144 | 145 | if (error) throw error; 146 | 147 | strictEqual(stdout.toString(), ""); 148 | 149 | await snapshot( 150 | replaceStackTraces(stderr.toString()), 151 | new URL( 152 | "./test/snapshots/reportCliError/CliError-instance-stderr.ans", 153 | import.meta.url 154 | ) 155 | ); 156 | 157 | strictEqual(status, 0); 158 | }); 159 | }); 160 | 161 | tests.add("`reportCliError` with a primitive value.", async () => { 162 | await disposableDirectory(async (tempDirPath) => { 163 | const filePath = join(tempDirPath, "test.mjs"); 164 | 165 | await writeFile( 166 | filePath, 167 | `import reportCliError from "${REPORT_CLI_ERROR_PATH}"; 168 | 169 | reportCliError("CLI", ""); 170 | ` 171 | ); 172 | 173 | const { stdout, stderr, status, error } = spawnSync("node", [filePath], { 174 | env: { 175 | ...process.env, 176 | FORCE_COLOR: "1", 177 | }, 178 | }); 179 | 180 | if (error) throw error; 181 | 182 | strictEqual(stdout.toString(), ""); 183 | 184 | await snapshot( 185 | replaceStackTraces(stderr.toString()), 186 | new URL( 187 | "./test/snapshots/reportCliError/primitive-value-stderr.ans", 188 | import.meta.url 189 | ) 190 | ); 191 | 192 | strictEqual(status, 0); 193 | }); 194 | }); 195 | }; 196 | -------------------------------------------------------------------------------- /reportCoverage.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { bold, green, red, yellow } from "kleur/colors"; 4 | import { relative } from "node:path"; 5 | 6 | import errorConsole from "./errorConsole.mjs"; 7 | 8 | /** 9 | * Reports a code coverage analysis to the console. 10 | * @param {import("./analyseCoverage.mjs").CoverageAnalysis} coverageAnalysis 11 | * Coverage analysis. 12 | */ 13 | export default function reportCoverage({ 14 | filesCount, 15 | covered, 16 | ignored, 17 | uncovered, 18 | }) { 19 | if (covered.length) { 20 | console.group( 21 | `\n${green( 22 | `${covered.length} file${covered.length === 1 ? "" : "s"} covered:` 23 | )}\n` 24 | ); 25 | 26 | for (const path of covered) console.info(relative("", path)); 27 | 28 | console.groupEnd(); 29 | } 30 | 31 | if (ignored.length) { 32 | console.group( 33 | `\n${yellow( 34 | `${ignored.length} file${ 35 | ignored.length === 1 ? "" : "s" 36 | } ignoring coverage:` 37 | )}\n` 38 | ); 39 | 40 | for (const { path, ranges } of ignored) 41 | for (const { start, end } of ranges) 42 | console.info( 43 | `${relative("", path)}:${start.line}:${start.column} → ${end.line}:${ 44 | end.column 45 | }` 46 | ); 47 | 48 | console.groupEnd(); 49 | } 50 | 51 | if (uncovered.length) { 52 | errorConsole.group( 53 | `\n${red( 54 | `${uncovered.length} file${ 55 | uncovered.length === 1 ? "" : "s" 56 | } missing coverage:` 57 | )}\n` 58 | ); 59 | 60 | for (const { path, ranges } of uncovered) 61 | for (const { start, end } of ranges) 62 | errorConsole.info( 63 | `${relative("", path)}:${start.line}:${start.column} → ${end.line}:${ 64 | end.column 65 | }` 66 | ); 67 | 68 | errorConsole.groupEnd(); 69 | } 70 | 71 | console.info( 72 | `\n${bold( 73 | (uncovered.length ? red : ignored.length ? yellow : green)( 74 | `${covered.length}/${filesCount} files covered.` 75 | ) 76 | )}\n` 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /sourceRange.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * Gets info about a source code range, including line/column numbers and if 5 | * it’s ignored by a comment. 6 | * @param {string} source Source code. 7 | * @param {number} startOffset Start character offset. 8 | * @param {number} endOffset End character offset. 9 | * @param {string | false} [ignoreNextLineComment] Single line 10 | * case-insensitive comment content to ignore ranges that start on the the 11 | * next line, or `false` to disable ignore comments. Defaults to 12 | * `" coverage ignore next line"`. 13 | * @returns {SourceCodeRange} Source code range info. 14 | */ 15 | export default function sourceRange( 16 | source, 17 | startOffset, 18 | endOffset, 19 | ignoreNextLineComment = " coverage ignore next line" 20 | ) { 21 | if (typeof source !== "string") 22 | throw new TypeError("Argument 1 `source` must be a string."); 23 | 24 | if (typeof startOffset !== "number") 25 | throw new TypeError("Argument 2 `startOffset` must be a number."); 26 | 27 | if (typeof endOffset !== "number") 28 | throw new TypeError("Argument 3 `endOffset` must be a number."); 29 | 30 | if ( 31 | typeof ignoreNextLineComment !== "string" && 32 | ignoreNextLineComment !== false 33 | ) 34 | throw new TypeError( 35 | "Argument 4 `ignoreNextLineComment` must be a string or `false`." 36 | ); 37 | 38 | const ignoreNextLineCommentLowerCase = ignoreNextLineComment 39 | ? `//${ignoreNextLineComment.toLowerCase()}` 40 | : null; 41 | 42 | /** @type {SourceCodeRange["ignore"]} */ 43 | let ignore = false; 44 | 45 | /** @type {SourceCodeLocation["line"] | undefined} */ 46 | let startLine; 47 | 48 | /** @type {SourceCodeLocation["column"] | undefined} */ 49 | let startColumn; 50 | 51 | /** @type {SourceCodeLocation["line"] | undefined} */ 52 | let endLine; 53 | 54 | /** @type {SourceCodeLocation["column"] | undefined} */ 55 | let endColumn; 56 | 57 | const lines = source.split(/^/gmu); 58 | 59 | let lineOffset = 0; 60 | 61 | for (const [lineIndex, lineSource] of lines.entries()) { 62 | const nextLineOffset = lineOffset + lineSource.length; 63 | 64 | if ( 65 | !startLine && 66 | startOffset >= lineOffset && 67 | startOffset < nextLineOffset 68 | ) { 69 | startLine = lineIndex + 1; 70 | startColumn = startOffset - lineOffset + 1; 71 | 72 | if ( 73 | // Ignoring is enabled. 74 | ignoreNextLineCommentLowerCase && 75 | // It’s not the first line that can’t be ignored, because there can’t be 76 | // an ignore comment on the previous line. 77 | lineIndex && 78 | // The previous line contains the case-insensitive comment to ignore 79 | // this line. 80 | lines[lineIndex - 1] 81 | .trim() 82 | .toLowerCase() 83 | .endsWith(ignoreNextLineCommentLowerCase) 84 | ) 85 | ignore = true; 86 | } 87 | 88 | if (endOffset >= lineOffset && endOffset < nextLineOffset) { 89 | endLine = lineIndex + 1; 90 | endColumn = endOffset - lineOffset + 1; 91 | break; 92 | } 93 | 94 | lineOffset = nextLineOffset; 95 | } 96 | 97 | return { 98 | ignore, 99 | start: { 100 | offset: startOffset, 101 | line: /** @type {number} */ (startLine), 102 | column: /** @type {number} */ (startColumn), 103 | }, 104 | end: { 105 | offset: endOffset, 106 | line: /** @type {number} */ (endLine), 107 | column: /** @type {number} */ (endColumn), 108 | }, 109 | }; 110 | } 111 | 112 | /** 113 | * Source code location. 114 | * @typedef {object} SourceCodeLocation 115 | * @prop {number} offset Character offset. 116 | * @prop {number} line Line number. 117 | * @prop {number} column Column number. 118 | */ 119 | 120 | /** 121 | * Source code range details. 122 | * @typedef {object} SourceCodeRange 123 | * @prop {boolean} ignore Should it be ignored. 124 | * @prop {SourceCodeLocation} start Start location. 125 | * @prop {SourceCodeLocation} end End location. 126 | */ 127 | -------------------------------------------------------------------------------- /sourceRange.test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import { deepStrictEqual, throws } from "node:assert"; 4 | 5 | import sourceRange from "./sourceRange.mjs"; 6 | 7 | /** 8 | * Adds `sourceRange` tests. 9 | * @param {import("test-director").default} tests Test director. 10 | */ 11 | export default (tests) => { 12 | tests.add("`sourceRange` with argument 1 `source` not a string.", () => { 13 | throws(() => { 14 | sourceRange( 15 | // @ts-expect-error Testing invalid. 16 | true, 17 | 0, 18 | 0 19 | ); 20 | }, new TypeError("Argument 1 `source` must be a string.")); 21 | }); 22 | 23 | tests.add("`sourceRange` with argument 2 `startOffset` not a number.", () => { 24 | throws(() => { 25 | sourceRange( 26 | "a", 27 | // @ts-expect-error Testing invalid. 28 | true, 29 | 0 30 | ); 31 | }, new TypeError("Argument 2 `startOffset` must be a number.")); 32 | }); 33 | 34 | tests.add("`sourceRange` with argument 3 `endOffset` not a number.", () => { 35 | throws(() => { 36 | sourceRange( 37 | "a", 38 | 0, 39 | // @ts-expect-error Testing invalid. 40 | true 41 | ); 42 | }, new TypeError("Argument 3 `endOffset` must be a number.")); 43 | }); 44 | 45 | tests.add( 46 | "`sourceRange` with argument 4 `ignoreNextLineComment` not a string or `false`.", 47 | () => { 48 | throws(() => { 49 | sourceRange( 50 | "a", 51 | 0, 52 | 0, 53 | // @ts-expect-error Testing invalid. 54 | true 55 | ); 56 | }, new TypeError("Argument 4 `ignoreNextLineComment` must be a string or `false`.")); 57 | } 58 | ); 59 | 60 | tests.add("`sourceRange` with a single char line.", () => { 61 | const source = "a"; 62 | 63 | deepStrictEqual(sourceRange(source, 0, 0), { 64 | ignore: false, 65 | start: { 66 | offset: 0, 67 | line: 1, 68 | column: 1, 69 | }, 70 | end: { 71 | offset: 0, 72 | line: 1, 73 | column: 1, 74 | }, 75 | }); 76 | }); 77 | 78 | tests.add("`sourceRange` with a multi char line.", () => { 79 | const source = "abc"; 80 | 81 | deepStrictEqual(sourceRange(source, 0, 2), { 82 | ignore: false, 83 | start: { 84 | offset: 0, 85 | line: 1, 86 | column: 1, 87 | }, 88 | end: { 89 | offset: 2, 90 | line: 1, 91 | column: 3, 92 | }, 93 | }); 94 | }); 95 | 96 | tests.add("`sourceRange` in multiple lines.", () => { 97 | const source = `abc 98 | 99 | def 100 | ghi 101 | jkl`; 102 | 103 | deepStrictEqual(sourceRange(source, 9, 11), { 104 | ignore: false, 105 | start: { 106 | offset: 9, 107 | line: 4, 108 | column: 1, 109 | }, 110 | end: { 111 | offset: 11, 112 | line: 4, 113 | column: 3, 114 | }, 115 | }); 116 | }); 117 | 118 | tests.add("`sourceRange` across multiple lines.", () => { 119 | const source = `abc 120 | 121 | def 122 | ghi 123 | jkl`; 124 | 125 | deepStrictEqual(sourceRange(source, 6, 11), { 126 | ignore: false, 127 | start: { 128 | offset: 6, 129 | line: 3, 130 | column: 2, 131 | }, 132 | end: { 133 | offset: 11, 134 | line: 4, 135 | column: 3, 136 | }, 137 | }); 138 | }); 139 | 140 | tests.add( 141 | "`sourceRange` with a default ignore comment, line exclusive.", 142 | () => { 143 | const source = `// coverage ignore next line 144 | a`; 145 | 146 | deepStrictEqual(sourceRange(source, 29, 29), { 147 | ignore: true, 148 | start: { 149 | offset: 29, 150 | line: 2, 151 | column: 1, 152 | }, 153 | end: { 154 | offset: 29, 155 | line: 2, 156 | column: 1, 157 | }, 158 | }); 159 | } 160 | ); 161 | 162 | tests.add("`sourceRange` with a default ignore comment, line shared.", () => { 163 | const source = `let a // coverage ignore next line 164 | b`; 165 | 166 | deepStrictEqual(sourceRange(source, 35, 35), { 167 | ignore: true, 168 | start: { 169 | offset: 35, 170 | line: 2, 171 | column: 1, 172 | }, 173 | end: { 174 | offset: 35, 175 | line: 2, 176 | column: 1, 177 | }, 178 | }); 179 | }); 180 | 181 | tests.add( 182 | "`sourceRange` with a default ignore comment, arbitrary capitalization, line exclusive.", 183 | () => { 184 | const source = `// Coverage Ignore Next Line 185 | a`; 186 | 187 | deepStrictEqual(sourceRange(source, 29, 29), { 188 | ignore: true, 189 | start: { 190 | offset: 29, 191 | line: 2, 192 | column: 1, 193 | }, 194 | end: { 195 | offset: 29, 196 | line: 2, 197 | column: 1, 198 | }, 199 | }); 200 | } 201 | ); 202 | 203 | tests.add( 204 | "`sourceRange` with a default ignore comment, arbitrary capitalization, line shared.", 205 | () => { 206 | const source = `let a // Coverage Ignore Next Line 207 | b`; 208 | 209 | deepStrictEqual(sourceRange(source, 35, 35), { 210 | ignore: true, 211 | start: { 212 | offset: 35, 213 | line: 2, 214 | column: 1, 215 | }, 216 | end: { 217 | offset: 35, 218 | line: 2, 219 | column: 1, 220 | }, 221 | }); 222 | } 223 | ); 224 | 225 | tests.add( 226 | "`sourceRange` with a custom ignore comment, line exclusive.", 227 | () => { 228 | const source = `// a 229 | a`; 230 | 231 | deepStrictEqual(sourceRange(source, 5, 5, " a"), { 232 | ignore: true, 233 | start: { 234 | offset: 5, 235 | line: 2, 236 | column: 1, 237 | }, 238 | end: { 239 | offset: 5, 240 | line: 2, 241 | column: 1, 242 | }, 243 | }); 244 | } 245 | ); 246 | 247 | tests.add("`sourceRange` with a custom ignore comment, line shared.", () => { 248 | const source = `let a // a 249 | b`; 250 | 251 | deepStrictEqual(sourceRange(source, 11, 11, " a"), { 252 | ignore: true, 253 | start: { 254 | offset: 11, 255 | line: 2, 256 | column: 1, 257 | }, 258 | end: { 259 | offset: 11, 260 | line: 2, 261 | column: 1, 262 | }, 263 | }); 264 | }); 265 | 266 | tests.add( 267 | "`sourceRange` with a custom ignore comment, arbitrary capitalization, line exclusive.", 268 | () => { 269 | const source = `// A 270 | a`; 271 | 272 | deepStrictEqual(sourceRange(source, 5, 5, " a"), { 273 | ignore: true, 274 | start: { 275 | offset: 5, 276 | line: 2, 277 | column: 1, 278 | }, 279 | end: { 280 | offset: 5, 281 | line: 2, 282 | column: 1, 283 | }, 284 | }); 285 | } 286 | ); 287 | 288 | tests.add( 289 | "`sourceRange` with a custom ignore comment, arbitrary capitalization, line shared.", 290 | () => { 291 | const source = `let a // A 292 | b`; 293 | 294 | deepStrictEqual(sourceRange(source, 11, 11, " a"), { 295 | ignore: true, 296 | start: { 297 | offset: 11, 298 | line: 2, 299 | column: 1, 300 | }, 301 | end: { 302 | offset: 11, 303 | line: 2, 304 | column: 1, 305 | }, 306 | }); 307 | } 308 | ); 309 | 310 | tests.add("`sourceRange` with ignore comments disabled.", () => { 311 | const source = `// coverage ignore next line 312 | a`; 313 | 314 | deepStrictEqual(sourceRange(source, 29, 29, false), { 315 | ignore: false, 316 | start: { 317 | offset: 29, 318 | line: 2, 319 | column: 1, 320 | }, 321 | end: { 322 | offset: 29, 323 | line: 2, 324 | column: 1, 325 | }, 326 | }); 327 | }); 328 | }; 329 | -------------------------------------------------------------------------------- /test.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import TestDirector from "test-director"; 4 | 5 | import test_analyseCoverage from "./analyseCoverage.test.mjs"; 6 | import test_childProcessPromise from "./childProcessPromise.test.mjs"; 7 | import test_CliError from "./CliError.test.mjs"; 8 | import test_cli_coverage_node from "./coverage-node.test.mjs"; 9 | import test_reportCliError from "./reportCliError.test.mjs"; 10 | import test_sourceRange from "./sourceRange.test.mjs"; 11 | 12 | const tests = new TestDirector(); 13 | 14 | test_CliError(tests); 15 | test_analyseCoverage(tests); 16 | test_childProcessPromise(tests); 17 | test_cli_coverage_node(tests); 18 | test_reportCliError(tests); 19 | test_sourceRange(tests); 20 | 21 | tests.run(); 22 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-covered-file-stdout.ans: -------------------------------------------------------------------------------- 1 | 2 | 1 file covered: 3 | 4 | 5 | 6 | 1/1 files covered. 7 | 8 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-ignored-file-stdout.ans: -------------------------------------------------------------------------------- 1 | 2 | 1 file ignoring coverage: 3 | 4 | :2:1 → 2:8 5 | 6 | 0/1 files covered. 7 | 8 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | 1 file missing coverage: 3 | 4 | :1:1 → 1:8 5 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-falsy-stdout.ans: -------------------------------------------------------------------------------- 1 | 2 | 0/1 files covered. 3 | 4 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | 1 file missing coverage: 3 | 4 | :1:1 → 1:8 5 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/1-uncovered-file-ALLOW_MISSING_COVERAGE-truthy-stdout.ans: -------------------------------------------------------------------------------- 1 | 2 | 0/1 files covered. 3 | 4 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/2-covered-ignored-uncovered-files-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | 2 files missing coverage: 3 | 4 | :1:1 → 1:8 5 | :1:1 → 1:8 6 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/2-covered-ignored-uncovered-files-stdout.ans: -------------------------------------------------------------------------------- 1 | 2 | 2 files covered: 3 | 4 | 5 | 6 | 7 | 2 files ignoring coverage: 8 | 9 | :2:1 → 2:8 10 | :2:1 → 2:8 11 | 12 | 2/6 files covered. 13 | 14 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/node-option-invalid-stderr.ans: -------------------------------------------------------------------------------- 1 | node: bad option: --not-a-real-option 2 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/script-console-log-stdout.ans: -------------------------------------------------------------------------------- 1 | Message. 2 | 3 | 1 file covered: 4 | 5 | 6 | 7 | 1/1 files covered. 8 | 9 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/script-error-stderr.ans: -------------------------------------------------------------------------------- 1 | file://:1 2 | throw new Error("Error."); 3 | ^ 4 | 5 | Error: Error. 6 | 7 | -------------------------------------------------------------------------------- /test/snapshots/coverage-node/without-arguments-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | Error running Node.js with coverage: 3 | 4 | Node.js CLI arguments are required. 5 | 6 | -------------------------------------------------------------------------------- /test/snapshots/reportCliError/CliError-instance-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | Error running CLI: 3 | 4 | Message. 5 | 6 | -------------------------------------------------------------------------------- /test/snapshots/reportCliError/Error-instance-with-stack-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | Error running CLI: 3 | 4 | Error: Message. 5 |  6 | 7 | -------------------------------------------------------------------------------- /test/snapshots/reportCliError/Error-instance-without-stack-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | Error running CLI: 3 | 4 | Error: Message. 5 | 6 | -------------------------------------------------------------------------------- /test/snapshots/reportCliError/primitive-value-stderr.ans: -------------------------------------------------------------------------------- 1 | 2 | Error running CLI: 3 | 4 | '' 5 | 6 | --------------------------------------------------------------------------------