diff --git a/README.md b/README.md index a5e3a488f..da2b74ac8 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,12 @@ markdownlint --help -V, --version output the version number -f, --fix fix basic errors (does not work with STDIN) -s, --stdin read from STDIN (does not work with files) - -o, --output [outputFile] write issues to file (no console) - -c, --config [configFile] configuration file (JSON, JSONC, JS, or YAML) - -i, --ignore [file|directory|glob] file(s) to ignore/exclude - -p, --ignore-path [file] path to file with ignore pattern(s) - -r, --rules [file|directory|glob|package] custom rule files + -o, --output write issues to file (no console) + -j, --junit write issues to file in JUnit format and to the console + -c, --config configuration file (JSON, JSONC, JS, or YAML) + -i, --ignore file(s) to ignore/exclude + -p, --ignore-path path to file with ignore pattern(s) + -r, --rules custom rule files ``` ### Globbing @@ -63,6 +64,13 @@ Because this option makes changes to the input files, it is good to make a backu > Because not all rules include fix information when reporting errors, fixes may overlap, and not all errors are fixable, `--fix` will not usually address all errors. +### JUnit report + +If the `-j`/`--junit` option is specified, `markdownlint-cli` will output an XML report in JUnit format. +This report can be used in CI/CD environments to make the `markdownlint-cli` results available to your CI/CD tool. +If both `-j`/`--junit` and `-o`/`--output` are specified, `-o`/`--output` will take precedence. +Only one of these options should be specified. + ## Configuration `markdownlint-cli` reuses [the rules][rules] from `markdownlint` package. @@ -100,6 +108,7 @@ A JS configuration file may internally `require` one or more npm packages as a w - `1`: Linting errors / bad parameter - `2`: Unable to write `-o`/`--output` output file - `3`: Unable to load `-r`/`--rules` custom rule +- `4`: Unable to write `-j`/`--junit` output file ## Related diff --git a/markdownlint.js b/markdownlint.js index c84ea0884..46a9b58b8 100755 --- a/markdownlint.js +++ b/markdownlint.js @@ -67,7 +67,7 @@ function readConfiguration(args) { markdownlint.readConfigSync(userConfigFile, configFileParsers); config = require('deep-extend')(config, userConfig); } catch (error) { - console.warn('Cannot read or parse config file ' + userConfigFile + ': ' + error.message); + console.warn(`Cannot read or parse config file ${userConfigFile}: ${error.message}`); } } @@ -165,9 +165,43 @@ function printResult(lintResult) { try { fs.writeFileSync(program.output, lintResultString); } catch (error) { - console.warn('Cannot write to output file ' + program.output + ': ' + error.message); + console.warn(`Cannot write to output file ${program.output}: ${error.message}`); process.exitCode = 2; } + } else if (program.junit) { + const builder = require('junit-report-builder'); + const testSuite = builder + .testSuite() + .name('markdownlint') + .timestamp(new Date().toISOString()) + .time(0); + if (results.length > 0) { + results.forEach(result => { + const {file, lineNumber, column, names, description} = result; + const columnText = column ? `:${column}` : ''; + const testName = `${file}:${lineNumber}${columnText} ${names}`; + testSuite + .testCase() + .className(file) + .name(testName) + .failure(`${testName} ${description}`, names) + .time(0); + }); + } else { + const className = program.stdin ? 'stdin' : program.args; + testSuite.testCase().className(className).name('markdownlint').time(0); + } + + try { + builder.writeTo(program.junit); + } catch (error) { + console.warn(`Cannot write to JUnit file ${program.junit}: ${error.message}`); + process.exitCode = 4; + } + + if (lintResultString) { + console.error(lintResultString); + } } else if (lintResultString) { console.error(lintResultString); } @@ -184,11 +218,12 @@ program .usage('[options] ') .option('-f, --fix', 'fix basic errors (does not work with STDIN)') .option('-s, --stdin', 'read from STDIN (does not work with files)') - .option('-o, --output [outputFile]', 'write issues to file (no console)') - .option('-c, --config [configFile]', 'configuration file (JSON, JSONC, JS, or YAML)') - .option('-i, --ignore [file|directory|glob]', 'file(s) to ignore/exclude', concatArray, []) - .option('-p, --ignore-path [file]', 'path to file with ignore pattern(s)') - .option('-r, --rules [file|directory|glob|package]', 'custom rule files', concatArray, []); + .option('-o, --output ', 'write issues to file (no console)') + .option('-j, --junit ', 'write issues to file in JUnit format and to the console') + .option('-c, --config ', 'configuration file (JSON, JSONC, JS, or YAML)') + .option('-i, --ignore ', 'file(s) to ignore/exclude', concatArray, []) + .option('-p, --ignore-path ', 'path to file with ignore pattern(s)') + .option('-r, --rules ', 'custom rule files', concatArray, []); program.parse(process.argv); @@ -227,7 +262,7 @@ function loadCustomRules(rules) { return fileList; } catch (error) { - console.error('Cannot load custom rule ' + rule + ': ' + error.message); + console.error(`Cannot load custom rule ${rule}: ${error.message}`); process.exit(3); } })); diff --git a/package-lock.json b/package-lock.json index d7ae0158a..a7e8918ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1467,6 +1467,11 @@ "array-find-index": "^1.0.1" } }, + "date-format": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-0.0.2.tgz", + "integrity": "sha1-+v1Ej3IRXvHitzkVWukvK+bCjdE=" + }, "date-time": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/date-time/-/date-time-2.1.0.tgz", @@ -4042,6 +4047,32 @@ "universalify": "^1.0.0" } }, + "junit-report-builder": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/junit-report-builder/-/junit-report-builder-2.0.0.tgz", + "integrity": "sha512-/CkUScz5G7U1Fehna9swd8YfA+o5tV07NZz+3pea27okD0+ZEgyXQ5E0etyxMaqzXOjBvN8HGmNpVO/79yj5TA==", + "requires": { + "date-format": "0.0.2", + "lodash": "^4.17.15", + "make-dir": "^1.3.0", + "xmlbuilder": "^10.0.0" + }, + "dependencies": { + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "requires": { + "pify": "^3.0.0" + } + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=" + } + } + }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", @@ -4139,8 +4170,7 @@ "lodash": { "version": "4.17.15", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", - "dev": true + "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==" }, "lodash.clonedeep": { "version": "4.5.0", @@ -5657,6 +5687,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "sax": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", + "dev": true + }, "semver": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", @@ -6845,6 +6881,20 @@ "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", "dev": true }, + "xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "requires": { + "sax": "^1.2.4" + } + }, + "xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==" + }, "xo": { "version": "0.30.0", "resolved": "https://registry.npmjs.org/xo/-/xo-0.30.0.tgz", diff --git a/package.json b/package.json index 2927e41f2..f91b2cb59 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "ignore": "~5.1.4", "js-yaml": "~3.13.1", "jsonc-parser": "~2.2.0", + "junit-report-builder": "~2.0.0", "lodash.differencewith": "~4.5.0", "lodash.flatten": "~4.4.0", "markdownlint": "~0.20.3", @@ -58,6 +59,7 @@ "husky": "^3.0.4", "tap-growl": "^3.0.0", "test-rule-package": "./test/custom-rules/test-rule-package", + "xml-js": "^1.6.11", "xo": "*" }, "xo": { diff --git a/test/test.js b/test/test.js index 7b7ecd4ba..850e66843 100644 --- a/test/test.js +++ b/test/test.js @@ -4,6 +4,7 @@ const fs = require('fs'); const path = require('path'); const test = require('ava'); const execa = require('execa'); +const xmlParser = require('xml-js'); const errorPattern = /(\.md|\.markdown|\.mdf|stdin):\d+(:\d+)? MD\d{3}/gm; @@ -357,6 +358,85 @@ test('--output with invalid path fails', async t => { } }); +test('--junit with empty input has single successful test', async t => { + const input = ''; + const junit = '../junitA.xml'; + const result = await execa('../markdownlint.js', + ['--stdin', '--junit', junit], + {input, stripFinalNewline: false}); + const xml = fs.readFileSync(junit, 'utf8'); + const parsedXml = xmlParser.xml2js(xml, {compact: true}); + t.is(result.stdout, ''); + t.is(result.stderr, ''); + t.is(Object.keys(parsedXml.testsuites).length, 1); + t.is(Object.keys(parsedXml.testsuites.testsuite).length, 2); + t.is(parsedXml.testsuites.testsuite._attributes.name, 'markdownlint'); + t.is(parsedXml.testsuites.testsuite._attributes.timestamp.match(/\d{4}-[01]\d-[0-3]\dT[0-2](?:\d:[0-5]){2}\d\.\d+Z/gm).length, 1); + t.is(parsedXml.testsuites.testsuite._attributes.time, '0'); + t.is(parsedXml.testsuites.testsuite._attributes.tests, '1'); + t.is(parsedXml.testsuites.testsuite._attributes.failures, '0'); + t.is(parsedXml.testsuites.testsuite._attributes.errors, '0'); + t.is(parsedXml.testsuites.testsuite._attributes.skipped, '0'); + t.is(parsedXml.testsuites.testsuite.testcase._attributes.classname, 'stdin'); + t.is(parsedXml.testsuites.testsuite.testcase._attributes.name, 'markdownlint'); + t.is(parsedXml.testsuites.testsuite.testcase._attributes.time, '0'); + fs.unlinkSync(junit); +}); + +// WIP +// test('--junit with valid input has empty output', async t => { +// const input = [ +// '# Heading', +// '', +// 'Text', +// '' +// ].join('\n'); +// const output = '../outputB.txt'; +// const result = await execa('../markdownlint.js', +// ['--stdin', '--output', output], +// {input, stripFinalNewline: false}); +// t.is(result.stdout, ''); +// t.is(result.stderr, ''); +// t.is(fs.readFileSync(output, 'utf8'), ''); +// fs.unlinkSync(output); +// }); + +// test('--junit with invalid input outputs violations', async t => { +// const input = [ +// 'Heading', +// '', +// 'Text ', +// '' +// ].join('\n'); +// const output = '../outputC.txt'; +// try { +// await execa('../markdownlint.js', +// ['--stdin', '--output', output], +// {input, stripFinalNewline: false}); +// t.fail(); +// } catch (error) { +// t.is(error.stdout, ''); +// t.is(error.stderr, ''); +// t.is(fs.readFileSync(output, 'utf8').match(errorPattern).length, 2); +// fs.unlinkSync(output); +// } +// }); + +// test('--junit with invalid path fails', async t => { +// const input = ''; +// const output = 'invalid/outputD.txt'; +// try { +// await execa('../markdownlint.js', +// ['--stdin', '--output', output], +// {input, stripFinalNewline: false}); +// t.fail(); +// } catch (error) { +// t.is(error.stdout, ''); +// t.is(error.stderr.replace(/: ENOENT[^]*$/, ''), 'Cannot write to output file ' + output); +// t.throws(() => fs.accessSync(output, 'utf8')); +// } +// }); + test('configuration file can be YAML', async t => { const result = await execa('../markdownlint.js', ['--config', 'md043-config.yaml', 'md043-config.md'],