diff --git a/README.md b/README.md index ae13af8..6219802 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,28 @@ There are several ways that you can configure tslint-teamcity. You don't have to configure anything by default, you just have the option to if you would like. Settings are looked for in the following priority: -#### 1. From your package.json -If you have a package.json file in the current directory, you can add an extra "eslint-teamcity" property to it: +#### 1. As a second argument +If you run tslint-teamcity-reporter by requiring it in your code, you can pass a second argument to the function: +```js +import { Formatter } from 'tslint-teamcity-reporter'; + +const formatter = new Formatter(); +const options = { + reporter: 'inspections', + reportName: 'My TSLint Violations', + errorStatisticsName: 'My TSLint Error Count', + warningStatisticsName: 'My TSLint Warning Count', +}; +console.log(formatter.format(tslintFailures, options)); +``` + +#### 2. From your package.json +If you have a package.json file in the current directory, you can add an extra "tslint-teamcity" property to it: ```json { "tslint-teamcity": { + "reporter": "inspections", "report-name": "My TSLint Violations", "error-statistics-name": "My TSLint Error Count", "warning-statistics-name": "My TSLint Warning Count" @@ -100,9 +116,10 @@ If you have a package.json file in the current directory, you can add an extra " } ``` -#### 2. ENV variables +#### 3. ENV variables ```sh +export TSLINT_TEAMCITY_REPORTER="inspections" export TSLINT_TEAMCITY_REPORT_NAME="My Formatting Problems" export TSLINT_TEAMCITY_ERROR_STATISTICS_NAME="My Error Count" export TSLINT_TEAMCITY_WARNING_STATISTICS_NAME="My Warning Count" @@ -114,6 +131,9 @@ You can also output your current settings to the log if you set: export TSLINT_TEAMCITY_DISPLAY_CONFIG=true ``` +#### Output type +By default, the output is displayed as tests on a TeamCity build (`"reporter": "errors"`). You can change it to be displayed as "Inspections" in a separate tab by setting the `"reporter": "inspections"` option. + ## Building diff --git a/package.json b/package.json index 0ff596e..a199d02 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tslint-teamcity-reporter", - "version": "3.1.0", + "version": "3.2.0", "description": "A TSLint formatter/reporter for use in TeamCity which groups by files using TeamCity Test Suite", "main": "./index.js", "types": "./index.d.ts", diff --git a/src/lib/Reporter.ts b/src/lib/Reporter.ts index f93320c..56039c1 100644 --- a/src/lib/Reporter.ts +++ b/src/lib/Reporter.ts @@ -1,7 +1,8 @@ -import * as path from 'path'; import { AbstractFormatter } from 'tslint/lib/language/formatter/abstractFormatter'; import { RuleFailure, IFormatterMetadata } from 'tslint'; -import { getOutputMessage, getUserConfig, TeamCityMessages } from './util'; +import { getUserConfig } from './util'; +import { formatAsInspections } from './formatters/inspections'; +import { formatAsTests } from './formatters/errors'; export class Formatter extends AbstractFormatter { /* tslint:disable:object-literal-sort-keys */ @@ -24,95 +25,25 @@ export class Formatter extends AbstractFormatter { }; /* tslint:enable:object-literal-sort-keys */ - public format(failures: RuleFailure[]): string { - const config = getUserConfig({}); - const { reportName } = config; + public format(failures: RuleFailure[], config: { [key: string]: string } = {}): string { + const userConfig = getUserConfig(config); if (process.env.TSLINT_TEAMCITY_DISPLAY_CONFIG) { // tslint:disable-next-line no-console - console.info(`Running TSLint Teamcity with config: ${JSON.stringify(config, null, 4)}`); + console.info(`Running TSLint Teamcity with config: ${JSON.stringify(userConfig, null, 4)}`); } - const output = []; - let errorCount = 0; - let warningCount = 0; - - output.push(getOutputMessage(TeamCityMessages.TEST_SUITE_STARTED, { report: reportName })); - - // group failures per file, instead of reporting each failure individually - const failuresByFile = failures.reduce<{ - [key: string]: { filePath?: string; messages?: Array }; - }>((acc, f) => { - const file = f.getFileName(); - if (!acc[file]) acc[file] = { filePath: file, messages: [] }; - acc[file].messages.push(f); - return acc; - }, {}); - - Object.values(failuresByFile).forEach(result => { - const filePath = path.relative(process.cwd(), result.filePath); - output.push( - getOutputMessage(TeamCityMessages.TEST_STARTED, { report: reportName, file: filePath }), - ); - - const errorsList = []; - const warningsList = []; - - result.messages.forEach(failure => { - const startPos = failure.getStartPosition().getLineAndCharacter(); - const formattedMessage = `line ${startPos.line}, col ${ - startPos.character - }, ${failure.getFailure()} (${failure.getRuleName()})`; - - const isError = failure.getRuleSeverity() === 'error'; - if (!isError) - { - warningsList.push(formattedMessage); - warningCount += 1; - } else - { - errorsList.push(formattedMessage); - errorCount += 1; - } - }); - - // Group errors and warnings together per file - if (errorsList.length) { - const errors = errorsList.join('\n'); - output.push( - getOutputMessage(TeamCityMessages.TEST_FAILED, { - errors, - report: reportName, - file: filePath, - }), - ); - } - - if (warningsList.length) { - const warnings = warningsList.join('\n'); - output.push( - getOutputMessage(TeamCityMessages.TEST_STD_OUT, { - warnings, - report: reportName, - file: filePath, - }), - ); - } - - output.push( - getOutputMessage(TeamCityMessages.TEST_FINISHED, { report: reportName, file: filePath }), - ); - }); - - output.push(getOutputMessage(TeamCityMessages.TEST_SUITE_FINISHED, { report: reportName })); - - output.push( - ...(>getOutputMessage(TeamCityMessages.BUILD_STATISTIC_VALUE, { - [config.errorStatisticsName]: errorCount, - [config.warningStatisticsName]: warningCount, - })), - ); + let outputMessage = ''; + switch (userConfig.reporter.toLowerCase()) { + case 'inspections': + outputMessage = formatAsInspections(failures, userConfig); + break; + case 'errors': + default: + outputMessage = formatAsTests(failures, userConfig); + break; + } - return output.join('\n'); + return outputMessage; } } diff --git a/src/lib/formatters/errors.ts b/src/lib/formatters/errors.ts new file mode 100644 index 0000000..08928f1 --- /dev/null +++ b/src/lib/formatters/errors.ts @@ -0,0 +1,88 @@ +import * as path from 'path'; + +import { RuleFailure } from 'tslint'; +import { getOutputMessage, TeamCityMessages } from '../util'; + +export function formatAsTests(failures: RuleFailure[], config: { [key: string]: string }) { + const { reportName } = config; + + const output = []; + let errorCount = 0; + let warningCount = 0; + + output.push(getOutputMessage(TeamCityMessages.TEST_SUITE_STARTED, { report: reportName })); + + // group failures per file, instead of reporting each failure individually + const failuresByFile = failures.reduce<{ + [key: string]: { filePath?: string; messages?: Array }; + }>((acc, f) => { + const file = f.getFileName(); + if (!acc[file]) acc[file] = { filePath: file, messages: [] }; + acc[file].messages.push(f); + return acc; + }, {}); + + Object.values(failuresByFile).forEach(result => { + const filePath = path.relative(process.cwd(), result.filePath); + output.push( + getOutputMessage(TeamCityMessages.TEST_STARTED, { report: reportName, file: filePath }), + ); + + const errorsList = []; + const warningsList = []; + + result.messages.forEach(failure => { + const startPos = failure.getStartPosition().getLineAndCharacter(); + const formattedMessage = `line ${startPos.line}, col ${ + startPos.character + }, ${failure.getFailure()} (${failure.getRuleName()})`; + + const isError = failure.getRuleSeverity() === 'error'; + if (!isError) { + warningsList.push(formattedMessage); + warningCount += 1; + } else { + errorsList.push(formattedMessage); + errorCount += 1; + } + }); + + // Group errors and warnings together per file + if (errorsList.length) { + const errors = errorsList.join('\n'); + output.push( + getOutputMessage(TeamCityMessages.TEST_FAILED, { + errors, + report: reportName, + file: filePath, + }), + ); + } + + if (warningsList.length) { + const warnings = warningsList.join('\n'); + output.push( + getOutputMessage(TeamCityMessages.TEST_STD_OUT, { + warnings, + report: reportName, + file: filePath, + }), + ); + } + + output.push( + getOutputMessage(TeamCityMessages.TEST_FINISHED, { report: reportName, file: filePath }), + ); + }); + + output.push(getOutputMessage(TeamCityMessages.TEST_SUITE_FINISHED, { report: reportName })); + + output.push( + ...(>getOutputMessage(TeamCityMessages.BUILD_STATISTIC_VALUE, { + [config.errorStatisticsName]: errorCount, + [config.warningStatisticsName]: warningCount, + })), + ); + + return output.join('\n'); +} diff --git a/src/lib/formatters/inspections.ts b/src/lib/formatters/inspections.ts new file mode 100644 index 0000000..a1bd7ce --- /dev/null +++ b/src/lib/formatters/inspections.ts @@ -0,0 +1,62 @@ +import * as path from 'path'; + +import { RuleFailure } from 'tslint'; +import { getOutputMessage, TeamCityMessages } from '../util'; + +export function formatAsInspections(failures: RuleFailure[], config: { [key: string]: string }) { + const { reportName } = config; + + const output = []; + let errorCount = 0; + let warningCount = 0; + + // group failures per file, instead of reporting each failure individually + const failuresByRule = failures.reduce<{ + [key: string]: { ruleName?: string; messages?: Array }; + }>((acc, f) => { + const ruleName = f.getRuleName(); + if (!acc[ruleName]) acc[ruleName] = { ruleName, messages: [] }; + acc[ruleName].messages.push(f); + return acc; + }, {}); + + Object.values(failuresByRule).forEach(result => { + output.push( + getOutputMessage(TeamCityMessages.INSPECTION_TYPE, { reportName, ruleName: result.ruleName }), + ); + + result.messages.forEach(failure => { + const filePath = path.relative(process.cwd(), failure.getFileName()); + const startPos = failure.getStartPosition().getLineAndCharacter(); + const formattedMessage = `line ${startPos.line}, col ${ + startPos.character + }, ${failure.getFailure()}`; + + const isError = failure.getRuleSeverity() === 'error'; + const severity = isError ? 'ERROR' : 'WARNING'; + if (isError) { + errorCount += 1; + } else { + warningCount += 1; + } + output.push( + getOutputMessage(TeamCityMessages.INSPECTION, { + formattedMessage, + filePath, + severity, + ruleName: result.ruleName, + line: startPos.line, + }), + ); + }); + }); + + output.push( + ...(>getOutputMessage(TeamCityMessages.BUILD_STATISTIC_VALUE, { + [config.errorStatisticsName]: errorCount, + [config.warningStatisticsName]: warningCount, + })), + ); + + return output.join('\n'); +} diff --git a/src/lib/util/index.ts b/src/lib/util/index.ts index 8dedc11..28b1467 100644 --- a/src/lib/util/index.ts +++ b/src/lib/util/index.ts @@ -10,6 +10,8 @@ export enum TeamCityMessages { TEST_IGNORED = 'testIgnored', TEST_FAILED = 'testFailed', BUILD_STATISTIC_VALUE = 'buildStatisticValue', + INSPECTION_TYPE = 'inspectionType', + INSPECTION = 'inspection', } export type GetOutputMessageOptions = { @@ -17,24 +19,42 @@ export type GetOutputMessageOptions = { file?: string; errors?: string; warnings?: string; - [key: string]: string|number; + reportName?: string; + ruleName?: string; + formattedMessage?: string; + filePath?: string; + line?: number; + severity?: 'ERROR' | 'WARNING'; + [key: string]: string | number; }; const messages = { [TeamCityMessages.TEST_SUITE_STARTED]: (type, { report }) => `##teamcity[${type} name='${report}']`, + [TeamCityMessages.TEST_SUITE_FINISHED]: (type, { report }) => `##teamcity[${type} name='${report}']`, + [TeamCityMessages.TEST_STARTED]: (type, { report, file }) => `##teamcity[${type} name='${report}: ${file}']`, + [TeamCityMessages.TEST_FINISHED]: (type, { report, file }) => `##teamcity[${type} name='${report}: ${file}']`, + [TeamCityMessages.TEST_FAILED]: (type, { report, file, errors }) => `##teamcity[${type} name='${report}: ${file}' message='${errors}']`, + [TeamCityMessages.TEST_STD_OUT]: (type, { report, file, warnings }) => `##teamcity[${type} name='${report}: ${file}' out='warning: ${warnings}']`, + [TeamCityMessages.BUILD_STATISTIC_VALUE]: (type, values) => Object.keys(values).map(key => `##teamcity[${type} key='${key}' value='${values[key]}']`), + + [TeamCityMessages.INSPECTION_TYPE]: (type, { reportName, ruleName }) => + `##teamcity[${type} id='${ruleName}' category='${reportName}' name='${ruleName}' description='${reportName}']`, + + [TeamCityMessages.INSPECTION]: (type, { ruleName, formattedMessage, filePath, line, severity }) => + `##teamcity[${type} typeId='${ruleName}' message='${formattedMessage}' file='${filePath}' line='${line}' SEVERITY='${severity}']`, }; export function getOutputMessage(type, options: GetOutputMessageOptions): string | Array { @@ -104,6 +124,9 @@ export function getUserConfig(propNames): { [key: string]: string } { // Attempt to load package.json from current directory const config = JSON.parse(loadPackageJson())['tslint-teamcity-reporter'] || {}; + const reporter = + propNames.reporter || config.reporter || process.env.TSLINT_TEAMCITY_REPORTER || 'errors'; + const reportName = propNames.reportName || config['report-name'] || @@ -123,6 +146,7 @@ export function getUserConfig(propNames): { [key: string]: string } { 'TSLint Warning Count'; return { + reporter, reportName: escapeTeamCityString(reportName), errorStatisticsName: escapeTeamCityString(errorStatisticsName), warningStatisticsName: escapeTeamCityString(warningStatisticsName), diff --git a/test/Reporter.spec.ts b/test/Reporter.spec.ts index bc52487..ff6cfd3 100644 --- a/test/Reporter.spec.ts +++ b/test/Reporter.spec.ts @@ -1,24 +1,26 @@ import { Formatter } from '../src/lib/Reporter'; -import {expect} from 'chai'; -import { Replacement } from 'tslint'; +import { expect } from 'chai'; +import { Replacement, RuleFailure } from 'tslint'; import * as ts from "typescript"; import { createFailure, getSourceFile } from './utils'; -let reporter:Formatter; +let reporter: Formatter; let sourceFile1: ts.SourceFile; let sourceFile2: ts.SourceFile; +let failures: Array; +let maxPositionObj1: ts.LineAndCharacter; +let maxPositionObj2: ts.LineAndCharacter; describe('Reporter', () => { beforeEach(() => { sourceFile1 = getSourceFile('test1.ts'); sourceFile2 = getSourceFile('test2.ts'); reporter = new Formatter(); - }); - it('formats failures', () => { const maxPosition = sourceFile1.getFullWidth(); - - const failures = [ + maxPositionObj1 = sourceFile1.getLineAndCharacterOfPosition(maxPosition - 1); + maxPositionObj2 = sourceFile2.getLineAndCharacterOfPosition(maxPosition - 1); + failures = [ createFailure(sourceFile1, 0, 1, "first failure", "first-name", undefined, "error"), createFailure( sourceFile1, @@ -48,12 +50,10 @@ describe('Reporter', () => { "warning", ), ]; + }); - const maxPositionObj1 = sourceFile1.getLineAndCharacterOfPosition(maxPosition - 1); - const maxPositionObj2 = sourceFile2.getLineAndCharacterOfPosition(maxPosition - 1); - - - const expectedResult:string = ` + it('formats failures as tests', () => { + const expectedResult: string = ` ##teamcity[testSuiteStarted name='TSLint Violations'] ##teamcity[testStarted name='TSLint Violations: test1.ts'] ##teamcity[testFailed name='TSLint Violations: test1.ts' message='line 0, col 0, first failure (first-name)|nline ${maxPositionObj1.line}, col ${maxPositionObj1.character}, last failure (last-name)'] @@ -69,4 +69,20 @@ describe('Reporter', () => { const actualResult = reporter.format(failures); expect(actualResult).to.eql(expectedResult); }); + + it('formats failures as inspections', () => { + const expectedResult: string = ` +##teamcity[inspectionType id='first-name' category='TSLint Violations' name='first-name' description='TSLint Violations'] +##teamcity[inspection typeId='first-name' message='line 0, col 0, first failure' file='test1.ts' line='0' SEVERITY='ERROR'] +##teamcity[inspectionType id='last-name' category='TSLint Violations' name='last-name' description='TSLint Violations'] +##teamcity[inspection typeId='last-name' message='line 12, col 1, last failure' file='test1.ts' line='12' SEVERITY='ERROR'] +##teamcity[inspectionType id='full-name' category='TSLint Violations' name='full-name' description='TSLint Violations'] +##teamcity[inspection typeId='full-name' message='line 0, col 0, full failure' file='test1.ts' line='0' SEVERITY='WARNING'] +##teamcity[inspection typeId='full-name' message='line 9, col 38, full failure' file='test2.ts' line='9' SEVERITY='WARNING'] +##teamcity[buildStatisticValue key='TSLint Error Count' value='2'] +##teamcity[buildStatisticValue key='TSLint Warning Count' value='2']`.slice(1); // strip leading newline + + const actualResult = reporter.format(failures, { reporter: 'inspections' }); + expect(actualResult).to.eql(expectedResult); + }); }); diff --git a/test/util/index.spec.ts b/test/util/index.spec.ts index 0a6e52a..82b4d23 100644 --- a/test/util/index.spec.ts +++ b/test/util/index.spec.ts @@ -105,5 +105,43 @@ describe('utils', () => { }), ).to.eql(`##teamcity[testStdOut name='|[report-name|]: |[test.js|]' out='warning: ${escapeTeamCityString(warnings)}']`); }); + + it('returns the INSPECTION_TYPE message', () => { + expect( + getOutputMessage(TeamCityMessages.INSPECTION_TYPE, { + reportName: 'report-name', + ruleName: 'rule-name', + }), + ).to.eql(`##teamcity[inspectionType id='rule-name' category='report-name' name='rule-name' description='report-name']`); + }); + it('returns the INSPECTION message', () => { + const formattedMessage = ''; + expect( + getOutputMessage(TeamCityMessages.INSPECTION, { + formattedMessage, + reportName: 'report-name', + ruleName: 'rule-name', + filePath: 'test.js', + line: 1, + severity: 'ERROR' + }), + ).to.eql(`##teamcity[inspection typeId='rule-name' message='${formattedMessage}' file='test.js' line='1' SEVERITY='ERROR']`); + }); + + it('returns escaped values in inspections', () => { + const formattedMessage = '|[]\''; + expect( + getOutputMessage(TeamCityMessages.INSPECTION, { + formattedMessage, + reportName: '[report-name]', + ruleName: '[rule-name]', + filePath: '[test.js]', + line: 1, + severity: 'WARNING' + }), + ).to.eql(`##teamcity[inspection typeId='|[rule-name|]' message='${ + escapeTeamCityString(formattedMessage) + }' file='|[test.js|]' line='1' SEVERITY='WARNING']`); + }); }); });