diff --git a/packages/api/schema/stryker-core.json b/packages/api/schema/stryker-core.json index e7fd692c82..d2ce869742 100644 --- a/packages/api/schema/stryker-core.json +++ b/packages/api/schema/stryker-core.json @@ -133,6 +133,18 @@ } } }, + "jsonReporterOptions": { + "title": "JsonReporterOptions", + "additionalProperties": false, + "type": "object", + "properties": { + "fileName": { + "description": "The relative filename for the json report.", + "type": "string", + "default": "reports/mutation/mutation.json" + } + } + }, "mutationScoreThresholds": { "title": "MutationScoreThresholds", "additionalProperties": false, @@ -349,6 +361,11 @@ "description": "The options for the html reporter", "$ref": "#/definitions/htmlReporterOptions" }, + "jsonReporter": { + "description": "The options for the json reporter", + "$ref": "#/definitions/jsonReporterOptions", + "default": {} + }, "disableTypeChecks": { "description": "Configure a pattern that matches the files of which type checking has to be disabled. This is needed because Stryker will create (typescript) type errors when inserting the mutants in your code. Stryker disables type checking by inserting `// @ts-nocheck` atop those files and removing other `// @ts-xxx` directives (so they won't interfere with `@ts-nocheck`). The default setting allows these directives to be stripped from all JavaScript and friend files in `lib`, `src` and `test` directories. You can specify a different glob expression or set it to `false` to completely disable this behavior.", "anyOf": [ diff --git a/packages/core/README.md b/packages/core/README.md index 66b2bfac02..24049ef39d 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -311,17 +311,19 @@ you can consult [npm](https://www.npmjs.com/search?q=stryker-plugin) or ### `reporters` [`string[]`] Default: `['clear-text', 'progress', 'html']` -Command line: `--reporters clear-text,progress,dots,dashboard,html` -Config file: `reporters: ['clear-text', 'progress', 'dots', 'dashboard', 'html']` +Command line: `--reporters clear-text,progress,dots,dashboard,html,json` +Config file: `reporters: ['clear-text', 'progress', 'dots', 'dashboard', 'html', 'json']` With `reporters`, you can set the reporters for stryker to use. -These reporters can be used out of the box: `html`, `progress`, `clear-text`, `dots`, `dashboard` and `event-recorder`. +These reporters can be used out of the box: `html`, `json`, `progress`, `clear-text`, `dots`, `dashboard` and `event-recorder`. By default, `clear-text`, `progress`, `html` are active if no reporters are configured. You can load additional plugins to get more reporters. See [stryker-mutator.io](https://stryker-mutator.io) for an up-to-date list of supported reporter plugins and a description on each reporter. The `html` reporter allows you to specify an output folder. This defaults to `reports/mutation/html`. The config for your config file is: `htmlReporter: { baseDir: 'mypath/reports/stryker' }` +The `json` reporter allows specifying an output file name (may also contain a path). The config for your config file is: `jsonReporter: { fileName: 'mypath/reports/mutation.json' }` + The `clear-text` reporter supports three additional config options: * `allowColor` to use cyan and yellow in printing source file names and positions. This defaults to `true`, so specify as `clearTextReporter: { allowColor: false },` to disable if you must. * `logTests` to log the names of unit tests that were run to allow mutants. By default, only the first three are logged. The config for your config file is: `clearTextReporter: { logTests: true },` @@ -480,4 +482,4 @@ async function main() { } ``` -Stryker is written in TypeScript, so it is recommended to use Stryker as well to get the best developer experience. \ No newline at end of file +Stryker is written in TypeScript, so it is recommended to use Stryker as well to get the best developer experience. diff --git a/packages/core/src/reporters/html/html-reporter.ts b/packages/core/src/reporters/html/html-reporter.ts index 9de853bee6..302435e83c 100644 --- a/packages/core/src/reporters/html/html-reporter.ts +++ b/packages/core/src/reporters/html/html-reporter.ts @@ -7,8 +7,9 @@ import { mutationTestReportSchema, Reporter } from '@stryker-mutator/api/report' import fileUrl = require('file-url'); +import * as ReporterUtil from '../reporter-util'; + import { bindMutationTestReport } from './templates/bind-mutation-test-report'; -import * as HtmlReporterUtil from './html-reporter-util'; const DEFAULT_BASE_FOLDER = path.normalize('reports/mutation/html'); export const RESOURCES_DIR_NAME = 'strykerResources'; @@ -33,13 +34,13 @@ export default class HtmlReporter implements Reporter { const indexFileName = path.resolve(this.baseDir, 'index.html'); await this.cleanBaseFolder(); await Promise.all([ - HtmlReporterUtil.copyFile( + ReporterUtil.copyFile( require.resolve('mutation-testing-elements/dist/mutation-test-elements.js'), path.resolve(this.baseDir, 'mutation-test-elements.js') ), - HtmlReporterUtil.copyFile(path.resolve(__dirname, 'templates', 'stryker-80x80.png'), path.resolve(this.baseDir, 'stryker-80x80.png')), - HtmlReporterUtil.copyFile(path.resolve(__dirname, 'templates', 'index.html'), path.resolve(this.baseDir, 'index.html')), - HtmlReporterUtil.writeFile(path.resolve(this.baseDir, 'bind-mutation-test-report.js'), bindMutationTestReport(report)), + ReporterUtil.copyFile(path.resolve(__dirname, 'templates', 'stryker-80x80.png'), path.resolve(this.baseDir, 'stryker-80x80.png')), + ReporterUtil.copyFile(path.resolve(__dirname, 'templates', 'index.html'), path.resolve(this.baseDir, 'index.html')), + ReporterUtil.writeFile(path.resolve(this.baseDir, 'bind-mutation-test-report.js'), bindMutationTestReport(report)), ]); this.log.info(`Your report can be found at: ${fileUrl(indexFileName)}`); } @@ -60,7 +61,7 @@ export default class HtmlReporter implements Reporter { } private async cleanBaseFolder(): Promise { - await HtmlReporterUtil.deleteDir(this.baseDir); - await HtmlReporterUtil.mkdir(this.baseDir); + await ReporterUtil.deleteDir(this.baseDir); + await ReporterUtil.mkdir(this.baseDir); } } diff --git a/packages/core/src/reporters/index.ts b/packages/core/src/reporters/index.ts index 4ad89dceb8..cc878dd912 100644 --- a/packages/core/src/reporters/index.ts +++ b/packages/core/src/reporters/index.ts @@ -7,6 +7,7 @@ import EventRecorderReporter from './event-recorder-reporter'; import ProgressAppendOnlyReporter from './progress-append-only-reporter'; import ProgressReporter from './progress-reporter'; import HtmlReporter from './html/html-reporter'; +import JsonReporter from './json-reporter'; export const strykerPlugins = [ declareClassPlugin(PluginKind.Reporter, 'clear-text', ClearTextReporter), @@ -15,5 +16,6 @@ export const strykerPlugins = [ declareClassPlugin(PluginKind.Reporter, 'dots', DotsReporter), declareClassPlugin(PluginKind.Reporter, 'event-recorder', EventRecorderReporter), declareClassPlugin(PluginKind.Reporter, 'html', HtmlReporter), + declareClassPlugin(PluginKind.Reporter, 'json', JsonReporter), declareFactoryPlugin(PluginKind.Reporter, 'dashboard', dashboardReporterFactory), ]; diff --git a/packages/core/src/reporters/json-reporter.ts b/packages/core/src/reporters/json-reporter.ts new file mode 100644 index 0000000000..e078c114f2 --- /dev/null +++ b/packages/core/src/reporters/json-reporter.ts @@ -0,0 +1,36 @@ +import * as path from 'path'; + +import { StrykerOptions } from '@stryker-mutator/api/core'; +import { Logger } from '@stryker-mutator/api/logging'; +import { commonTokens, tokens } from '@stryker-mutator/api/plugin'; +import { mutationTestReportSchema, Reporter } from '@stryker-mutator/api/report'; + +import fileUrl = require('file-url'); + +import * as ReporterUtil from './reporter-util'; + +const INDENTION_LEVEL = 0; +export const RESOURCES_DIR_NAME = 'strykerResources'; + +export default class JsonReporter implements Reporter { + private mainPromise: Promise | undefined; + + constructor(private readonly options: StrykerOptions, private readonly log: Logger) {} + + public static readonly inject = tokens(commonTokens.options, commonTokens.logger); + + public onMutationTestReportReady(report: mutationTestReportSchema.MutationTestResult) { + this.mainPromise = this.generateReport(report); + } + + public wrapUp() { + return this.mainPromise; + } + + private async generateReport(report: mutationTestReportSchema.MutationTestResult) { + const filePath = path.normalize(this.options.jsonReporter.fileName); + this.log.debug(`Using relative path ${filePath}`); + await ReporterUtil.writeFile(path.resolve(filePath), JSON.stringify(report, null, INDENTION_LEVEL)); + this.log.info(`Your report can be found at: ${fileUrl(filePath)}`); + } +} diff --git a/packages/core/src/reporters/html/html-reporter-util.ts b/packages/core/src/reporters/reporter-util.ts similarity index 89% rename from packages/core/src/reporters/html/html-reporter-util.ts rename to packages/core/src/reporters/reporter-util.ts index 4f2f69fd68..2e6746fcc2 100644 --- a/packages/core/src/reporters/html/html-reporter-util.ts +++ b/packages/core/src/reporters/reporter-util.ts @@ -1,4 +1,4 @@ -import * as path from 'path'; +import * as ReporterUtil from 'path'; import { promisify } from 'util'; import { createReadStream, createWriteStream, promises as fs } from 'fs'; @@ -20,6 +20,6 @@ export const deleteDir = promisify(rimraf); export const mkdir = mkdirp; export async function writeFile(fileName: string, content: string) { - await mkdirp(path.dirname(fileName)); + await mkdirp(ReporterUtil.dirname(fileName)); await fs.writeFile(fileName, content, 'utf8'); } diff --git a/packages/core/test/unit/reporters/html/html-reporter.spec.ts b/packages/core/test/unit/reporters/html/html-reporter.spec.ts index 5bc6a4440c..8a81878280 100644 --- a/packages/core/test/unit/reporters/html/html-reporter.spec.ts +++ b/packages/core/test/unit/reporters/html/html-reporter.spec.ts @@ -6,7 +6,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import HtmlReporter from '../../../../src/reporters/html/html-reporter'; -import * as HtmlReporterUtil from '../../../../src/reporters/html/html-reporter-util'; +import * as ReporterUtil from '../../../../src/reporters/reporter-util'; import { bindMutationTestReport } from '../../../../src/reporters/html/templates/bind-mutation-test-report'; describe(HtmlReporter.name, () => { @@ -17,10 +17,10 @@ describe(HtmlReporter.name, () => { let sut: HtmlReporter; beforeEach(() => { - copyFileStub = sinon.stub(HtmlReporterUtil, 'copyFile'); - writeFileStub = sinon.stub(HtmlReporterUtil, 'writeFile'); - deleteDirStub = sinon.stub(HtmlReporterUtil, 'deleteDir'); - mkdirStub = sinon.stub(HtmlReporterUtil, 'mkdir'); + copyFileStub = sinon.stub(ReporterUtil, 'copyFile'); + writeFileStub = sinon.stub(ReporterUtil, 'writeFile'); + deleteDirStub = sinon.stub(ReporterUtil, 'deleteDir'); + mkdirStub = sinon.stub(ReporterUtil, 'mkdir'); sut = testInjector.injector.injectClass(HtmlReporter); }); diff --git a/packages/core/test/unit/reporters/json-reporter.spec.ts b/packages/core/test/unit/reporters/json-reporter.spec.ts new file mode 100644 index 0000000000..005f2aebe8 --- /dev/null +++ b/packages/core/test/unit/reporters/json-reporter.spec.ts @@ -0,0 +1,71 @@ +import * as path from 'path'; + +import { mutationTestReportSchema } from '@stryker-mutator/api/report'; +import { testInjector } from '@stryker-mutator/test-helpers'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; + +import JsonReporter from '../../../src/reporters/json-reporter'; +import * as JsonReporterUtil from '../../../src/reporters/reporter-util'; + +describe(JsonReporter.name, () => { + let writeFileStub: sinon.SinonStub; + let sut: JsonReporter; + + beforeEach(() => { + writeFileStub = sinon.stub(JsonReporterUtil, 'writeFile'); + sut = testInjector.injector.injectClass(JsonReporter); + }); + + describe('onMutationTestReportReady', () => { + it('should use configured file path', async () => { + const fileName = 'foo/bar/myReport.json'; + const expectedPath = path.normalize(fileName); + testInjector.options.jsonReporter = { + fileName, + }; + actReportReady(); + await sut.wrapUp(); + expect(testInjector.logger.debug).calledWith(`Using relative path ${expectedPath}`); + }); + + it('should use default base directory when no override is configured', async () => { + const expectedPath = path.normalize('reports/mutation/mutation.json'); + actReportReady(); + await sut.wrapUp(); + expect(testInjector.logger.debug).calledWith(`Using relative path ${expectedPath}`); + }); + + it('should write the mutation report to disk', async () => { + const report: mutationTestReportSchema.MutationTestResult = { + files: {}, + schemaVersion: '1.0', + thresholds: { + high: 80, + low: 60, + }, + }; + sut.onMutationTestReportReady(report); + await sut.wrapUp(); + expect(writeFileStub).calledWith(path.resolve('reports', 'mutation', 'mutation.json'), JSON.stringify(report)); + }); + }); + + describe('wrapUp', () => { + it('should resolve when everything is OK', () => { + actReportReady(); + return expect(sut.wrapUp()).eventually.undefined; + }); + + it('should reject when "writeFile" rejects', () => { + const expectedError = new Error('writeFile'); + writeFileStub.rejects(expectedError); + actReportReady(); + return expect(sut.wrapUp()).rejectedWith(expectedError); + }); + }); + + function actReportReady() { + sut.onMutationTestReportReady({ files: {}, schemaVersion: '', thresholds: { high: 0, low: 0 } }); + } +});