Skip to content

Commit

Permalink
feat(append-only-progress): Implement new reporter (#213)
Browse files Browse the repository at this point in the history
* feat(append-only-progress): Implement new reporter

* Create a new progress reporter, one which only appends. It prints progress every 10 seconds.
* When the "progress" reporter is not used when the console does not support it.

* refactor(progress-reporter-spec): Remove duplicate code
  • Loading branch information
nicojs authored and simondel committed Jan 7, 2017
1 parent 682f9d4 commit 7b68506
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 89 deletions.
35 changes: 23 additions & 12 deletions src/ReporterOrchestrator.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,37 @@
import {StrykerOptions} from 'stryker-api/core';
import {Reporter, ReporterFactory} from 'stryker-api/report';
import { StrykerOptions } from 'stryker-api/core';
import { Reporter, ReporterFactory } from 'stryker-api/report';
import ClearTextReporter from './reporters/ClearTextReporter';
import ProgressReporter from './reporters/ProgressReporter';
import ProgressAppendOnlyReporter from './reporters/ProgressAppendOnlyReporter';
import DotsReporter from './reporters/DotsReporter';
import EventRecorderReporter from './reporters/EventRecorderReporter';
import BroadcastReporter, {NamedReporter} from './reporters/BroadcastReporter';
import BroadcastReporter, { NamedReporter } from './reporters/BroadcastReporter';
import * as log4js from 'log4js';

const log = log4js.getLogger('ReporterOrchestrator');

function registerDefaultReporters() {
ReporterFactory.instance().register('progress-append-only', ProgressAppendOnlyReporter);
ReporterFactory.instance().register('progress', ProgressReporter);
ReporterFactory.instance().register('dots', DotsReporter);
ReporterFactory.instance().register('clear-text', ClearTextReporter);
ReporterFactory.instance().register('event-recorder', EventRecorderReporter);
}
registerDefaultReporters();

export default class ReporterOrchestrator {

constructor(private options: StrykerOptions) {
this.registerDefaultReporters();
}

public createBroadcastReporter(): Reporter {
let reporters: NamedReporter[] = [];
let reporterOption = this.options.reporter;
if (reporterOption) {
if (Array.isArray(reporterOption)) {
reporterOption.forEach(reporterName => reporters.push({ name: reporterName, reporter: ReporterFactory.instance().create(reporterName, this.options) }));
reporterOption.forEach(reporterName => reporters.push(this.createReporter(reporterName)));
} else {
reporters.push({ name: reporterOption, reporter: ReporterFactory.instance().create(reporterOption, this.options) });
reporters.push(this.createReporter(reporterOption));
}
} else {
log.warn(`No reporter configured. Please configure one or more reporters in the (for example: reporter: 'progress')`);
Expand All @@ -32,6 +40,15 @@ export default class ReporterOrchestrator {
return new BroadcastReporter(reporters);
}

private createReporter(name: string) {
if (name === 'progress' && !(process.stdout as any)['isTTY']) {
log.info('Detected that current console does not support the "progress" reporter, downgrading to "progress-append-only" reporter');
return { name: 'progress-append-only', reporter: ReporterFactory.instance().create('progress-append-only', this.options) };
} else {
return { name, reporter: ReporterFactory.instance().create(name, this.options) };
}
}

private logPossibleReporters() {
let possibleReportersCsv = '';
ReporterFactory.instance().knownNames().forEach(name => {
Expand All @@ -43,10 +60,4 @@ export default class ReporterOrchestrator {
log.warn(`Possible reporters: ${possibleReportersCsv}`);
}

private registerDefaultReporters() {
ReporterFactory.instance().register('progress', ProgressReporter);
ReporterFactory.instance().register('dots', DotsReporter);
ReporterFactory.instance().register('clear-text', ClearTextReporter);
ReporterFactory.instance().register('event-recorder', EventRecorderReporter);
}
}
43 changes: 43 additions & 0 deletions src/reporters/ProgressAppendOnlyReporter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { MatchedMutant, MutantResult, MutantStatus } from 'stryker-api/report';
import * as chalk from 'chalk';
import * as os from 'os';
import ProgressKeeper from './ProgressKeeper';
import Timer from '../utils/Timer';

export default class ProgressAppendOnlyReporter extends ProgressKeeper {
private intervalReference: NodeJS.Timer;
private timer: Timer;

onAllMutantsMatchedWithTests(matchedMutants: ReadonlyArray<MatchedMutant>): void {
super.onAllMutantsMatchedWithTests(matchedMutants);
this.timer = new Timer();
this.intervalReference = setInterval(() => this.render(), 10000);
}

onAllMutantsTested(): void {
clearInterval(this.intervalReference);
}

private render() {
process.stdout.write(`Mutation testing ${this.procent()} (ETC ${this.etc()}) ` +
`[${this.progress.killed} ${this.progress.killedLabel}] ` +
`[${this.progress.survived} ${this.progress.survivedLabel}] ` +
`[${this.progress.noCoverage} ${this.progress.noCoverageLabel}] ` +
`[${this.progress.timeout} ${this.progress.timeoutLabel}] ` +
`[${this.progress.error} ${this.progress.errorLabel}]` +
os.EOL);
}

private procent() {
return Math.floor(this.progress.testedCount / this.progress.totalCount * 100) + '%';
}

private etc() {
const etcSeconds = Math.floor(this.timer.elapsedSeconds() / this.progress.testedCount * (this.progress.totalCount - this.progress.testedCount));
if (isFinite(etcSeconds)) {
return etcSeconds + 's';
} else {
return 'n/a';
}
}
}
49 changes: 49 additions & 0 deletions src/reporters/ProgressKeeper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as chalk from 'chalk';
import { MatchedMutant, Reporter, MutantResult, MutantStatus } from 'stryker-api/report';

abstract class ProgressKeeper implements Reporter {

// progress contains Labels, because on initation of the ProgressBar the width is determined based on the amount of characters of the progressBarContent inclusive ASCII-codes for colors
protected progress = {
error: 0,
survived: 0,
killed: 0,
timeout: 0,
noCoverage: 0,
testedCount: 0,
totalCount: 0,
killedLabel: chalk.green.bold('killed'),
survivedLabel: chalk.red.bold('survived'),
noCoverageLabel: chalk.red.bold('no coverage'),
timeoutLabel: chalk.yellow.bold('timeout'),
errorLabel: chalk.yellow.bold('error')
};

onAllMutantsMatchedWithTests(matchedMutants: ReadonlyArray<MatchedMutant>): void {
this.progress.totalCount = matchedMutants.filter(m => m.scopedTestIds.length > 0).length;
}

onMutantTested(result: MutantResult): void {
this.progress.testedCount++;
switch (result.status) {
case MutantStatus.NoCoverage:
this.progress.testedCount--; // correct for not tested, because no coverage
this.progress.noCoverage++;
break;
case MutantStatus.Killed:
this.progress.killed++;
break;
case MutantStatus.Survived:
this.progress.survived++;
break;
case MutantStatus.TimedOut:
this.progress.timeout++;
break;
case MutantStatus.Error:
this.progress.error++;
break;
}
}

}
export default ProgressKeeper;
62 changes: 19 additions & 43 deletions src/reporters/ProgressReporter.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
import { Reporter, MatchedMutant, MutantResult, MutantStatus } from 'stryker-api/report';
import { MatchedMutant, MutantResult, MutantStatus } from 'stryker-api/report';
import ProgressKeeper from './ProgressKeeper';
import ProgressBar from './ProgressBar';
import * as chalk from 'chalk';

export default class ProgressReporter implements Reporter {
export default class ProgressBarReporter extends ProgressKeeper {
private progressBar: ProgressBar;

// tickValues contains Labels, because on initation of the ProgressBar the width is determined based on the amount of characters of the progressBarContent inclusive ASCII-codes for colors
private tickValues = {
error: 0,
survived: 0,
killed: 0,
timeout: 0,
noCoverage: 0,
killedLabel: chalk.green.bold('killed'),
survivedLabel: chalk.red.bold('survived'),
noCoverageLabel: chalk.red.bold('no coverage'),
timeoutLabel: chalk.yellow.bold('timeout'),
errorLabel: chalk.yellow.bold('error')
};

onAllMutantsMatchedWithTests(matchedMutants: ReadonlyArray<MatchedMutant>): void {
let progressBarContent: string;

progressBarContent =
super.onAllMutantsMatchedWithTests(matchedMutants);
const progressBarContent =
`Mutation testing [:bar] :percent (ETC :etas)` +
`[:killed :killedLabel] ` +
`[:survived :survivedLabel] ` +
Expand All @@ -34,36 +20,26 @@ export default class ProgressReporter implements Reporter {
width: 50,
complete: '=',
incomplete: ' ',
total: matchedMutants.filter(m => m.scopedTestIds.length > 0).length
stream: process.stdout,
total: this.progress.totalCount
});
}

onMutantTested(result: MutantResult): void {
switch (result.status) {
case MutantStatus.NoCoverage:
this.tickValues.noCoverage++;
this.progressBar.render(this.tickValues);
break;
case MutantStatus.Killed:
this.tickValues.killed++;
this.tick();
break;
case MutantStatus.Survived:
this.tickValues.survived++;
this.tick();
break;
case MutantStatus.TimedOut:
this.tickValues.timeout++;
this.tick();
break;
case MutantStatus.Error:
this.tickValues.error++;
this.tick();
break;
const ticksBefore = this.progress.testedCount;
super.onMutantTested(result);
if (ticksBefore < this.progress.testedCount) {
this.tick();
} else {
this.render();
}
}

tick(): void {
this.progressBar.tick(this.tickValues);
private tick(): void {
this.progressBar.tick(this.progress);
}

private render(): void {
this.progressBar.render(this.progress);
}
}
8 changes: 6 additions & 2 deletions src/utils/Timer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ export default class Timer {
}

humanReadableElapsed() {
const elapsedMs = new Date().getTime() - this.start.getTime();
const elapsedSeconds = Math.floor(elapsedMs / 1000);
const elapsedSeconds = this.elapsedSeconds();
return Timer.humanReadableElapsedMinutes(elapsedSeconds) + Timer.humanReadableElapsedSeconds(elapsedSeconds);
}

elapsedSeconds() {
const elapsedMs = new Date().getTime() - this.start.getTime();
return Math.floor(elapsedMs / 1000);
}

private static humanReadableElapsedSeconds(elapsedSeconds: number) {
const restSeconds = elapsedSeconds % 60;
if (restSeconds === 1) {
Expand Down
29 changes: 29 additions & 0 deletions test/helpers/producers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MutantStatus, MatchedMutant, MutantResult } from 'stryker-api/report';

export function mutantResult(status: MutantStatus): MutantResult {
return {
location: null,
mutatedLines: null,
mutatorName: null,
originalLines: null,
replacement: null,
sourceFilePath: null,
testsRan: null,
status,
range: null
};
}

export function matchedMutant(numberOfTests: number): MatchedMutant {
let scopedTestIds: number[] = [];
for (let i = 0; i < numberOfTests; i++) {
scopedTestIds.push(1);
}
return {
mutatorName: null,
scopedTestIds: scopedTestIds,
timeSpentScopedTests: null,
filename: null,
replacement: null
};
}
60 changes: 60 additions & 0 deletions test/unit/ReporterOrchestratorSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as sinon from 'sinon';
import { expect } from 'chai';
import * as broadcastReporter from '../../src/reporters/BroadcastReporter';
import ReporterOrchestrator from '../../src/ReporterOrchestrator';
import { ReporterFactory } from 'stryker-api/report';

describe('ReporterOrchestrator', () => {
let sandbox: sinon.SinonSandbox;
let sut: ReporterOrchestrator;
let isTTY: boolean;
let broadcastReporterMock: sinon.SinonStub;

beforeEach(() => {
sandbox = sinon.sandbox.create();
broadcastReporterMock = sandbox.stub(broadcastReporter, 'default');
captureTTY();
});

afterEach(() => {
sandbox.restore();
restoreTTY();
});

it('should register default reporters', () => {
expect(ReporterFactory.instance().knownNames()).to.have.lengthOf(5);
});

describe('createBroadcastReporter()', () => {

// https://github.com/stryker-mutator/stryker/issues/212
it('should create "progress-append-only" instead of "progress" reporter if process.stdout is not a tty', () => {
setTTY(false);
sut = new ReporterOrchestrator({ reporter: 'progress' });
sut.createBroadcastReporter();
expect(broadcastReporterMock).to.have.been.calledWithNew;
expect(broadcastReporterMock).to.have.been.calledWith(sinon.match(
(reporters: broadcastReporter.NamedReporter[]) => reporters[0].name === 'progress-append-only'));
});

it('should create the correct reporters', () => {
setTTY(true);
sut = new ReporterOrchestrator({ reporter: ['progress', 'progress-append-only'] });
sut.createBroadcastReporter();
expect(broadcastReporterMock).to.have.been.calledWith(sinon.match(
(reporters: broadcastReporter.NamedReporter[]) => reporters[0].name === 'progress' && reporters[1].name === 'progress-append-only'));
});
});

function captureTTY() {
isTTY = (process.stdout as any)['isTTY'];
}

function restoreTTY() {
(process.stdout as any)['isTTY'] = isTTY;
}

function setTTY(val: boolean) {
(process.stdout as any)['isTTY'] = val;
}
});
Loading

0 comments on commit 7b68506

Please sign in to comment.