├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── elm-coverage ├── elm-instrument ├── fake-elm └── fake-elm.cmd ├── docs ├── .gitignore ├── Makefile ├── conf.py ├── elm-coverage.json ├── index.rst ├── reports.rst ├── requirements.txt └── under-the-hood.rst ├── elm.json ├── install.js ├── kernel-src └── Coverage.elm ├── lib ├── aggregate.js ├── analyze.js ├── cliArgs.js ├── codeCov.js ├── runner.js └── summarize.js ├── package-lock.json ├── package.json ├── src ├── Analyzer.elm ├── Coverage.elm ├── Html │ └── String │ │ └── Extra.elm ├── Markup.elm ├── Overview.elm ├── Service.elm ├── Source.elm ├── Styles.elm └── Util.elm └── tests ├── data └── simple │ ├── .gitignore │ ├── elm.json │ ├── expected.json │ ├── src │ ├── Main.elm │ └── Simple.elm │ └── tests │ └── Example.elm └── runner.js /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [ main ] 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [windows-latest, ubuntu-latest] 14 | node-version: [12.x, 14.x, 15.x] 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | - name: Install dependencies 22 | run: npm ci 23 | - run: npm run make 24 | - run: npm test 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /elm-stuff/ 2 | /node_modules/ 3 | /bin/elm-instrument 4 | /unpacked_bin 5 | /lib/analyzer.js 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2017, Ilias Van Peer 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Elm Coverage [![Build Status](https://travis-ci.org/zwilias/elm-coverage.svg?branch=master)](https://travis-ci.org/zwilias/elm-coverage) [![Build status](https://ci.appveyor.com/api/projects/status/xsarefeowsmffnny?svg=true)](https://ci.appveyor.com/project/zwilias/elm-coverage) [![Documentation Status](https://readthedocs.org/projects/elm-coverage/badge/?version=latest)](http://elm-coverage.readthedocs.io/en/latest/?badge=latest) 2 | > Work in progress - Code coverage tooling for Elm 3 | 4 | `elm-coverage` is a tool for calculating code coverage for Elm code tested with 5 | `elm-test`. 6 | 7 | The goal of the reports generated by `elm-coverage` is to help you visualize 8 | what parts of your code are being evaluated when running your tests, and to try 9 | and guide you towards writing tests that focus specifically on the more complex 10 | functions in your codebase. 11 | 12 | The goal is **not** to condense that information down into a single metric. It 13 | is too easy to write tests that don't make meaningful assertions about your code 14 | and its behaviour, but only serve to increase the coverage. 15 | 16 | The only thing worse than having no tests is having tests that provide a false 17 | sense of security. 18 | 19 | For further reading, please direct yourself to https://elm-coverage.readthedocs.io 20 | 21 | Installation 22 | ------------ 23 | 24 | Installing `elm-coverage` works much the same way as installing other tools in 25 | the Elm ecosystem: 26 | 27 | npm i -g elm-coverage 28 | 29 | Usage 30 | ----- 31 | 32 | The simplest invocation of `elm-coverage` is to simply invoke `elm-coverage` in 33 | the root of your project: 34 | 35 | elm-coverage 36 | 37 | By default, `elm-coverage` assumes that your sources exist in a separate `src/` 38 | directory, that you have `elm-test` installed globally, and that `elm-test` 39 | needs no further, special flags. 40 | 41 | You can specify an alternative path to crawl for sources to instrument: 42 | 43 | elm-coverage elm_src/ 44 | 45 | If you don't want to use a globally installed `elm-test`, you can specify the 46 | path to an `elm-test` executable: 47 | 48 | elm-coverage --elm-test ./node_modules/.bin/elm-test 49 | 50 | Parameters following `--` are passed through to `elm-test`, for example to 51 | specify the initial `seed` and number of `fuzz` testruns: 52 | 53 | elm-coverage -- --seed 12345 --fuzz 99 54 | 55 | `elm-coverage` will write an HTML report to `.coverage/coverage.html`. It is not 56 | recommended to version control this directory. In order to open the report once 57 | it is generated, you can specify the `--open` option: 58 | 59 | elm-coverage --open 60 | 61 | Contribute 62 | ---------- 63 | 64 | Issues and source code is available [on 65 | github](https://github.com/zwilias/elm-coverage>). The source for 66 | `elm-instrument` which is used to actually instrument the sources in order to 67 | calculate coverage is also 68 | [available](https://github.com/zwilias/elm-instrument). 69 | 70 | License 71 | ------- 72 | 73 | `elm-coverage` is licensed under the BSD-3 license. `elm-instrument` is 74 | licensed under the BSD-3 license. 75 | -------------------------------------------------------------------------------- /bin/elm-coverage: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var runner = require("../lib/runner.js"); 4 | require("../lib/cliArgs")(runner).argv; 5 | -------------------------------------------------------------------------------- /bin/elm-instrument: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | var path = require("path"); 3 | var spawn = require("child_process").spawn; 4 | spawn(path.join(__dirname, "..", "unpacked_bin", "elm-instrument"), process.argv.slice(2), {stdio: 'inherit'}).on('exit', process.exit); -------------------------------------------------------------------------------- /bin/fake-elm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var yargs = require("yargs"); 4 | var spawn = require("child_process").spawn; 5 | var fs = require("fs-extra"); 6 | var which = require("which"); 7 | 8 | var elmBinary = which.sync("elm"); 9 | 10 | yargs 11 | .version("0.19.1") 12 | .command({ 13 | command: "$0", 14 | desc: "pass through arguments to elm", 15 | handler: function(args) { 16 | spawn(elmBinary, process.argv.slice(2), { stdio: "inherit" }).on( 17 | "exit", 18 | process.exit 19 | ); 20 | } 21 | }) 22 | .command({ 23 | command: "make", 24 | desc: 25 | "run elm make and then inject coverage tracking code into generated JS output", 26 | builder: function(yargs) { 27 | return yargs 28 | .option("output", { 29 | describe: "specify the name of the resulting JS file.", 30 | type: "string" 31 | }) 32 | .option("report", { 33 | describe: "Report in another format", 34 | type: "string" 35 | }); 36 | }, 37 | handler: function(args) { 38 | if (args.report == "json" || args.output == "/dev/null") { 39 | spawn(elmBinary, process.argv.slice(2), { 40 | stdio: "inherit" 41 | }).on("exit", process.exit); 42 | return; 43 | } 44 | 45 | spawn(elmBinary, process.argv.slice(2), { stdio: "inherit" }).on( 46 | "exit", 47 | function() { 48 | fs.readFile(args.output, { encoding: "utf8" }, function( 49 | err, 50 | data 51 | ) { 52 | if (err) { 53 | return; 54 | } 55 | var pattern = new RegExp( 56 | "(^var\\s+\\$author\\$project\\$Coverage\\$track.*$\\s+function\\s+\\()" + // function capture 57 | "([a-zA-Z]+)" + // arg1 58 | "\\s*,\\s*" + 59 | "([a-zA-Z]+)" + // arg2 60 | "\\)\\s+{$" + // end of function call 61 | "", 62 | "gm" 63 | ); 64 | 65 | var matches = data.match(pattern); 66 | if (!matches) { 67 | return; 68 | } 69 | matches = pattern.exec(data); 70 | 71 | var replacement = [ 72 | "// INJECTED COVERAGE FIXTURE ", 73 | 'var fs = require("fs");', 74 | "var counters = {};", 75 | "setTimeout(function() {", 76 | ' if (typeof app === "undefined") {', 77 | ' throw "elm-coverage error, failed to find test runner provided by elm-test";', 78 | " }", 79 | " app.ports.elmTestPort__send.subscribe(function(rawData) {", 80 | " var data = JSON.parse(rawData);", 81 | ' if (data.type === "FINISHED") {', 82 | " fs.writeFileSync(", 83 | ' "data-" + process.pid + ".json",', 84 | " JSON.stringify(counters)", 85 | " );", 86 | " }", 87 | " });", 88 | "});", 89 | "", 90 | matches[1] + matches[2] + ", " + matches[3] + ") {", 91 | " counters[" + 92 | matches[2] + 93 | "] = counters[" + 94 | matches[2] + 95 | "] || [];", 96 | " counters[" + 97 | matches[2] + 98 | "].push(" + 99 | matches[3] + 100 | ");", 101 | "" 102 | ].join("\n"); 103 | 104 | var result = data.replace(pattern, replacement); 105 | fs.writeFileSync(args.output, result); 106 | process.exit(); 107 | }); 108 | } 109 | ); 110 | } 111 | }).argv; 112 | -------------------------------------------------------------------------------- /bin/fake-elm.cmd: -------------------------------------------------------------------------------- 1 | @SETLOCAL 2 | @SET PATHEXT=%PATHEXT:;.JS;=;% 3 | node "%~dp0\fake-elm" %* 4 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = elm-coverage 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # elm-coverage documentation build configuration file, created by 5 | # sphinx-quickstart on Sat Dec 9 18:44:26 2017. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | import sphinx_rtd_theme 24 | 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | # 30 | # needs_sphinx = '1.0' 31 | 32 | # Add any Sphinx extension module names here, as strings. They can be 33 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 34 | # ones. 35 | extensions = [] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix(es) of source filenames. 41 | # You can specify multiple suffix as a list of string: 42 | # 43 | # source_suffix = ['.rst', '.md'] 44 | source_suffix = '.rst' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = 'elm-coverage' 51 | copyright = '2017, Ilias Van Peer' 52 | author = 'Ilias Van Peer' 53 | 54 | # The version info for the project you're documenting, acts as replacement for 55 | # |version| and |release|, also used in various other places throughout the 56 | # built documents. 57 | # 58 | # The short X.Y version. 59 | version = '0.1.0' 60 | # The full version, including alpha/beta/rc tags. 61 | release = '0.1.0' 62 | 63 | # The language for content autogenerated by Sphinx. Refer to documentation 64 | # for a list of supported languages. 65 | # 66 | # This is also used if you do content translation via gettext catalogs. 67 | # Usually you set "language" from the command line for these cases. 68 | language = None 69 | 70 | # List of patterns, relative to source directory, that match files and 71 | # directories to ignore when looking for source files. 72 | # This patterns also effect to html_static_path and html_extra_path 73 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 74 | 75 | # The name of the Pygments (syntax highlighting) style to use. 76 | pygments_style = 'sphinx' 77 | 78 | # If true, `todo` and `todoList` produce output, else they produce nothing. 79 | todo_include_todos = False 80 | 81 | 82 | # -- Options for HTML output ---------------------------------------------- 83 | 84 | # The theme to use for HTML and HTML Help pages. See the documentation for 85 | # a list of builtin themes. 86 | # 87 | html_theme = 'sphinx_rtd_theme' 88 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 89 | 90 | # Theme options are theme-specific and customize the look and feel of a theme 91 | # further. For a list of options available for each theme, see the 92 | # documentation. 93 | # 94 | # html_theme_options = {} 95 | 96 | # Add any paths that contain custom static files (such as style sheets) here, 97 | # relative to this directory. They are copied after the builtin static files, 98 | # so a file named "default.css" will overwrite the builtin "default.css". 99 | html_static_path = ['_static'] 100 | 101 | # Custom sidebar templates, must be a dictionary that maps document names 102 | # to template names. 103 | # 104 | # This is required for the alabaster theme 105 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 106 | html_sidebars = { 107 | '**': [ 108 | 'relations.html', # needs 'show_related': True theme option to display 109 | 'searchbox.html', 110 | ] 111 | } 112 | 113 | 114 | # -- Options for HTMLHelp output ------------------------------------------ 115 | 116 | # Output file base name for HTML help builder. 117 | htmlhelp_basename = 'elm-coveragedoc' 118 | 119 | 120 | # -- Options for LaTeX output --------------------------------------------- 121 | 122 | latex_elements = { 123 | # The paper size ('letterpaper' or 'a4paper'). 124 | # 125 | # 'papersize': 'letterpaper', 126 | 127 | # The font size ('10pt', '11pt' or '12pt'). 128 | # 129 | # 'pointsize': '10pt', 130 | 131 | # Additional stuff for the LaTeX preamble. 132 | # 133 | # 'preamble': '', 134 | 135 | # Latex figure (float) alignment 136 | # 137 | # 'figure_align': 'htbp', 138 | } 139 | 140 | # Grouping the document tree into LaTeX files. List of tuples 141 | # (source start file, target name, title, 142 | # author, documentclass [howto, manual, or own class]). 143 | latex_documents = [ 144 | (master_doc, 'elm-coverage.tex', 'elm-coverage Documentation', 145 | 'Ilias Van Peer', 'manual'), 146 | ] 147 | 148 | 149 | # -- Options for manual page output --------------------------------------- 150 | 151 | # One entry per manual page. List of tuples 152 | # (source start file, name, description, authors, manual section). 153 | man_pages = [ 154 | (master_doc, 'elm-coverage', 'elm-coverage Documentation', 155 | [author], 1) 156 | ] 157 | 158 | 159 | # -- Options for Texinfo output ------------------------------------------- 160 | 161 | # Grouping the document tree into Texinfo files. List of tuples 162 | # (source start file, target name, title, author, 163 | # dir menu entry, description, category) 164 | texinfo_documents = [ 165 | (master_doc, 'elm-coverage', 'elm-coverage Documentation', 166 | author, 'elm-coverage', 'One line description of project.', 167 | 'Miscellaneous'), 168 | ] 169 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /docs/elm-coverage.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "type": "object", 4 | "properties": { 5 | "coverageData": { 6 | "description": "Mapping of modules to coverage data.", 7 | "type": "object", 8 | "patternProperties": { 9 | "^.*$": { 10 | "type": "array", 11 | "items": { 12 | "$ref": "#/definitions/annotation" 13 | } 14 | } 15 | } 16 | }, 17 | "moduleMap": { 18 | "description": "Mapping of modules to filenames.", 19 | "type": "object", 20 | "patternProperties": { 21 | "^.*$": { 22 | "type": "string" 23 | } 24 | } 25 | } 26 | }, 27 | "additionalProperties": false, 28 | "definitions": { 29 | "annotation": { 30 | "description": "Each annotation has a region identified by the `to` and `from` properties. These regions may be contained in other regions, but can never overlap only partially. An annotation has an option `count` property which represents the number of times this expression was evaluated. When the `count` is missing, the expression was never evaluated.", 31 | "type": "object", 32 | "anyOf": [ 33 | { 34 | "$ref": "#/definitions/declaration" 35 | }, 36 | { 37 | "$ref": "#/definitions/complexityAnnotation" 38 | }, 39 | { 40 | "$ref": "#/definitions/simpleAnnotation" 41 | } 42 | ] 43 | }, 44 | "declaration": { 45 | "description": "A (top-level) declaration defines one (or more) values; which may be constants or functions", 46 | "properties": { 47 | "type": { 48 | "const": "declaration" 49 | }, 50 | "complexity": { 51 | "type": "integer" 52 | }, 53 | "name": { 54 | "type": "string" 55 | }, 56 | "count": { 57 | "type": "integer" 58 | }, 59 | "from": { 60 | "$ref": "#/definitions/location" 61 | }, 62 | "to": { 63 | "$ref": "#/definitions/location" 64 | } 65 | }, 66 | "required": [ 67 | "type", 68 | "complexity", 69 | "name", 70 | "from", 71 | "to" 72 | ], 73 | "additionalProperties": false 74 | }, 75 | "complexityAnnotation": { 76 | "description": "Lambdas and let-declarations", 77 | "properties": { 78 | "type": { 79 | "enum": [ 80 | "letDeclaration", 81 | "lambdaBody" 82 | ] 83 | }, 84 | "complexity": { 85 | "type": "integer" 86 | }, 87 | "count": { 88 | "type": "integer" 89 | }, 90 | "from": { 91 | "$ref": "#/definitions/location" 92 | }, 93 | "to": { 94 | "$ref": "#/definitions/location" 95 | } 96 | }, 97 | "required": [ 98 | "type", 99 | "complexity", 100 | "from", 101 | "to" 102 | ], 103 | "additionalProperties": false 104 | }, 105 | "simpleAnnotation": { 106 | "description": "branches", 107 | "properties": { 108 | "type": { 109 | "enum": [ 110 | "ifElseBranch", 111 | "caseBranch" 112 | ] 113 | }, 114 | "count": { 115 | "type": "integer" 116 | }, 117 | "from": { 118 | "$ref": "#/definitions/location" 119 | }, 120 | "to": { 121 | "$ref": "#/definitions/location" 122 | } 123 | }, 124 | "required": [ 125 | "type", 126 | "from", 127 | "to" 128 | ], 129 | "additionalProperties": false 130 | }, 131 | "location": { 132 | "additionalProperties": false, 133 | "properties": { 134 | "line": { 135 | "description": "Line number, starts at 1", 136 | "type": "integer" 137 | }, 138 | "column": { 139 | "description": "Column, starts at 1", 140 | "type": "integer" 141 | } 142 | }, 143 | "required": [ 144 | "line", 145 | "column" 146 | ] 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | elm-coverage 2 | ============ 3 | 4 | 5 | ``elm-coverage`` is a tool for calculating code coverage for Elm code tested with 6 | ``elm-test``. 7 | 8 | The goal of the reports generated by ``elm-coverage`` is to help you visualize 9 | what parts of your code are being evaluated when running your tests, and to try 10 | and guide you towards writing tests that focus specifically on the more complex 11 | functions in your codebase. 12 | 13 | The goal is **not** to condense that information down into a single metric. It 14 | is too easy to write tests that don't make meaningful assertions about your code 15 | and its behaviour, but only serve to increase the coverage. 16 | 17 | The only thing worse than having no tests is having tests that provide a false 18 | sense of security. 19 | 20 | Installation 21 | ------------ 22 | 23 | Installing ``elm-coverage`` works much the same way as installing other tools in 24 | the Elm ecosystem:: 25 | 26 | npm i -g elm-coverage 27 | 28 | Usage 29 | ----- 30 | 31 | The simplest invocation of ``elm-coverage`` is to simply invoke ``elm-coverage`` in 32 | the root of your project:: 33 | 34 | elm-coverage 35 | 36 | By default, ``elm-coverage`` assumes that your sources exist in a separate ``src/`` 37 | directory, that you have ``elm-test`` installed globally, and that ``elm-test`` 38 | needs no further, special flags. 39 | 40 | You can specify an alternative path to crawl for sources to instrument:: 41 | 42 | elm-coverage elm_src/ 43 | 44 | If you don't want to use a globally installed ``elm-test``, you can specify the 45 | path to an ``elm-test`` executable:: 46 | 47 | elm-coverage --elm-test ./node_modules/.bin/elm-test 48 | 49 | Parameters following ``--`` are passed through to ``elm-test``, for example to 50 | specify the initial ``seed`` and number of ``fuzz`` testruns:: 51 | 52 | elm-coverage -- --seed 12345 --fuzz 99 53 | 54 | ``elm-coverage`` will write an HTML report to ``.coverage/coverage.html``. It is not 55 | recommended to version control this directory. In order to open the report once 56 | it is generated, you can specify the ``--open`` option:: 57 | 58 | elm-coverage --open 59 | 60 | Contribute 61 | ---------- 62 | 63 | Issues and source code is available `on github 64 | `_. The source for ``elm-instrument`` which 65 | is used to actually instrument the sources in order to calculate coverage is 66 | also `available `_. 67 | 68 | License 69 | ------- 70 | 71 | ``elm-coverage`` is licensed under the BSD-3 license. ``elm-instrument`` is 72 | licensed under the BSD-3 license. 73 | 74 | 75 | Further reading 76 | --------------- 77 | 78 | .. toctree:: 79 | :maxdepth: 2 80 | 81 | reports 82 | under-the-hood 83 | 84 | -------------------------------------------------------------------------------- /docs/reports.rst: -------------------------------------------------------------------------------- 1 | Reports 2 | ======= 3 | 4 | *TODO*: Give examples and information. 5 | 6 | Cyclomatic Complexity 7 | --------------------- 8 | 9 | *Cyclomatic complexity* gives an indication of how complex a piece of code is. 10 | Essentially, it counts the number of paths through code. 11 | 12 | The goal of including complexity information is to help prioritizing which 13 | functions to write tests for. A function (or module) with a very high cyclomatic 14 | complexity and low test-coverage is a good place to start testing. 15 | 16 | Expression complexity 17 | ~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | There are only two expressions that - by themselves - increment cyclomatic 20 | complexity: ``if then`` and branches in ``case of``. 21 | 22 | Let's look at some examples:: 23 | 24 | if a == 12 then 25 | "It was twelve!" 26 | else 27 | "It wasn't twelve..." 28 | 29 | The above example has a cyclomatic complexity of **1**, as there is 1 extra flow 30 | through the code added. Similarly:: 31 | 32 | if a == 6 then 33 | "It was six." 34 | else if a == 12 then 35 | "It was twelve!" 36 | else 37 | "It was just a random number..." 38 | 39 | This expression has a cyclomatic complexity of **2**. 40 | 41 | For ``case of``, similar rules apply. A single branch (which can either 42 | be a catch-all or a destructuring) does not increase complexity; as all pattern 43 | matches in Elm must be exhaustive, we know that this branch was the only option 44 | and as such, does not introduce a decision point. 45 | 46 | As such, and easy way to compute the complexity of an expression is to count the 47 | number of ``if`` branches, add the number of ``case`` branches and subtract the 48 | number of ``case of`` expressions. 49 | 50 | Declaration complexity 51 | ~~~~~~~~~~~~~~~~~~~~~~ 52 | 53 | The total complexity of a top-level declaration is simply the total complexity 54 | of its body + 1. Note that let-bindings do not - by themselves - increase the 55 | complexity of a declaration. If they contain ``case of`` expressions of 56 | ``if then`` expressions, however, they will count towards the complexity 57 | of the surrounding declaration. The same rule is applied to anonymous functions. 58 | 59 | Module complexity 60 | ~~~~~~~~~~~~~~~~~ 61 | 62 | The complexity of a module is calculated by taking the sum of the calculated 63 | complexity of each declaration, subtracting the number of declarations and 64 | adding one. 65 | 66 | As such, no matter how many declarations are defined in a module, if they all 67 | have complexity **1**, the module will also have complexity **1**. If there is 68 | one declaration with complexity **2** and one declaration with complexity **3**, 69 | the total complexity of that module will be **4**. 70 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx==1.6.5 2 | sphinx-autobuild==0.7.1 3 | sphinx_rtd_theme==0.2.4 4 | -------------------------------------------------------------------------------- /docs/under-the-hood.rst: -------------------------------------------------------------------------------- 1 | Under the hood 2 | ============== 3 | 4 | ``elm-coverage`` consists of a fair number of moving parts. This is my attempt 5 | to document how those parts work together, and which part is responsible for 6 | what. 7 | 8 | The runner 9 | ---------- 10 | 11 | The *runner* or *supervisor* is the main entrypoint and is responsible for 12 | glueing all the pieces together to a coherent whole. 13 | 14 | Its responsibilities are roughly these: 15 | 16 | - Parse the commandline arguments 17 | - Traverse the source-path, looking for Elm-files 18 | - Create a backup of all of these and instrument the originals in-place using 19 | ``elm-instrument`` 20 | - Modify ``tests/elm-package.json`` to know where the ``Coverage`` module is 21 | - Run ``elm-test`` 22 | - Restore all the backups (sources and ``tests/elm-package.json`` 23 | - Instruct the analyzer to analyze the generated coverage files and create a 24 | report 25 | - Optionally, try to open the generated report in the user's browser 26 | 27 | Instrumenting with ``elm-instrument`` 28 | ------------------------------------- 29 | 30 | Instrumentation is handled by an AST->AST transformation implemented in a fork 31 | of ``elm-format`` - since that project happens to have the highest quality 32 | parser+writer in the ecosystem, battle-tested on hundreds of projects. 33 | 34 | The AST is traversed and modified while also keeping track of a few bit of 35 | information. Certain expressions (specifically the bodies of declarations, 36 | let-declarations, lambda's, if/else branches and case..of branches) are 37 | instrumented with a ``let _ = Coverage.track 38 | in`` expression. Whenever instrumentation is added, some 39 | information about the instrumented expression is tracked. 40 | 41 | 42 | +---------------+----------+----------+----------+ 43 | | |Source |Cyclomatic|Name | 44 | | |location |complexity| | 45 | +===============+==========+==========+==========+ 46 | |**Declaration**|x |x |x | 47 | +---------------+----------+----------+----------+ 48 | |**Let |x |x | | 49 | |declaration** | | | | 50 | +---------------+----------+----------+----------+ 51 | |**Lambda body**|x |x | | 52 | +---------------+----------+----------+----------+ 53 | |**if/else |x | | | 54 | |branch** | | | | 55 | +---------------+----------+----------+----------+ 56 | |**case..of |x | | | 57 | |branch** | | | | 58 | +---------------+----------+----------+----------+ 59 | 60 | The recorded information is accumulated for all instrumented modules and 61 | persisted to ``.coverage/info.json``. 62 | 63 | The ``Coverage`` module 64 | ----------------------- 65 | 66 | The ``Coverage`` module, which is "linked in" by the runner, exposes a single 67 | function:: 68 | 69 | Coverage.track : String -> Int -> Never -> a 70 | 71 | It is passed the module-name and an offset in the total list of track 72 | expressions of a module, and returns a function that can never be called. When 73 | evaluated, the internal coverage-data is updated; incrementing a simple counter 74 | based on the module-name and offset. 75 | 76 | When the active process signals `elm-test` that all of its tests have finished 77 | running, the coverage data is persisted to disk in a ``coverage-{{pid}}.json`` 78 | file. 79 | 80 | The analyzer 81 | ------------ 82 | 83 | The analyzer is a thin wrapper around an Elm module. The wrapper reads in the 84 | ``info.json`` file created by the instrumenter, all the coverage files created 85 | by the ``elm-test`` run, and all the sources of the referenced files. Once all 86 | the data is read, it is bundled up and sent off to an Elm module for further 87 | processing. 88 | 89 | The Elm module parses all that data and creates the HTML report, returning the 90 | generated report as a String over a port. 91 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "NoRedInk/elm-string-conversions": "1.0.1", 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.4", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "zwilias/elm-html-string": "2.0.2" 15 | }, 16 | "indirect": { 17 | "elm/bytes": "1.0.8", 18 | "elm/file": "1.0.5", 19 | "elm/http": "2.0.0", 20 | "elm/time": "1.0.0", 21 | "elm/url": "1.0.0", 22 | "elm/virtual-dom": "1.0.2" 23 | } 24 | }, 25 | "test-dependencies": { 26 | "direct": {}, 27 | "indirect": {} 28 | } 29 | } -------------------------------------------------------------------------------- /install.js: -------------------------------------------------------------------------------- 1 | var binwrap = require("binwrap"); 2 | var path = require("path"); 3 | 4 | var binVersion = "0.0.7"; 5 | 6 | var root = 7 | "https://github.com/zwilias/elm-instrument/releases/download/" + 8 | binVersion + 9 | "/"; 10 | 11 | module.exports = binwrap({ 12 | dirname: __dirname, 13 | binaries: ["elm-instrument"], 14 | urls: { 15 | "darwin-arm64": root + "osx-x64.tar.gz", 16 | "darwin-x64": root + "osx-x64.tar.gz", 17 | "linux-x64": root + "linux-x64.tar.gz", 18 | "win32-x64": root + "windows-x64.tar.gz", 19 | "win32-ia32": root + "windows-ia32.tar.gz" 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /kernel-src/Coverage.elm: -------------------------------------------------------------------------------- 1 | module Coverage exposing (track) 2 | 3 | 4 | track : String -> Int -> () 5 | track line index = 6 | () 7 | -------------------------------------------------------------------------------- /lib/aggregate.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs-extra"), 2 | Promise = require("bluebird"), 3 | path = require("path"); 4 | 5 | var elmTestGeneratedDir = path.join( 6 | ".coverage", 7 | "instrumented" 8 | ); 9 | 10 | module.exports = function(sourcePath) { 11 | return Promise.all([fs.readdir(elmTestGeneratedDir), readInfo()]) 12 | .spread(function(fileList, infoData) { 13 | return Promise.reduce( 14 | fileList.filter(isCoverageDataFile).map(function(fileName) { 15 | return readJsonFile( 16 | path.join(elmTestGeneratedDir, fileName) 17 | ); 18 | }), 19 | addCoverage, 20 | infoData 21 | ); 22 | }) 23 | .then(function(coverageData) { 24 | var moduleMap = {}; 25 | 26 | Object.keys(coverageData).forEach(function(moduleName) { 27 | moduleMap[moduleName] = toPath(sourcePath, moduleName); 28 | }); 29 | 30 | return { 31 | coverageData: coverageData, 32 | moduleMap: moduleMap 33 | }; 34 | }); 35 | }; 36 | 37 | function readJsonFile(filePath) { 38 | return new Promise(function(resolve, reject) { 39 | fs.readFile(filePath) 40 | .then(function(infoData) { 41 | try { 42 | resolve(JSON.parse(infoData)); 43 | } catch (e) { 44 | reject(e); 45 | } 46 | }) 47 | .catch(reject); 48 | }); 49 | } 50 | 51 | function readInfo() { 52 | return readJsonFile(path.join(".coverage", "info.json")); 53 | } 54 | 55 | function addCoverage(info, coverage) { 56 | Object.keys(coverage).forEach(function(module) { 57 | var evaluatedExpressions = coverage[module]; 58 | 59 | evaluatedExpressions.forEach(function(idx) { 60 | info[module][idx].count = info[module][idx].count + 1 || 1; 61 | }); 62 | }); 63 | 64 | return info; 65 | } 66 | 67 | function isCoverageDataFile(filePath) { 68 | return /^data-\d+.json$/.test(filePath); 69 | } 70 | 71 | function toPath(sourcePath, moduleName) { 72 | var parts = moduleName.split("."); 73 | var moduleFile = parts.pop(); 74 | parts.push(moduleFile + ".elm"); 75 | 76 | return path.join.apply(path, [sourcePath].concat(parts)); 77 | } 78 | -------------------------------------------------------------------------------- /lib/analyze.js: -------------------------------------------------------------------------------- 1 | var emitter = require("./analyzer"), 2 | fs = require("fs-extra"), 3 | Promise = require("bluebird"), 4 | packageInfo = require("../package.json"), 5 | path = require("path"); 6 | 7 | module.exports = function(sourcePath, allData) { 8 | return new Promise(function(resolve, reject) { 9 | return Promise.map(Object.keys(allData.moduleMap), function( 10 | moduleName 11 | ) { 12 | return fs 13 | .readFile(allData.moduleMap[moduleName]) 14 | .then(function(data) { 15 | return [moduleName, data.toString()]; 16 | }); 17 | }).then(function(sourceDataList) { 18 | var sourceData = {}; 19 | sourceDataList.forEach(function(entry) { 20 | sourceData[entry[0]] = entry[1]; 21 | }); 22 | 23 | var app = emitter.Elm.Analyzer.init({ flags: { version: packageInfo.version}}); 24 | 25 | app.ports.receive.send({ 26 | coverage: allData.coverageData, 27 | files: sourceData 28 | }); 29 | 30 | app.ports.emit.subscribe(function(report) { 31 | if (report.type && report.type === "error") { 32 | reject(report.message); 33 | } else { 34 | fs 35 | .writeFile( 36 | path.join(".coverage", "coverage.html"), 37 | report 38 | ) 39 | .then(resolve); 40 | } 41 | }); 42 | }); 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /lib/cliArgs.js: -------------------------------------------------------------------------------- 1 | var yargs = require("yargs"); 2 | 3 | module.exports = config => 4 | yargs 5 | .command({ 6 | command: "$0 [path]", 7 | desc: "Run tests and generate code coverage", 8 | builder: compose(runnerOptions, globalOptions), 9 | handler: config.run 10 | }) 11 | .command({ 12 | command: "generate [path]", 13 | desc: "Generate a report from previously capture data.", 14 | builder: globalOptions, 15 | handler: config.generateOnly 16 | }); 17 | 18 | function compose() { 19 | var fns = Array.from(arguments); 20 | return arg => fns.reduce((acc, f) => f(acc), arg); 21 | } 22 | 23 | var runnerOptions = yargs => 24 | yargs 25 | .option("elm-test", { 26 | describe: "Path to the elm-test executable", 27 | default: "elm-test", 28 | type: "string" 29 | }) 30 | .option("force", { 31 | describe: 32 | "Forcefully continue, even when some files can't be instrumented and/or tests fail", 33 | alias: "f", 34 | default: false, 35 | type: "boolean" 36 | }) 37 | .option("silent", { 38 | describe: "Suppress `elm-test` output", 39 | alias: "s", 40 | default: false, 41 | type: "boolean" 42 | }) 43 | .option("tests", { 44 | describe: "Path to your tests. Will be passed along to `elm-test`.", 45 | alias: "t", 46 | default: "tests/", 47 | type: "string" 48 | }) 49 | .epilog("Thanks <3"); 50 | 51 | var globalOptions = yargs => 52 | yargs 53 | .positional("path", { 54 | describe: 55 | "Where are your sources located? This path will be " + 56 | "scanned (recursively) and files instrumented.", 57 | type: "string", 58 | default: "src/" 59 | }) 60 | .option("verbose", { 61 | describe: "Print debug info.", 62 | alias: "v", 63 | default: false, 64 | type: "boolean" 65 | }) 66 | .option("open", { 67 | describe: "Open the report", 68 | alias: "o", 69 | default: false, 70 | type: "boolean" 71 | }) 72 | .option("report", { 73 | describe: "Type of report to generate", 74 | alias: "r", 75 | default: "human", 76 | choices: ["human", "json", "codecov"] 77 | }); 78 | -------------------------------------------------------------------------------- /lib/codeCov.js: -------------------------------------------------------------------------------- 1 | var fs = require("fs-extra"), 2 | path = require("path"); 3 | 4 | module.exports = function(coverage) { 5 | return fs.writeJson(path.join(".coverage", "codecov.json"), coverage); 6 | }; 7 | -------------------------------------------------------------------------------- /lib/runner.js: -------------------------------------------------------------------------------- 1 | var Promise = require("bluebird"), 2 | find = require("find"), 3 | analyzer = require("./analyze"), 4 | _ = require("lodash"), 5 | fs = require("fs-extra"), 6 | tmp = Promise.promisifyAll(require("tmp")), 7 | spawn = require("cross-spawn"), 8 | path = require("path"), 9 | touch = require("touch"), 10 | moment = require("moment"), 11 | opn = require("opn"), 12 | aggregate = require("./aggregate"), 13 | codeCov = require("./codeCov"), 14 | summarize = require("./summarize"); 15 | 16 | module.exports.run = function(args) { 17 | var log = createLogger(args); 18 | Promise.resolve() 19 | .then(prepare()) 20 | .then(instrumentSources(log, args)) 21 | .then(setupTests(log, args)) 22 | .then(runTests(log, args)) 23 | .then(generateReport(log, args)) 24 | .then(finishUp(log, args)) 25 | .catch(handleError(log)); 26 | }; 27 | 28 | module.exports.generateOnly = function(args) { 29 | var log = createLogger(args); 30 | Promise.resolve() 31 | .then(generateReport(log, args)) 32 | .then(finishUp(log, args)) 33 | .catch(handleError(log)); 34 | }; 35 | 36 | var elmInstrument = path.join(__dirname, "..", "bin", "elm-instrument"); 37 | var coverageDir = path.join(".coverage", "instrumented"); 38 | var fakeElmBinary = path.resolve(path.join(__dirname, "..", "bin", "fake-elm")); 39 | 40 | function createLogger(args) { 41 | var isJsonOutput = args.report === "json"; 42 | var logger = function(op) { 43 | return function(event, msg) { 44 | var now = "[" + moment().format("hh:mm:ss.SS") + "] "; 45 | 46 | if (isJsonOutput) { 47 | var message = { 48 | event: event, 49 | message: msg, 50 | ts: moment() 51 | }; 52 | 53 | op(JSON.stringify(message, null, 0)); 54 | } else { 55 | op(now + msg); 56 | } 57 | }; 58 | }; 59 | return { 60 | debug: args.verbose ? logger(console.log) : function() {}, 61 | info: logger(console.log), 62 | warn: logger(isJsonOutput ? console.log : console.error), 63 | error: logger(isJsonOutput ? console.log : console.error) 64 | }; 65 | } 66 | 67 | var setupTests = (log, args) => () => { 68 | var tmpElmJson = path.join(coverageDir, "elm.json"); 69 | return fs 70 | .readJson("elm.json") 71 | .then(function(elmPackage) { 72 | log.debug( 73 | "modifyingTests", 74 | "Generating elm.json for coverage at " + tmpElmJson + "..." 75 | ); 76 | var covSrc = path.resolve(path.join(coverageDir, args.path)); 77 | var originalPath = path.resolve(args.path); 78 | 79 | elmPackage["name"] = "author/project"; 80 | 81 | return elmPackage; 82 | }) 83 | .then(function(elmPackage) { 84 | log.debug("writeTestElmJson", "writing elm.json"); 85 | return fs.writeJson(tmpElmJson, elmPackage); 86 | }) 87 | .then(function() { 88 | var generatedTestsDir = path.join(coverageDir, "tests"); 89 | log.debug( 90 | "copyTests", 91 | "Copying tests from " + args.tests + " to " + generatedTestsDir 92 | ); 93 | return fs.copy(args.tests, generatedTestsDir); 94 | }) 95 | .then(function() { 96 | return fs.copyFile( 97 | path.join(__dirname, "..", "kernel-src", "Coverage.elm"), 98 | path.join(coverageDir, args.path, "Coverage.elm") 99 | ); 100 | }) 101 | .then(function() { 102 | log.debug("testModificationComplete", "Setup complete"); 103 | }); 104 | }; 105 | 106 | var runTests = (log, args) => () => { 107 | log.info("testRunInit", "Running tests..."); 108 | return new Promise(function(resolve, reject) { 109 | log.debug( 110 | "testRun", 111 | "spawning " + 112 | args["elm-test"] + 113 | " --compiler " + 114 | fakeElmBinary + 115 | " " + 116 | args.tests 117 | ); 118 | var process = spawn( 119 | args["elm-test"], 120 | ["--compiler", fakeElmBinary, args.tests] 121 | .concat(args._) 122 | .concat(args.report === "json" ? ["--report", "json"] : []), 123 | { 124 | // run elm-test in the instrumented files dir 125 | cwd: coverageDir, 126 | stdio: ["ignore", args.silent ? "ignore" : "inherit", "pipe"] 127 | } 128 | ); 129 | 130 | var errStream = ""; 131 | process.stderr.on("data", function(d) { 132 | errStream += d; 133 | }); 134 | 135 | process.on("exit", function(exitCode) { 136 | if (exitCode === 0) { 137 | log.debug("testRunComplete", "Ran tests!"); 138 | resolve(); 139 | } else if (args.force) { 140 | log.info( 141 | "testFailure", 142 | "Some tests failed. `--force` passed so continuing." 143 | ); 144 | resolve(); 145 | } else { 146 | log.error("testFailure", "Ruh roh, tests failed."); 147 | reject(new Error(errStream)); 148 | } 149 | }); 150 | }); 151 | }; 152 | 153 | var prepare = () => () => { 154 | return new Promise(function(resolve, reject) { 155 | return fs 156 | .remove(".coverage") 157 | .then(function() { 158 | return fs.mkdirp(coverageDir); 159 | }) 160 | .then(resolve); 161 | }); 162 | }; 163 | 164 | var allSources = args => { 165 | return new Promise(function(resolve, reject) { 166 | find.file(/\.(elm|js)$/, args.path, resolve); 167 | }); 168 | }; 169 | 170 | var instrumentSources = (log, args) => () => { 171 | return allSources(args) 172 | .then(function(files) { 173 | return Promise.map( 174 | files.filter(function(file) { 175 | return !( 176 | file.includes("elm-stuff") || 177 | file.includes(args.tests) || 178 | file.includes(".coverage") 179 | ); 180 | }), 181 | function(file) { 182 | return fs.copy(file, path.join(coverageDir, file)); 183 | } 184 | ); 185 | }) 186 | .then(function() { 187 | log.info("instrumenting", "Instrumenting sources..."); 188 | 189 | return new Promise(function(resolve, reject) { 190 | var process = spawn(elmInstrument, [coverageDir]); 191 | var err = ""; 192 | 193 | process.stderr.on("data", function(data) { 194 | err += data; 195 | }); 196 | process.on("error", function(code) { 197 | log.error("instrumenting", "got error"); 198 | reject(err); 199 | }); 200 | 201 | process.on("exit", function(code) { 202 | log.debug("instrumenting", "finished instrumenting" + code); 203 | if (code === 0) { 204 | resolve(); 205 | } else { 206 | reject(err); 207 | } 208 | }); 209 | }); 210 | }); 211 | }; 212 | 213 | var generateReport = (log, args) => () => { 214 | log.debug("aggregating", "Aggregating info"); 215 | return aggregate(args.path) 216 | .then(function(data) { 217 | if (args.report != "json" && !args.silent) { 218 | summarize.printSummary(data); 219 | } 220 | return data; 221 | }) 222 | .then(function(data) { 223 | switch (args.report) { 224 | case "json": 225 | data["event"] = "coverage"; 226 | console.log(JSON.stringify(data, null, 0)); 227 | return Promise.resolve(); 228 | case "human": 229 | log.info("generating", "Generating report..."); 230 | return analyzer(args.path, data); 231 | case "codecov": 232 | log.info( 233 | "generating", 234 | "Writing code coverage to " + 235 | path.join(".coverage", "codecov.json") 236 | ); 237 | return codeCov(data); 238 | } 239 | }); 240 | }; 241 | 242 | var finishUp = (log, args) => () => { 243 | return Promise.map(allSources(args), function(file) { 244 | return touch(file); 245 | }).then(function() { 246 | if (args.report !== "human") { 247 | // Do nothing! 248 | } else if (args.open) { 249 | log.info("complete", "All done! Opening .coverage/coverage.html"); 250 | opn(".coverage/coverage.html", { wait: false }); 251 | } else { 252 | log.info( 253 | "complete", 254 | "All done! Your coverage is waiting for you in .coverage/coverage.html" 255 | ); 256 | } 257 | return Promise.resolve(); 258 | }); 259 | }; 260 | 261 | var handleError = log => e => { 262 | log.error("handleError", "Something went wrong: \n" + e.toString()); 263 | process.exit(1); 264 | }; 265 | -------------------------------------------------------------------------------- /lib/summarize.js: -------------------------------------------------------------------------------- 1 | var { table } = require("table"); 2 | 3 | var emptyRow = name => { 4 | var emptyCoverage = { covered: 0, count: 0 }; 5 | return { 6 | moduleName: name, 7 | declaration: emptyCoverage, 8 | letDeclaration: emptyCoverage, 9 | lambdaBody: emptyCoverage, 10 | caseBranch: emptyCoverage, 11 | ifElseBranch: emptyCoverage 12 | }; 13 | }; 14 | 15 | var summarize = (moduleName, coverage) => 16 | coverage.reduce(function(acc, item) { 17 | acc[item.type] = { 18 | covered: item.count 19 | ? acc[item.type].covered + 1 20 | : acc[item.type].covered, 21 | count: acc[item.type].count + 1 22 | }; 23 | return acc; 24 | }, emptyRow(moduleName)); 25 | 26 | var toCount = ({ covered, count }) => { 27 | if (count > 0) { 28 | return ( 29 | covered + 30 | "/" + 31 | count + 32 | " (" + 33 | Math.round(covered * 100 / count) + 34 | "%)" 35 | ); 36 | } else { 37 | return "n/a"; 38 | } 39 | }; 40 | 41 | var toLine = row => [ 42 | row.moduleName, 43 | toCount(row.declaration), 44 | toCount(row.letDeclaration), 45 | toCount(row.lambdaBody), 46 | toCount({ 47 | covered: row.caseBranch.covered + row.ifElseBranch.covered, 48 | count: row.caseBranch.count + row.ifElseBranch.count 49 | }) 50 | ]; 51 | 52 | module.exports.printSummary = function(data) { 53 | function sum(name, obj1, obj2) { 54 | return { 55 | count: obj1[name].count + obj2[name].count, 56 | covered: obj1[name].covered + obj2[name].covered 57 | }; 58 | } 59 | 60 | var emptyCoverage = { covered: 0, count: 0 }; 61 | 62 | var summary = Object.keys(data.coverageData) 63 | .map(function(key) { 64 | return summarize(key, data.coverageData[key]); 65 | }) 66 | .reduce( 67 | function(acc, row) { 68 | acc.lines.push(toLine(row)); 69 | acc.total = { 70 | moduleName: acc.total.moduleName, 71 | declaration: sum("declaration", acc.total, row), 72 | letDeclaration: sum("letDeclaration", acc.total, row), 73 | lambdaBody: sum("lambdaBody", acc.total, row), 74 | caseBranch: sum("caseBranch", acc.total, row), 75 | ifElseBranch: sum("ifElseBranch", acc.total, row) 76 | }; 77 | return acc; 78 | }, 79 | { 80 | lines: [ 81 | ["Module", "decls", "let decls", "lambdas", "branches"] 82 | ], 83 | total: emptyRow("total") 84 | } 85 | ); 86 | 87 | summary.lines.push(toLine(summary.total)); 88 | 89 | console.log(table(summary.lines)); 90 | }; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elm-coverage", 3 | "version": "0.4.1", 4 | "license": "BSD-3-Clause", 5 | "homepage": "https://github.com/zwilias/elm-coverage#readme", 6 | "repository": "zwilias/elm-coverage", 7 | "main": "install.js", 8 | "devDependencies": { 9 | "chai": "^4.2.0", 10 | "chai-json-schema-ajv": "^2.0.1", 11 | "chai-match-pattern": "^1.1.0", 12 | "mocha": "^9.1.3", 13 | "shelljs": "^0.8.5" 14 | }, 15 | "dependencies": { 16 | "binwrap": "^0.2.2", 17 | "bluebird": "^3.7.2", 18 | "cross-spawn": "^5.1.0", 19 | "elm": "^0.19.1-5", 20 | "elm-test": "^0.19.1-revision7", 21 | "find": "^0.2.9", 22 | "fs-extra": "^4.0.3", 23 | "lodash": "^4.17.15", 24 | "moment": "^2.24.0", 25 | "opn": "^5.5.0", 26 | "request": "^2.88.0", 27 | "table": "^6.8.0", 28 | "tmp": "0.0.33", 29 | "touch": "^3.1.0", 30 | "upgrade": "^1.1.0", 31 | "yargs": "^15.0.2" 32 | }, 33 | "bin": { 34 | "elm-coverage": "bin/elm-coverage", 35 | "elm-instrument": "bin/elm-instrument" 36 | }, 37 | "files": [ 38 | "bin/", 39 | "lib/", 40 | "install.js", 41 | "kernel-src" 42 | ], 43 | "scripts": { 44 | "make": "elm make --optimize src/Analyzer.elm --output lib/analyzer.js", 45 | "prepublishOnly": "npm run make && npm run test", 46 | "test": "binwrap-test && npm run test:unit", 47 | "test:unit": "mocha tests", 48 | "install": "binwrap-install" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Analyzer.elm: -------------------------------------------------------------------------------- 1 | module Analyzer exposing (main) 2 | 3 | import Coverage 4 | import Dict exposing (Dict) 5 | import Html.String as Html exposing (Html) 6 | import Html.String.Attributes as Attr 7 | import Json.Decode as Decode exposing (Decoder) 8 | import Json.Encode as Encode 9 | import Markup 10 | import Overview 11 | import Service exposing (Service) 12 | import Styles 13 | import Util 14 | 15 | 16 | main : Service Model 17 | main = 18 | Service.create 19 | { handle = \version model -> view version model |> Html.toString 0 20 | , emit = Encode.string 21 | , receive = decodeModel 22 | } 23 | 24 | 25 | decodeModel : Decoder Model 26 | decodeModel = 27 | Decode.map2 Model 28 | (Decode.field "files" 29 | (Decode.keyValuePairs Decode.string |> Decode.map Dict.fromList) 30 | ) 31 | (Decode.field "coverage" Coverage.regionsDecoder) 32 | 33 | 34 | type alias Model = 35 | { inputs : Dict String String 36 | , moduleMap : Coverage.Map 37 | } 38 | 39 | 40 | view : Service.Version -> Model -> Html msg 41 | view version model = 42 | model.moduleMap 43 | |> Dict.toList 44 | |> List.filterMap 45 | (\( key, coverageInfo ) -> 46 | Dict.get key model.inputs 47 | |> Maybe.map (Markup.file key coverageInfo) 48 | ) 49 | |> (::) (overview model.moduleMap) 50 | |> Styles.page "Coverage report" styles version 51 | 52 | 53 | overview : Coverage.Map -> Html msg 54 | overview moduleMap = 55 | let 56 | ( rows, totals ) = 57 | moduleMap 58 | |> Dict.toList 59 | |> List.foldr foldFile ( [], Dict.empty ) 60 | in 61 | Html.table [ Attr.class "overview" ] 62 | [ Html.thead [] [ Overview.heading totals ] 63 | , Html.tbody [] rows 64 | , Html.tfoot [] [ Overview.row (Html.text "total") totals ] 65 | ] 66 | 67 | 68 | foldFile : 69 | ( String, List Coverage.AnnotationInfo ) 70 | -> ( List (Html msg), Dict String ( Int, Int ) ) 71 | -> ( List (Html msg), Dict String ( Int, Int ) ) 72 | foldFile ( moduleName, coverageInfo ) ( rows, totals ) = 73 | let 74 | counts : Dict String ( Int, Int ) 75 | counts = 76 | Overview.computeCounts emptyCountDict coverageInfo 77 | 78 | name : Html msg 79 | name = 80 | Html.a 81 | [ Attr.href <| "#" ++ moduleToId moduleName ] 82 | [ Html.text <| 83 | "(" 84 | ++ (String.fromInt <| Coverage.totalComplexity coverageInfo) 85 | ++ ")\u{00A0}" 86 | , Html.code [] [ Html.text moduleName ] 87 | ] 88 | in 89 | ( Overview.row name counts :: rows 90 | , Dict.foldl adjustTotals totals counts 91 | ) 92 | 93 | 94 | adjustTotals : 95 | String 96 | -> ( Int, Int ) 97 | -> Dict String ( Int, Int ) 98 | -> Dict String ( Int, Int ) 99 | adjustTotals coverageType counts = 100 | Dict.update coverageType 101 | (Maybe.map (Util.mapBoth (+) counts) 102 | >> Maybe.withDefault counts 103 | >> Just 104 | ) 105 | 106 | 107 | emptyCountDict : Dict String ( Int, Int ) 108 | emptyCountDict = 109 | [ Coverage.declaration 110 | , Coverage.letDeclaration 111 | , Coverage.lambdaBody 112 | , Coverage.caseBranch 113 | , Coverage.ifElseBranch 114 | ] 115 | |> List.foldl (\k -> Dict.insert k ( 0, 0 )) Dict.empty 116 | 117 | 118 | moduleToId : String -> String 119 | moduleToId = 120 | String.toLower >> String.split "." >> String.join "-" 121 | 122 | 123 | styles : String 124 | styles = 125 | String.concat 126 | [ Styles.general 127 | , Styles.file 128 | , Styles.overview 129 | ] 130 | -------------------------------------------------------------------------------- /src/Coverage.elm: -------------------------------------------------------------------------------- 1 | module Coverage exposing 2 | ( Annotation(..) 3 | , AnnotationInfo 4 | , Complexity 5 | , Index 6 | , Map 7 | , Name 8 | , Position 9 | , Region 10 | , annotationType 11 | , caseBranch 12 | , complexity 13 | , declaration 14 | , fromAnnotation 15 | , ifElseBranch 16 | , index 17 | , lambdaBody 18 | , letDeclaration 19 | , line 20 | , positionToOffset 21 | , regionsDecoder 22 | , totalComplexity 23 | ) 24 | 25 | import Array exposing (Array) 26 | import Dict exposing (Dict) 27 | import Json.Decode as Decode exposing (Decoder) 28 | 29 | 30 | 31 | -- Types 32 | 33 | 34 | type alias Position = 35 | ( Int, Int ) 36 | 37 | 38 | type alias Region = 39 | { from : Position 40 | , to : Position 41 | } 42 | 43 | 44 | type alias Map = 45 | Dict String (List AnnotationInfo) 46 | 47 | 48 | type alias Name = 49 | String 50 | 51 | 52 | type alias Complexity = 53 | Int 54 | 55 | 56 | type Annotation 57 | = Declaration Name Complexity 58 | | LetDeclaration Complexity 59 | | LambdaBody Complexity 60 | | CaseBranch 61 | | IfElseBranch 62 | 63 | 64 | type alias AnnotationInfo = 65 | ( Region, Annotation, Int ) 66 | 67 | 68 | 69 | -- Extracting information from types 70 | 71 | 72 | complexity : Annotation -> Maybe Int 73 | complexity annotation = 74 | case annotation of 75 | Declaration _ c -> 76 | Just c 77 | 78 | LetDeclaration c -> 79 | Just c 80 | 81 | LambdaBody c -> 82 | Just c 83 | 84 | _ -> 85 | Nothing 86 | 87 | 88 | totalComplexity : List AnnotationInfo -> Complexity 89 | totalComplexity annotations = 90 | let 91 | allComplexities : List Complexity 92 | allComplexities = 93 | List.filterMap 94 | (\( _, annotation, _ ) -> 95 | case annotation of 96 | Declaration _ c -> 97 | Just c 98 | 99 | _ -> 100 | Nothing 101 | ) 102 | annotations 103 | in 104 | List.sum allComplexities - List.length allComplexities + 1 105 | 106 | 107 | line : Position -> Int 108 | line = 109 | Tuple.first 110 | 111 | 112 | column : Position -> Int 113 | column = 114 | Tuple.second 115 | 116 | 117 | position : Decoder Position 118 | position = 119 | Decode.map2 (\a b -> ( a, b )) 120 | (Decode.field "line" Decode.int) 121 | (Decode.field "column" Decode.int) 122 | 123 | 124 | regionDecoder : Decoder Region 125 | regionDecoder = 126 | Decode.map2 Region 127 | (Decode.field "from" position) 128 | (Decode.field "to" position) 129 | 130 | 131 | annotationInfoDecoder : Decoder AnnotationInfo 132 | annotationInfoDecoder = 133 | Decode.map3 (\a b c -> ( a, b, c )) 134 | regionDecoder 135 | annotationDecoder 136 | evaluationCountDecoder 137 | 138 | 139 | evaluationCountDecoder : Decoder Int 140 | evaluationCountDecoder = 141 | Decode.oneOf [ Decode.field "count" Decode.int, Decode.succeed 0 ] 142 | 143 | 144 | typeIs : String -> Decoder a -> Decoder a 145 | typeIs expectedValue decoder = 146 | Decode.field "type" Decode.string 147 | |> Decode.andThen 148 | (\actual -> 149 | if actual == expectedValue then 150 | decoder 151 | 152 | else 153 | Decode.fail "not this one" 154 | ) 155 | 156 | 157 | annotationDecoder : Decoder Annotation 158 | annotationDecoder = 159 | Decode.oneOf 160 | [ typeIs declaration declarationDecoder 161 | , typeIs letDeclaration (withComplexity LetDeclaration) 162 | , typeIs lambdaBody (withComplexity LambdaBody) 163 | , typeIs caseBranch (Decode.succeed CaseBranch) 164 | , typeIs ifElseBranch (Decode.succeed IfElseBranch) 165 | ] 166 | 167 | 168 | withComplexity : (Complexity -> a) -> Decoder a 169 | withComplexity tag = 170 | Decode.map tag (Decode.field "complexity" Decode.int) 171 | 172 | 173 | declarationDecoder : Decoder Annotation 174 | declarationDecoder = 175 | Decode.map2 Declaration 176 | (Decode.field "name" Decode.string) 177 | (Decode.field "complexity" Decode.int) 178 | 179 | 180 | regionsDecoder : Decoder Map 181 | regionsDecoder = 182 | Decode.keyValuePairs (Decode.list annotationInfoDecoder) 183 | |> Decode.map Dict.fromList 184 | 185 | 186 | type alias Index = 187 | Array Int 188 | 189 | 190 | index : String -> Index 191 | index input = 192 | input 193 | |> String.lines 194 | |> List.foldl 195 | (\singleLine ( acc, sum ) -> 196 | ( Array.push sum acc 197 | , sum + String.length singleLine + 1 198 | ) 199 | ) 200 | ( Array.empty, 0 ) 201 | |> Tuple.first 202 | 203 | 204 | positionToOffset : Position -> Index -> Maybe Int 205 | positionToOffset pos idx = 206 | Array.get (line pos - 1) idx 207 | |> Maybe.map (\offSet -> offSet + column pos - 1) 208 | 209 | 210 | declaration : String 211 | declaration = 212 | "declaration" 213 | 214 | 215 | letDeclaration : String 216 | letDeclaration = 217 | "letDeclaration" 218 | 219 | 220 | lambdaBody : String 221 | lambdaBody = 222 | "lambdaBody" 223 | 224 | 225 | caseBranch : String 226 | caseBranch = 227 | "caseBranch" 228 | 229 | 230 | ifElseBranch : String 231 | ifElseBranch = 232 | "ifElseBranch" 233 | 234 | 235 | annotationType : Annotation -> String 236 | annotationType annotation = 237 | case annotation of 238 | Declaration _ _ -> 239 | declaration 240 | 241 | LetDeclaration _ -> 242 | letDeclaration 243 | 244 | LambdaBody _ -> 245 | lambdaBody 246 | 247 | CaseBranch -> 248 | caseBranch 249 | 250 | IfElseBranch -> 251 | ifElseBranch 252 | 253 | 254 | fromAnnotation : 255 | { declaration : a 256 | , letDeclaration : a 257 | , lambdaBody : a 258 | , caseBranch : a 259 | , ifElseBranch : a 260 | , default : a 261 | } 262 | -> String 263 | -> a 264 | fromAnnotation settings annotation = 265 | case annotation of 266 | "declaration" -> 267 | settings.declaration 268 | 269 | "letDeclaration" -> 270 | settings.letDeclaration 271 | 272 | "lambdaBody" -> 273 | settings.lambdaBody 274 | 275 | "caseBranch" -> 276 | settings.caseBranch 277 | 278 | "ifElseBranch" -> 279 | settings.ifElseBranch 280 | 281 | _ -> 282 | settings.default 283 | -------------------------------------------------------------------------------- /src/Html/String/Extra.elm: -------------------------------------------------------------------------------- 1 | module Html.String.Extra exposing (data, head, html, style) 2 | 3 | import Html.String as Html exposing (Html) 4 | import Html.String.Attributes as Attr 5 | 6 | 7 | html : List (Html.Attribute msg) -> List (Html msg) -> Html msg 8 | html = 9 | Html.node "html" 10 | 11 | 12 | head : List (Html.Attribute msg) -> List (Html msg) -> Html msg 13 | head = 14 | Html.node "head" 15 | 16 | 17 | style : List (Html.Attribute msg) -> List (Html msg) -> Html msg 18 | style = 19 | Html.node "style" 20 | 21 | 22 | data : String -> String -> Html.Attribute msg 23 | data key value = 24 | Attr.attribute ("data-" ++ key) value 25 | -------------------------------------------------------------------------------- /src/Markup.elm: -------------------------------------------------------------------------------- 1 | module Markup exposing (file) 2 | 3 | import Coverage 4 | import Dict exposing (Dict) 5 | import Html.String as Html exposing (Html) 6 | import Html.String.Attributes as Attr 7 | import Overview 8 | import Source 9 | import Util 10 | 11 | 12 | file : String -> List Coverage.AnnotationInfo -> String -> Html msg 13 | file moduleName coverageInfo source = 14 | let 15 | rendered = 16 | Source.render source coverageInfo 17 | |> render 18 | |> foldRendered (moduleToId moduleName) 19 | in 20 | Html.div [ Attr.class "file" ] 21 | [ Html.h2 [ Attr.id <| moduleToId moduleName ] 22 | [ Html.text "Module: " 23 | , Html.code [] [ Html.text moduleName ] 24 | , Html.a [ Attr.class "toTop", Attr.href "#top" ] [ Html.text "▲" ] 25 | ] 26 | , listDeclarations (moduleToId moduleName) coverageInfo 27 | , Html.p [ Attr.class "legend" ] 28 | [ Html.text "Declarations sorted by cyclomatic complexity" ] 29 | , Html.div [ Attr.class "coverage" ] 30 | [ Html.div [ Attr.class "lines" ] rendered.lines 31 | , Html.div [ Attr.class "source" ] rendered.source 32 | ] 33 | ] 34 | 35 | 36 | moduleToId : String -> String 37 | moduleToId = 38 | String.toLower >> String.split "." >> String.join "-" 39 | 40 | 41 | listDeclarations : String -> List Coverage.AnnotationInfo -> Html msg 42 | listDeclarations moduleId annotations = 43 | let 44 | ( rows, totals, complexities ) = 45 | topLevelDeclarationInfo [] [] annotations 46 | |> List.sortBy .complexity 47 | |> List.foldl (foldDeclarations moduleId) ( [], Dict.empty, [] ) 48 | in 49 | Html.table [ Attr.class "overview" ] 50 | [ Html.thead [] [ Overview.heading totals ] 51 | , Html.tbody [] rows 52 | , Html.tfoot [] 53 | [ Overview.row 54 | (Html.text <| 55 | "(" 56 | ++ String.fromInt (Coverage.totalComplexity annotations) 57 | ++ ") total" 58 | ) 59 | totals 60 | ] 61 | ] 62 | 63 | 64 | type alias TopLevelDecl = 65 | { name : Coverage.Name 66 | , complexity : Coverage.Complexity 67 | , startLine : Int 68 | , children : List Coverage.AnnotationInfo 69 | } 70 | 71 | 72 | topLevelDeclarationInfo : 73 | List TopLevelDecl 74 | -> List Coverage.AnnotationInfo 75 | -> List Coverage.AnnotationInfo 76 | -> List TopLevelDecl 77 | topLevelDeclarationInfo acc children annotations = 78 | case annotations of 79 | [] -> 80 | List.reverse acc 81 | 82 | ( { from }, Coverage.Declaration name complexity, _ ) :: rest -> 83 | let 84 | decl : TopLevelDecl 85 | decl = 86 | { name = name 87 | , complexity = complexity 88 | , startLine = Coverage.line from 89 | , children = children 90 | } 91 | in 92 | topLevelDeclarationInfo (decl :: acc) [] rest 93 | 94 | c :: rest -> 95 | topLevelDeclarationInfo acc (c :: children) rest 96 | 97 | 98 | foldDeclarations : 99 | String 100 | -> TopLevelDecl 101 | -> ( List (Html msg), Dict String ( Int, Int ), List Coverage.Complexity ) 102 | -> ( List (Html msg), Dict String ( Int, Int ), List Coverage.Complexity ) 103 | foldDeclarations moduleId declaration ( rows, totals, totalComplexity ) = 104 | let 105 | counts : Dict String ( Int, Int ) 106 | counts = 107 | Overview.computeCounts emptyCountDict declaration.children 108 | 109 | adjustTotals : 110 | String 111 | -> ( Int, Int ) 112 | -> Dict String ( Int, Int ) 113 | -> Dict String ( Int, Int ) 114 | adjustTotals coverageType innerCounts = 115 | Dict.update coverageType 116 | (Maybe.map (Util.mapBoth (+) innerCounts) 117 | >> Maybe.withDefault innerCounts 118 | >> Just 119 | ) 120 | 121 | adjustedTotals : Dict String ( Int, Int ) 122 | adjustedTotals = 123 | counts 124 | |> Dict.foldl adjustTotals totals 125 | 126 | declarationId : String 127 | declarationId = 128 | "#" ++ moduleId ++ "_" ++ String.fromInt declaration.startLine 129 | 130 | formattedName = 131 | Html.a 132 | [ Attr.href declarationId ] 133 | [ Html.text <| "(" ++ String.fromInt declaration.complexity ++ ")\u{00A0}" 134 | , Html.code [] [ Html.text declaration.name ] 135 | ] 136 | in 137 | ( Overview.row formattedName counts :: rows 138 | , adjustedTotals 139 | , declaration.complexity :: totalComplexity 140 | ) 141 | 142 | 143 | emptyCountDict : Dict String ( Int, Int ) 144 | emptyCountDict = 145 | [ Coverage.letDeclaration 146 | , Coverage.lambdaBody 147 | , Coverage.caseBranch 148 | , Coverage.ifElseBranch 149 | ] 150 | |> List.foldl (\k -> Dict.insert k ( 0, 0 )) Dict.empty 151 | 152 | 153 | type alias Rendered msg = 154 | { lines : List (Html msg), source : List (Html msg) } 155 | 156 | 157 | foldRendered : String -> List (Line msg) -> Rendered msg 158 | foldRendered coverageId xs = 159 | xs 160 | |> Util.indexedFoldr 161 | (\idx (Line info content) ( lines, sources ) -> 162 | ( showLine coverageId (idx + 1) info :: lines 163 | , content :: sources 164 | ) 165 | ) 166 | ( [], [] ) 167 | |> Tuple.mapSecond (Util.intercalate linebreak) 168 | |> (\( a, b ) -> Rendered a b) 169 | 170 | 171 | showLine : String -> Int -> List Source.MarkerInfo -> Html msg 172 | showLine coverageId lineNr info = 173 | let 174 | lineId : String 175 | lineId = 176 | coverageId ++ "_" ++ String.fromInt lineNr 177 | in 178 | Html.a [ Attr.href <| "#" ++ lineId, Attr.id lineId, Attr.class "line" ] 179 | [ Html.div [] 180 | (Util.rFilterMap 181 | (.annotation >> Coverage.complexity >> Maybe.map indicator) 182 | info 183 | ++ [ Html.text <| String.fromInt lineNr ] 184 | ) 185 | ] 186 | 187 | 188 | indicator : Coverage.Complexity -> Html msg 189 | indicator complexity = 190 | let 191 | intensity : Float 192 | intensity = 193 | (toFloat (clamp 0 50 complexity) / 50) 194 | |> sqrt 195 | in 196 | Html.span 197 | [ Attr.class "indicator" 198 | , Attr.style "opacity" (String.fromFloat intensity ) 199 | , Attr.title <| "Cyclomatic complexity: " ++ String.fromInt complexity 200 | ] 201 | [ Html.text " " ] 202 | 203 | 204 | linebreak : Html msg 205 | linebreak = 206 | Html.br [] [] 207 | 208 | 209 | render : List Source.Content -> List (Line msg) 210 | render content = 211 | let 212 | initialAcc : ToHtmlAcc msg 213 | initialAcc = 214 | { lineSoFar = Line [] [] 215 | , stack = [] 216 | , lines = [] 217 | } 218 | 219 | finalize : ToHtmlAcc msg -> List (Line msg) 220 | finalize { lineSoFar, lines } = 221 | lineSoFar :: lines 222 | in 223 | List.foldl contentToHtml initialAcc content 224 | |> finalize 225 | 226 | 227 | type alias ToHtmlAcc msg = 228 | { lineSoFar : Line msg 229 | , stack : List Source.MarkerInfo 230 | , lines : List (Line msg) 231 | } 232 | 233 | 234 | type Line msg 235 | = Line (List Source.MarkerInfo) (List (Html msg)) 236 | 237 | 238 | contentToHtml : Source.Content -> ToHtmlAcc msg -> ToHtmlAcc msg 239 | contentToHtml content acc = 240 | case content of 241 | Source.Plain parts -> 242 | partsToHtml parts acc 243 | 244 | Source.Content marker parts -> 245 | List.foldl contentToHtml (pushStack marker acc) parts 246 | |> popStack 247 | 248 | 249 | pushStack : Source.MarkerInfo -> ToHtmlAcc msg -> ToHtmlAcc msg 250 | pushStack marker acc = 251 | { acc 252 | | stack = marker :: acc.stack 253 | , lineSoFar = addMarkerToLine marker acc.lineSoFar 254 | } 255 | 256 | 257 | popStack : ToHtmlAcc msg -> ToHtmlAcc msg 258 | popStack acc = 259 | case acc.stack of 260 | [] -> 261 | acc 262 | 263 | _ :: rest -> 264 | { acc | stack = rest } 265 | 266 | 267 | partsToHtml : List Source.Part -> ToHtmlAcc msg -> ToHtmlAcc msg 268 | partsToHtml parts acc = 269 | case parts of 270 | [] -> 271 | acc 272 | 273 | -- Empty part, just skip it 274 | (Source.Part "") :: rest -> 275 | partsToHtml rest acc 276 | 277 | (Source.Part s) :: rest -> 278 | tagAndAdd s acc 279 | |> partsToHtml rest 280 | 281 | Source.LineBreak :: rest -> 282 | newLine acc 283 | |> partsToHtml rest 284 | 285 | -- Empty part, useless markup to include, so skip it! 286 | (Source.Indented 0 "") :: rest -> 287 | partsToHtml rest acc 288 | 289 | (Source.Indented indent content) :: rest -> 290 | acc 291 | |> tagAndAdd content 292 | |> add (whitespace indent) 293 | |> partsToHtml rest 294 | 295 | 296 | add : Html msg -> ToHtmlAcc msg -> ToHtmlAcc msg 297 | add content acc = 298 | { acc | lineSoFar = addToLine content acc.lineSoFar } 299 | 300 | 301 | addMarkerToLine : Source.MarkerInfo -> Line msg -> Line msg 302 | addMarkerToLine marker (Line info content) = 303 | Line (marker :: info) content 304 | 305 | 306 | tagAndAdd : String -> ToHtmlAcc msg -> ToHtmlAcc msg 307 | tagAndAdd content acc = 308 | add (tagWith acc.stack content identity) acc 309 | 310 | 311 | addToLine : Html msg -> Line msg -> Line msg 312 | addToLine x (Line info xs) = 313 | Line info (x :: xs) 314 | 315 | 316 | {-| We need to use this rather than inlining `wrapper << tagger` to prevent 317 | a nasty variable shadowing bug. 318 | -} 319 | wrapTagger : Source.MarkerInfo -> (Html msg -> Html msg) -> Html msg -> Html msg 320 | wrapTagger { count, annotation } tagger content = 321 | wrapper count annotation <| tagger content 322 | 323 | 324 | tagWith : List Source.MarkerInfo -> String -> (Html msg -> Html msg) -> Html msg 325 | tagWith markers s tagger = 326 | case markers of 327 | [] -> 328 | tagger <| Html.text s 329 | 330 | marker :: rest -> 331 | tagWith rest s (wrapTagger marker tagger) 332 | 333 | 334 | newLine : ToHtmlAcc msg -> ToHtmlAcc msg 335 | newLine acc = 336 | { acc | lineSoFar = Line acc.stack [], lines = acc.lineSoFar :: acc.lines } 337 | 338 | 339 | whitespace : Int -> Html msg 340 | whitespace indent = 341 | Html.text <| String.repeat indent " " 342 | 343 | 344 | wrapper : Int -> Coverage.Annotation -> Html msg -> Html msg 345 | wrapper count annotation content = 346 | let 347 | withComplexity : Coverage.Complexity -> String 348 | withComplexity complexity = 349 | "Evaluated " 350 | ++ String.fromInt count 351 | ++ " times, complexity " 352 | ++ String.fromInt complexity 353 | ++ "." 354 | 355 | justCount : String 356 | justCount = 357 | "Evaluated " ++ String.fromInt count ++ "times." 358 | 359 | title : String 360 | title = 361 | Coverage.complexity annotation 362 | |> Maybe.map withComplexity 363 | |> Maybe.withDefault justCount 364 | in 365 | Html.span 366 | [ Attr.class <| toClass count 367 | , Attr.title title 368 | ] 369 | [ content ] 370 | 371 | 372 | toClass : Int -> String 373 | toClass cnt = 374 | if cnt == 0 then 375 | "cover uncovered" 376 | 377 | else 378 | "cover covered" 379 | -------------------------------------------------------------------------------- /src/Overview.elm: -------------------------------------------------------------------------------- 1 | module Overview exposing (computeCounts, heading, row) 2 | 3 | import Coverage 4 | import Dict exposing (Dict) 5 | import Html.String as Html exposing (Html) 6 | import Html.String.Attributes as Attr 7 | import Util 8 | 9 | 10 | heading : Dict String a -> Html msg 11 | heading map = 12 | let 13 | makeHead : String -> Html msg 14 | makeHead = 15 | shortHumanCoverageType >> Html.th [] 16 | in 17 | Html.tr [] 18 | (Html.th [] [] :: (Dict.keys map |> List.map makeHead)) 19 | 20 | 21 | shortHumanCoverageType : String -> List (Html msg) 22 | shortHumanCoverageType = 23 | Coverage.fromAnnotation 24 | { caseBranch = 25 | [ Html.code [] [ Html.text "case" ] 26 | , Html.text " branches" 27 | ] 28 | , ifElseBranch = 29 | [ Html.code [] [ Html.text "if/else" ] 30 | , Html.text " branches" 31 | ] 32 | , declaration = [ Html.text "Declarations" ] 33 | , lambdaBody = [ Html.text "Lambdas" ] 34 | , letDeclaration = 35 | [ Html.code [] [ Html.text "let" ] 36 | , Html.text " declarations" 37 | ] 38 | , default = [ Html.text "unknown" ] 39 | } 40 | 41 | 42 | computeCounts : 43 | Dict String ( Int, Int ) 44 | -> List Coverage.AnnotationInfo 45 | -> Dict String ( Int, Int ) 46 | computeCounts emptyCountDict = 47 | List.foldl addCount emptyCountDict 48 | 49 | 50 | addCount : 51 | Coverage.AnnotationInfo 52 | -> Dict String ( Int, Int ) 53 | -> Dict String ( Int, Int ) 54 | addCount ( _, annotation, count ) acc = 55 | Dict.update (Coverage.annotationType annotation) 56 | (Maybe.withDefault ( 0, 0 ) 57 | >> Util.mapBoth (+) ( min count 1, 1 ) 58 | >> Just 59 | ) 60 | acc 61 | 62 | 63 | row : Html msg -> Dict String ( Int, Int ) -> Html msg 64 | row name counts = 65 | Html.tr [] 66 | (Html.th [] [ name ] 67 | :: (Dict.values counts |> List.map showCount) 68 | ) 69 | 70 | 71 | showCount : ( Int, Int ) -> Html msg 72 | showCount ( used, total ) = 73 | if total == 0 then 74 | Html.td [ Attr.class "none" ] 75 | [ Html.text "n/a" ] 76 | 77 | else 78 | Html.td [] 79 | [ Html.div [ Attr.class "wrapper" ] 80 | [ Html.div 81 | [ Attr.class "info" ] 82 | [ Html.text <| 83 | String.fromInt used 84 | ++ "/" 85 | ++ String.fromInt total 86 | ] 87 | , Html.progress 88 | [ Attr.max <| String.fromInt total 89 | , Attr.value <| String.fromInt used 90 | ] 91 | [] 92 | ] 93 | ] 94 | -------------------------------------------------------------------------------- /src/Service.elm: -------------------------------------------------------------------------------- 1 | port module Service exposing (Service, Version, create) 2 | 3 | import Json.Decode as Decode exposing (Decoder, Value) 4 | import Json.Encode as Encode 5 | 6 | 7 | type alias Version = 8 | String 9 | 10 | 11 | port emit : Value -> Cmd msg 12 | 13 | 14 | port receive : (Value -> msg) -> Sub msg 15 | 16 | 17 | type Msg input 18 | = Receive input 19 | | Bad String 20 | 21 | 22 | type alias Service input = 23 | Program Value Version (Msg input) 24 | 25 | 26 | create : 27 | { handle : Version -> input -> output 28 | , receive : Decoder input 29 | , emit : output -> Value 30 | } 31 | -> Service input 32 | create settings = 33 | Platform.worker 34 | { init = innerInit 35 | , update = handle settings.handle settings.emit 36 | , subscriptions = subscribe settings.receive 37 | } 38 | 39 | innerInit : Value -> (Version, Cmd msg) 40 | innerInit flags = 41 | (Result.withDefault "1.0.0" (Decode.decodeValue (Decode.field "version" Decode.string) flags), Cmd.none) 42 | 43 | 44 | handle : (Version -> input -> output) -> (output -> Value) -> Msg input -> Version -> ( Version, Cmd msg ) 45 | handle handler encode msg version = 46 | case msg of 47 | Receive input -> 48 | ( version, emit <| encode <| handler version input ) 49 | 50 | Bad val -> 51 | ( version 52 | , emit <| 53 | Encode.object 54 | [ ( "type", Encode.string "error" ) 55 | , ( "message", Encode.string val ) 56 | ] 57 | ) 58 | 59 | 60 | subscribe : Decoder input -> a -> Sub (Msg input) 61 | subscribe decoder _ = 62 | receive 63 | (\data -> 64 | case Decode.decodeValue decoder data of 65 | Ok input -> 66 | Receive input 67 | 68 | Err e -> 69 | Bad <| Decode.errorToString e 70 | ) 71 | -------------------------------------------------------------------------------- /src/Source.elm: -------------------------------------------------------------------------------- 1 | module Source exposing (Content(..), Marker(..), MarkerInfo, Part(..), render) 2 | 3 | import Coverage 4 | import Dict exposing (Dict) 5 | 6 | 7 | type Marker 8 | = Begin MarkerInfo 9 | | End 10 | 11 | 12 | type alias MarkerInfo = 13 | { count : Int 14 | , annotation : Coverage.Annotation 15 | } 16 | 17 | 18 | type alias Acc = 19 | { children : List Content 20 | , stack : List ( MarkerInfo, List Content ) 21 | } 22 | 23 | 24 | type Part 25 | = Part String 26 | | LineBreak 27 | | Indented Int String 28 | 29 | 30 | type Content 31 | = Plain (List Part) 32 | | Content MarkerInfo (List Content) 33 | 34 | 35 | render : String -> List Coverage.AnnotationInfo -> List Content 36 | render source regions = 37 | markupHelper source 0 (toMarkerDict regions source) { children = [], stack = [] } 38 | |> .children 39 | 40 | 41 | stringParts : String -> Content 42 | stringParts string = 43 | case String.lines string of 44 | [] -> 45 | Plain [] 46 | 47 | head :: rest -> 48 | (Part head :: List.map findIndent rest) 49 | |> List.intersperse LineBreak 50 | |> List.reverse 51 | |> Plain 52 | 53 | 54 | findIndent : String -> Part 55 | findIndent string = 56 | let 57 | countIndentLength : Char -> ( Int, Bool ) -> ( Int, Bool ) 58 | countIndentLength c ( spaces, continue ) = 59 | if continue && c == ' ' then 60 | ( spaces + 1, True ) 61 | 62 | else 63 | ( spaces, False ) 64 | 65 | toIndentedString : Int -> Part 66 | toIndentedString spaces = 67 | if String.length string == spaces then 68 | Indented spaces "" 69 | 70 | else if spaces == 0 then 71 | Part string 72 | 73 | else 74 | String.slice spaces (String.length string) string 75 | |> Indented spaces 76 | in 77 | string 78 | |> String.foldl countIndentLength ( 0, True ) 79 | |> Tuple.first 80 | |> toIndentedString 81 | 82 | 83 | markupHelper : String -> Int -> List ( Int, List Marker ) -> Acc -> Acc 84 | markupHelper original offset markers acc = 85 | let 86 | rest : String -> Content 87 | rest input = 88 | input 89 | |> String.slice offset (String.length input) 90 | |> stringParts 91 | 92 | readUntil : Int -> String -> Content 93 | readUntil pos = 94 | String.slice offset pos >> stringParts 95 | in 96 | case markers of 97 | [] -> 98 | { acc | children = rest original :: acc.children } 99 | 100 | ( pos, markerList ) :: otherMarkers -> 101 | { acc | children = readUntil pos original :: acc.children } 102 | |> consumeMarkers markerList 103 | |> markupHelper original pos otherMarkers 104 | 105 | 106 | consumeMarkers : List Marker -> Acc -> Acc 107 | consumeMarkers markers acc = 108 | List.foldl consumeMarker acc markers 109 | 110 | 111 | consumeMarker : Marker -> Acc -> Acc 112 | consumeMarker marker acc = 113 | case marker of 114 | Begin markerInfo -> 115 | { children = [] 116 | , stack = ( markerInfo, acc.children ) :: acc.stack 117 | } 118 | 119 | End -> 120 | case acc.stack of 121 | [] -> 122 | acc 123 | 124 | ( markerInfo, x ) :: xs -> 125 | let 126 | content : Content 127 | content = 128 | Content markerInfo acc.children 129 | in 130 | { children = content :: x 131 | , stack = xs 132 | } 133 | 134 | 135 | toMarkerDict : List Coverage.AnnotationInfo -> String -> List ( Int, List Marker ) 136 | toMarkerDict regions source = 137 | let 138 | offsets = 139 | Coverage.index source 140 | in 141 | List.foldl (addRegion offsets) Dict.empty regions 142 | |> Dict.toList 143 | 144 | 145 | addRegion : 146 | Coverage.Index 147 | -> Coverage.AnnotationInfo 148 | -> Dict Int (List Marker) 149 | -> Dict Int (List Marker) 150 | addRegion offsets ( location, annotation, count ) acc = 151 | Maybe.map2 152 | (\from to -> 153 | acc 154 | |> Dict.update from 155 | (addToListDict (Begin <| MarkerInfo count annotation)) 156 | |> Dict.update to (addToListDict End) 157 | ) 158 | (Coverage.positionToOffset location.from offsets) 159 | (Coverage.positionToOffset location.to offsets) 160 | |> Maybe.withDefault acc 161 | 162 | 163 | addToListDict : a -> Maybe (List a) -> Maybe (List a) 164 | addToListDict a m = 165 | case m of 166 | Nothing -> 167 | Just [ a ] 168 | 169 | Just xs -> 170 | Just <| a :: xs 171 | -------------------------------------------------------------------------------- /src/Styles.elm: -------------------------------------------------------------------------------- 1 | module Styles exposing (file, general, overview, page) 2 | 3 | import Html.String as Html exposing (Html) 4 | import Html.String.Attributes as Attr 5 | import Html.String.Extra as Html 6 | import Service 7 | 8 | 9 | page : String -> String -> Service.Version -> List (Html msg) -> Html msg 10 | page title styles version content = 11 | Html.html [] 12 | [ Html.head [] 13 | [ Html.style [] [ Html.text styles ] 14 | , Html.node "meta" [ Attr.attribute "charset" "UTF-8" ] [] 15 | ] 16 | , Html.node "body" [] 17 | [ Html.header [] [ Html.h1 [ Attr.id "top" ] [ Html.text title ] ] 18 | , Html.section [] content 19 | , Html.footer [] 20 | [ Html.text "Generated with " 21 | , Html.a [ Attr.href "https://github.com/zwilias/elm-coverage" ] [ Html.text "elm-coverage" ] 22 | , Html.text <| "@" ++ version 23 | ] 24 | ] 25 | ] 26 | 27 | 28 | general : String 29 | general = 30 | """ 31 | @import url(https://fonts.googleapis.com/css?family=Fira+Sans); 32 | 33 | @font-face { 34 | font-family: 'Fira Code'; 35 | src: local('Fira Code'), local('FiraCode'), url(https://cdn.rawgit.com/tonsky/FiraCode/master/distr/ttf/FiraCode-Regular.ttf); 36 | } 37 | 38 | code { 39 | font-family: "Fira Code", monospace; 40 | font-size: 0.9em; 41 | } 42 | 43 | body { 44 | margin: 0 30px; 45 | color: #333333; 46 | font-family: "Fira Sans", sans-serif; 47 | background-color: #fdfdfd; 48 | font-size: 16px; 49 | } 50 | 51 | footer { 52 | margin: 1em; 53 | text-align: center; 54 | font-size: 0.8em; 55 | } 56 | 57 | a { 58 | font-weight: normal; 59 | } 60 | """ 61 | 62 | 63 | file : String 64 | file = 65 | """ 66 | .toTop { 67 | float: right; 68 | text-decoration: none; 69 | } 70 | 71 | .coverage { 72 | font-family: "Fira Code", monospace; 73 | font-size: 0.8em; 74 | white-space: pre; 75 | line-height: 1.2rem; 76 | background-color: #fdfdfd; 77 | padding: 1em 0.4em; 78 | border: 1px solid #D0D0D0; 79 | border-radius: 0.5em; 80 | display: flex; 81 | flex-direction: row; 82 | padding-left: 0; 83 | } 84 | 85 | .source .covered { 86 | background-color: #aef5ae; 87 | color: #202020; 88 | box-shadow: 0 0 0 2px #aef5ae; 89 | border-bottom: 1px solid #aef5ae; 90 | } 91 | 92 | .source .uncovered { 93 | background-color: rgb(255, 30, 30); 94 | color: white; 95 | box-shadow: 0 0 0 2px rgb(255, 30, 30); 96 | border-bottom-width: 1px; 97 | border-bottom-style: dashed; 98 | } 99 | 100 | .source .covered > .covered { 101 | box-shadow: none; 102 | background-color: initial; 103 | border-bottom: none; 104 | } 105 | 106 | .source .uncovered > .uncovered { 107 | box-shadow: none; 108 | border-bottom: none; 109 | background-color: initial; 110 | } 111 | 112 | .source .uncovered .covered { 113 | background-color: transparent; 114 | color: inherit; 115 | box-shadow: none; 116 | } 117 | 118 | .lines { 119 | text-align: right; 120 | margin-right: 10px; 121 | border-right: 1px solid #d0d0d0; 122 | padding-right: 10px; 123 | margin-top: -1em; 124 | padding-top: 1em; 125 | padding-bottom: 1em; 126 | margin-bottom: -1em; 127 | } 128 | 129 | .lines .line { 130 | display: block; 131 | color: #c0c0c0; 132 | text-decoration: none; 133 | transition: all 0.3s ease; 134 | font-size: 0.9em; 135 | line-height: 1.2rem; 136 | } 137 | 138 | .lines .line:hover { 139 | color: #303030; 140 | } 141 | 142 | .source { 143 | flex: 1; 144 | overflow: scroll; 145 | } 146 | 147 | .legend { 148 | text-align: center; 149 | font-size: 0.9em; 150 | margin-bottom: 2em; 151 | } 152 | 153 | .indicator { 154 | display: inline-block; 155 | float: left; 156 | background-color: rgb(255, 30, 30); 157 | } 158 | """ 159 | 160 | 161 | overview : String 162 | overview = 163 | """ 164 | .overview { 165 | width: 100%; 166 | padding: 0 30px; 167 | border: 1px solid #d0d0d0; 168 | border-radius: 0.5em; 169 | table-layout: fixed; 170 | } 171 | 172 | .overview thead { 173 | text-align: center; 174 | } 175 | 176 | .overview thead tr, 177 | .overview tfoot tr { 178 | height: 3em; 179 | } 180 | 181 | .overview tbody th, 182 | .overview tfoot th { 183 | text-align: right; 184 | text-overflow: ellipsis; 185 | overflow: hidden; 186 | direction: rtl; 187 | } 188 | 189 | .overview .wrapper { 190 | display: flex; 191 | } 192 | 193 | .overview .none { 194 | text-align: center; 195 | color: #606060; 196 | font-size: 0.8em; 197 | } 198 | 199 | .overview progress { 200 | flex: 1.5; 201 | display: none; 202 | } 203 | 204 | @media only screen and (min-width : 960px) { 205 | .overview progress { 206 | display: block; 207 | } 208 | } 209 | 210 | .overview .info { 211 | flex: 1; 212 | text-align: right; 213 | margin: 0 1em; 214 | } 215 | """ 216 | -------------------------------------------------------------------------------- /src/Util.elm: -------------------------------------------------------------------------------- 1 | module Util exposing (indexedFoldr, intercalate, mapBoth, rFilterMap) 2 | 3 | 4 | mapBoth : (a -> a -> a) -> ( a, a ) -> ( a, a ) -> ( a, a ) 5 | mapBoth f ( a, b ) ( x, y ) = 6 | ( f a x, f b y ) 7 | 8 | 9 | indexedFoldr : (Int -> a -> b -> b) -> b -> List a -> b 10 | indexedFoldr op acc xs = 11 | List.foldr 12 | (\x ( idx, a ) -> 13 | ( idx - 1 14 | , op idx x a 15 | ) 16 | ) 17 | ( List.length xs - 1, acc ) 18 | xs 19 | |> Tuple.second 20 | 21 | 22 | intercalate : a -> List (List a) -> List a 23 | intercalate sep = 24 | List.intersperse [ sep ] >> List.concat 25 | 26 | 27 | rFilterMap : (a -> Maybe b) -> List a -> List b 28 | rFilterMap toMaybe = 29 | List.foldl 30 | (\x acc -> 31 | case toMaybe x of 32 | Just ok -> 33 | ok :: acc 34 | 35 | Nothing -> 36 | acc 37 | ) 38 | [] 39 | -------------------------------------------------------------------------------- /tests/data/simple/.gitignore: -------------------------------------------------------------------------------- 1 | elm-stuff/ 2 | -------------------------------------------------------------------------------- /tests/data/simple/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/core": "1.0.4" 10 | }, 11 | "indirect": {} 12 | }, 13 | "test-dependencies": { 14 | "direct": { 15 | "elm-explorations/test": "1.2.2" 16 | }, 17 | "indirect": { 18 | "elm/html": "1.0.0", 19 | "elm/json": "1.1.3", 20 | "elm/random": "1.0.0", 21 | "elm/time": "1.0.0", 22 | "elm/virtual-dom": "1.0.2" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/data/simple/expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "coverageData": { 3 | "Simple": [ 4 | { 5 | "type": "declaration", 6 | "name": "foo", 7 | "complexity": 1, 8 | "from": { 9 | "line": 4, 10 | "column": 1 11 | }, 12 | "to": { 13 | "line": 5, 14 | "column": 12 15 | }, 16 | "count": 8 17 | } 18 | ], 19 | "Main": [ 20 | { 21 | "type": "declaration", 22 | "name": "foo", 23 | "complexity": 1, 24 | "from": { 25 | "line": 7, 26 | "column": 1 27 | }, 28 | "to": { 29 | "line": 8, 30 | "column": 27 31 | }, 32 | "count": 8 33 | } 34 | ] 35 | }, 36 | "moduleMap": { 37 | "Simple": "src/Simple.elm", 38 | "Main": "src/Main.elm" 39 | }, 40 | "event": "coverage" 41 | } 42 | -------------------------------------------------------------------------------- /tests/data/simple/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (foo) 2 | 3 | import Simple 4 | 5 | 6 | foo : String 7 | foo = 8 | "hello " ++ Simple.foo 9 | -------------------------------------------------------------------------------- /tests/data/simple/src/Simple.elm: -------------------------------------------------------------------------------- 1 | module Simple exposing (foo) 2 | 3 | foo : String 4 | foo = 5 | "world" 6 | -------------------------------------------------------------------------------- /tests/data/simple/tests/Example.elm: -------------------------------------------------------------------------------- 1 | module Example exposing (..) 2 | 3 | import Expect exposing (Expectation) 4 | import Main 5 | import Test exposing (..) 6 | 7 | 8 | suite : Test 9 | suite = 10 | test "A simple, succesfull test" <| 11 | \_ -> 12 | Main.foo 13 | |> Expect.equal "hello world" 14 | -------------------------------------------------------------------------------- /tests/runner.js: -------------------------------------------------------------------------------- 1 | var spawn = require("cross-spawn"), 2 | chai = require("chai"), 3 | Promise = require("bluebird"), 4 | assert = chai.assert, 5 | expect = chai.expect, 6 | shell = require("shelljs"), 7 | path = require("path"), 8 | fs = require("fs-extra"), 9 | chaiMatchPattern = require("chai-match-pattern"); 10 | 11 | chai.use(require("chai-json-schema-ajv")); 12 | chai.use(chaiMatchPattern); 13 | var _ = chaiMatchPattern.getLodashModule(); 14 | _.mixin({ 15 | matchesPath: (expected, actual) => actual.replace("\\", "/") === expected 16 | }); 17 | 18 | var elmCoverage = require.resolve( path.join("..", "bin", "elm-coverage")); 19 | 20 | describe("Sanity test", () => { 21 | it("prints the usage instructions when running with `--help`", done => { 22 | var process = spawn.spawn(elmCoverage, ["--help"]); 23 | var output = ""; 24 | 25 | process.stderr.on("data", data => { 26 | console.error(data.toString()); 27 | }); 28 | process.stdout.on("data", data => { 29 | output += data; 30 | }); 31 | 32 | process.on("exit", exitCode => { 33 | assert.equal(exitCode, 0, "Expected to exit with 0 exitcode"); 34 | assert.notEqual(output, "", "Expected to have some output"); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | describe("E2E tests", function() { 41 | this.timeout(Infinity); 42 | it("Should run succesfully", done => { 43 | var process = spawn.spawn(elmCoverage, { 44 | cwd: path.join("tests", "data", "simple") 45 | }); 46 | 47 | process.stderr.on("data", data => { 48 | console.error(data.toString()); 49 | }); 50 | 51 | process.on("exit", exitCode => { 52 | assert.equal(exitCode, 0, "Expected to finish succesfully"); 53 | done(); 54 | }); 55 | }); 56 | 57 | it("Should generate schema-validated JSON", () => 58 | Promise.all([ 59 | fs.readJSON(require.resolve("../docs/elm-coverage.json")), 60 | generateJSON() 61 | ]).spread((json, schema) => { 62 | expect(json).to.be.jsonSchema(schema); 63 | })); 64 | 65 | it("Should generate JSON that matches the pregenerated one, modulus runcount", () => 66 | Promise.all([ 67 | generateJSON(), 68 | fs.readJSON(require.resolve("./data/simple/expected.json")) 69 | ]).spread((actual, expectedJSON) => { 70 | var expected = {}; 71 | 72 | //expected event is "coverage" 73 | expected.event = "coverage"; 74 | 75 | // Ignore runcounts 76 | expected.coverageData = _.mapValues( 77 | expectedJSON.coverageData, 78 | moduleData => 79 | _.map(moduleData, coverage => 80 | Object.assign({}, coverage, { 81 | count: _.isInteger 82 | }) 83 | ) 84 | ); 85 | 86 | // System agnostic paths 87 | expected.moduleMap = _.mapValues( 88 | expectedJSON.moduleMap, 89 | modulePath => _.partial(_.matchesPath, modulePath, _) 90 | ); 91 | 92 | expect(actual).to.matchPattern(expected); 93 | })); 94 | }); 95 | 96 | function generateJSON() { 97 | return new Promise((resolve, reject) => { 98 | var process = spawn.spawn( 99 | elmCoverage, 100 | ["generate", "--report", "json"], 101 | { 102 | cwd: path.join("tests", "data", "simple") 103 | } 104 | ); 105 | 106 | var output = ""; 107 | 108 | process.stdout.on("data", data => { 109 | output += data; 110 | }); 111 | 112 | process.on("exit", exitCode => { 113 | assert.equal(exitCode, 0, "Expected to finish succesfully"); 114 | if (exitCode === 0) { 115 | resolve(output); 116 | } else { 117 | reject(new Error("Expected to finish successfully")); 118 | } 119 | }); 120 | }).then(json => JSON.parse(json)); 121 | } 122 | --------------------------------------------------------------------------------