1 | import os from 'os';
|
2 |
|
3 | import chalk, { Color } from 'chalk';
|
4 | import { schema, Position, StrykerOptions } from '@stryker-mutator/api/core';
|
5 | import { Logger } from '@stryker-mutator/api/logging';
|
6 | import { commonTokens } from '@stryker-mutator/api/plugin';
|
7 | import { Reporter } from '@stryker-mutator/api/report';
|
8 | import { MetricsResult, MutantModel, TestModel, MutationTestMetricsResult, TestFileModel, TestMetrics, TestStatus } from 'mutation-testing-metrics';
|
9 | import { tokens } from 'typed-inject';
|
10 |
|
11 | import { getEmojiForStatus, plural } from '../utils/string-utils.js';
|
12 |
|
13 | import { ClearTextScoreTable } from './clear-text-score-table.js';
|
14 |
|
15 | const { MutantStatus } = schema;
|
16 |
|
17 | export class ClearTextReporter implements Reporter {
|
18 | public static inject = tokens(commonTokens.logger, commonTokens.options);
|
19 | constructor(private readonly log: Logger, private readonly options: StrykerOptions) {
|
20 | this.configConsoleColor();
|
21 | }
|
22 |
|
23 | private readonly out: NodeJS.WritableStream = process.stdout;
|
24 |
|
25 | private readonly writeLine = (output?: string) => {
|
26 | this.out.write(`${output ?? ''}${os.EOL}`);
|
27 | };
|
28 |
|
29 | private readonly writeDebugLine = (input: string) => {
|
30 | this.log.debug(input);
|
31 | };
|
32 |
|
33 | private configConsoleColor() {
|
34 | if (!this.options.allowConsoleColors) {
|
35 | chalk.level = 0;
|
36 | }
|
37 | }
|
38 |
|
39 | public onMutationTestReportReady(_report: schema.MutationTestResult, metrics: MutationTestMetricsResult): void {
|
40 | this.writeLine();
|
41 | this.reportAllTests(metrics);
|
42 | this.reportAllMutants(metrics);
|
43 | this.writeLine(new ClearTextScoreTable(metrics.systemUnderTestMetrics, this.options).draw());
|
44 | }
|
45 |
|
46 | private reportAllTests(metrics: MutationTestMetricsResult) {
|
47 | function indent(depth: number) {
|
48 | return new Array(depth).fill(' ').join('');
|
49 | }
|
50 | const formatTestLine = (test: TestModel, state: string): string => {
|
51 | return `${this.color('grey', `${test.name}${test.location ? ` [line ${test.location.start.line}]` : ''}`)} (${state})`;
|
52 | };
|
53 |
|
54 | if (metrics.testMetrics) {
|
55 | const reportTests = (currentResult: MetricsResult<TestFileModel, TestMetrics>, depth = 0) => {
|
56 | const nameParts: string[] = [currentResult.name];
|
57 | while (!currentResult.file && currentResult.childResults.length === 1) {
|
58 | currentResult = currentResult.childResults[0];
|
59 | nameParts.push(currentResult.name);
|
60 | }
|
61 | this.writeLine(`${indent(depth)}${nameParts.join('/')}`);
|
62 | currentResult.file?.tests.forEach((test) => {
|
63 | switch (test.status) {
|
64 | case TestStatus.Killing:
|
65 | this.writeLine(`${indent(depth + 1)}${this.color('greenBright', '✓')} ${formatTestLine(test, `killed ${test.killedMutants?.length}`)}`);
|
66 | break;
|
67 | case TestStatus.Covering:
|
68 | this.writeLine(
|
69 | `${indent(depth + 1)}${this.color('blueBright', '~')} ${formatTestLine(test, `covered ${test.coveredMutants?.length}`)}`
|
70 | );
|
71 | break;
|
72 | case TestStatus.NotCovering:
|
73 | this.writeLine(`${indent(depth + 1)}${this.color('redBright', '✘')} ${formatTestLine(test, 'covered 0')}`);
|
74 | break;
|
75 | }
|
76 | });
|
77 | currentResult.childResults.forEach((childResult) => reportTests(childResult, depth + 1));
|
78 | };
|
79 | reportTests(metrics.testMetrics);
|
80 | }
|
81 | }
|
82 |
|
83 | private reportAllMutants({ systemUnderTestMetrics }: MutationTestMetricsResult): void {
|
84 | this.writeLine();
|
85 | let totalTests = 0;
|
86 |
|
87 | const reportMutants = (metrics: MetricsResult[]) => {
|
88 | metrics.forEach((child) => {
|
89 | child.file?.mutants.forEach((result) => {
|
90 | totalTests += result.testsCompleted ?? 0;
|
91 | switch (result.status) {
|
92 | case MutantStatus.Killed:
|
93 | case MutantStatus.Timeout:
|
94 | case MutantStatus.RuntimeError:
|
95 | case MutantStatus.CompileError:
|
96 | this.reportMutantResult(result, this.writeDebugLine);
|
97 | break;
|
98 | case MutantStatus.Survived:
|
99 | case MutantStatus.NoCoverage:
|
100 | this.reportMutantResult(result, this.writeLine);
|
101 | break;
|
102 | default:
|
103 | }
|
104 | });
|
105 | reportMutants(child.childResults);
|
106 | });
|
107 | };
|
108 | reportMutants(systemUnderTestMetrics.childResults);
|
109 | this.writeLine(`Ran ${(totalTests / systemUnderTestMetrics.metrics.totalMutants).toFixed(2)} tests per mutant on average.`);
|
110 | }
|
111 |
|
112 | private statusLabel(mutant: MutantModel): string {
|
113 | const status = MutantStatus[mutant.status];
|
114 | return this.options.clearTextReporter.allowEmojis ? `${getEmojiForStatus(status)} ${status}` : status.toString();
|
115 | }
|
116 |
|
117 | private reportMutantResult(result: MutantModel, logImplementation: (input: string) => void): void {
|
118 | logImplementation(`[${this.statusLabel(result)}] ${result.mutatorName}`);
|
119 | logImplementation(this.colorSourceFileAndLocation(result.fileName, result.location.start));
|
120 |
|
121 | result
|
122 | .getOriginalLines()
|
123 | .split('\n')
|
124 | .filter(Boolean)
|
125 | .forEach((line) => {
|
126 | logImplementation(chalk.red('- ' + line));
|
127 | });
|
128 | result
|
129 | .getMutatedLines()
|
130 | .split('\n')
|
131 | .filter(Boolean)
|
132 | .forEach((line) => {
|
133 | logImplementation(chalk.green('+ ' + line));
|
134 | });
|
135 | if (result.status === MutantStatus.Survived) {
|
136 | if (result.static) {
|
137 | logImplementation('Ran all tests for this mutant.');
|
138 | } else if (result.coveredByTests) {
|
139 | this.logExecutedTests(result.coveredByTests, logImplementation);
|
140 | }
|
141 | } else if (result.status === MutantStatus.Killed && result.killedByTests && result.killedByTests.length) {
|
142 | logImplementation(`Killed by: ${result.killedByTests[0].name}`);
|
143 | } else if (result.status === MutantStatus.RuntimeError || result.status === MutantStatus.CompileError) {
|
144 | logImplementation(`Error message: ${result.statusReason}`);
|
145 | }
|
146 | logImplementation('');
|
147 | }
|
148 |
|
149 | private colorSourceFileAndLocation(fileName: string, position: Position): string {
|
150 | return [this.color('cyan', fileName), this.color('yellow', position.line), this.color('yellow', position.column)].join(':');
|
151 | }
|
152 |
|
153 | private color(color: Color, ...text: unknown[]) {
|
154 | if (this.options.clearTextReporter.allowColor) {
|
155 | return chalk[color](...text);
|
156 | }
|
157 | return text.join('');
|
158 | }
|
159 |
|
160 | private logExecutedTests(tests: TestModel[], logImplementation: (input: string) => void) {
|
161 | if (!this.options.clearTextReporter.logTests) {
|
162 | return;
|
163 | }
|
164 |
|
165 | const testCount = Math.min(this.options.clearTextReporter.maxTestsToLog, tests.length);
|
166 |
|
167 | if (testCount > 0) {
|
168 | logImplementation('Tests ran:');
|
169 | tests.slice(0, testCount).forEach((test) => {
|
170 | logImplementation(` ${test.name}`);
|
171 | });
|
172 | const diff = tests.length - this.options.clearTextReporter.maxTestsToLog;
|
173 | if (diff > 0) {
|
174 | logImplementation(` and ${diff} more test${plural(diff)}!`);
|
175 | }
|
176 | logImplementation('');
|
177 | }
|
178 | }
|
179 | }
|
180 |
|
\ | No newline at end of file |