Skip to content

Commit

Permalink
feat(testrunner): introduce pytest-style reporter
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman committed Aug 24, 2020
1 parent 2b3a1ae commit 90aff69
Show file tree
Hide file tree
Showing 9 changed files with 278 additions and 50 deletions.
6 changes: 4 additions & 2 deletions test-runner/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@ import { collectTests, runTests, RunnerConfig } from '.';
import { DotReporter } from './reporters/dot';
import { ListReporter } from './reporters/list';
import { JSONReporter } from './reporters/json';
import { PytestReporter } from './reporters/pytest';

export const reporters = {
'dot': DotReporter,
'list': ListReporter,
'json': JSONReporter
'json': JSONReporter,
'pytest': PytestReporter,
};

program
.version('Version ' + /** @type {any} */ (require)('../package.json').version)
.option('--forbid-only', 'Fail if exclusive test(s) encountered', false)
.option('-g, --grep <grep>', 'Only run tests matching this string or regexp', '.*')
.option('-j, --jobs <jobs>', 'Number of concurrent jobs for --parallel; use 1 to run in serial, default: (number of CPU cores / 2)', Math.ceil(require('os').cpus().length / 2).toString())
.option('-j, --jobs <jobs>', 'Number of concurrent jobs for --parallel; use 1 to run in serial, default: (number of CPU cores / 2)', Math.ceil(require('os').cpus().length / 2) as any)
.option('--reporter <reporter>', 'Specify reporter to use', '')
.option('--trial-run', 'Only collect the matching tests and report them as passing')
.option('--quiet', 'Suppress stdio', false)
Expand Down
74 changes: 44 additions & 30 deletions test-runner/src/reporters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class BaseReporter implements Reporter {
pending: Test[] = [];
passes: Test[] = [];
failures: Test[] = [];
timeouts: Test[] = [];
duration = 0;
startTime: number;
config: RunnerConfig;
Expand Down Expand Up @@ -62,7 +63,10 @@ export class BaseReporter implements Reporter {
}

onFail(test: Test) {
this.failures.push(test);
if (test.duration >= test.timeout)
this.timeouts.push(test);
else
this.failures.push(test);
}

onEnd() {
Expand All @@ -72,43 +76,53 @@ export class BaseReporter implements Reporter {
epilogue() {
console.log('');

console.log(colors.green(` ${this.passes.length} passing`) + colors.dim(` (${milliseconds(this.duration)})`));
console.log(colors.green(` ${this.passes.length} passed`) + colors.dim(` (${milliseconds(this.duration)})`));

if (this.pending.length)
console.log(colors.yellow(` ${this.pending.length} skipped`));

if (this.failures.length) {
console.log(colors.red(` ${this.failures.length} failing`));
if (this.failures.length) {
console.log(colors.red(` ${this.failures.length} failed`));
console.log('');
this.failures.forEach((failure, index) => {
const relativePath = path.relative(process.cwd(), failure.file);
const header = ` ${index +1}. ${terminalLink(relativePath, `file://${os.hostname()}${failure.file}`)}${failure.title}`;
console.log(colors.bold(colors.red(header)));
const stack = failure.error.stack;
if (stack) {
console.log('');
const messageLocation = failure.error.stack.indexOf(failure.error.message);
const preamble = failure.error.stack.substring(0, messageLocation + failure.error.message.length);
console.log(indent(preamble, ' '));
const position = positionInFile(stack, failure.file);
if (position) {
const source = fs.readFileSync(failure.file, 'utf8');
console.log('');
console.log(indent(codeFrameColumns(source, {
start: position,
},
{ highlightCode: true}
), ' '));
}
console.log('');
console.log(indent(colors.dim(stack.substring(preamble.length + 1)), ' '));
} else {
this._printFailures(this.failures);
}

if (this.timeouts.length) {
console.log(colors.red(` ${this.timeouts.length} timed out`));
console.log('');
this._printFailures(this.timeouts);
}
}

private _printFailures(failures: Test[]) {
failures.forEach((failure, index) => {
const relativePath = path.relative(process.cwd(), failure.file);
const header = ` ${index +1}. ${terminalLink(relativePath, `file://${os.hostname()}${failure.file}`)}${failure.title}`;
console.log(colors.bold(colors.red(header)));
const stack = failure.error.stack;
if (stack) {
console.log('');
const messageLocation = failure.error.stack.indexOf(failure.error.message);
const preamble = failure.error.stack.substring(0, messageLocation + failure.error.message.length);
console.log(indent(preamble, ' '));
const position = positionInFile(stack, failure.file);
if (position) {
const source = fs.readFileSync(failure.file, 'utf8');
console.log('');
console.log(indent(String(failure.error), ' '));
console.log(indent(codeFrameColumns(source, {
start: position,
},
{ highlightCode: true}
), ' '));
}
console.log('');
});
}
console.log(indent(colors.dim(stack.substring(preamble.length + 1)), ' '));
} else {
console.log('');
console.log(indent(String(failure.error), ' '));
}
console.log('');
});
}
}

Expand Down
2 changes: 1 addition & 1 deletion test-runner/src/reporters/dot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class DotReporter extends BaseReporter {

onPass(test: Test) {
super.onPass(test);
process.stdout.write(colors.green('\u00B7'));
process.stdout.write(colors.green('·'));
}

onFail(test: Test) {
Expand Down
6 changes: 3 additions & 3 deletions test-runner/src/reporters/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,21 @@ export class ListReporter extends BaseReporter {
super.onPending(test);
process.stdout.write(colors.green(' - ') + colors.cyan(test.fullTitle()));
process.stdout.write('\n');
}
}

onPass(test: Test) {
super.onPass(test);
process.stdout.write('\u001b[2K\u001b[0G');
process.stdout.write(colors.green(' ✓ ') + colors.gray(test.fullTitle()));
process.stdout.write('\n');
}
}

onFail(test: Test) {
super.onFail(test);
process.stdout.write('\u001b[2K\u001b[0G');
process.stdout.write(colors.red(` ${++this._failure}) ` + test.fullTitle()));
process.stdout.write('\n');
}
}

onEnd() {
super.onEnd();
Expand Down
205 changes: 205 additions & 0 deletions test-runner/src/reporters/pytest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import colors from 'colors/safe';
import milliseconds from 'ms';
import * as path from 'path';
import { Test, Suite, Configuration } from '../test';
import { BaseReporter } from './base';
import { RunnerConfig } from '../runnerConfig';

const cursorPrevLine = '\u001B[F';
const eraseLine = '\u001B[2K'

type Row = {
id: string;
relativeFile: string;
configuration: string;
ordinal: number;
track: string[];
total: number;
failed: boolean;
startTime: number;
finishTime: number;
};

export class PytestReporter extends BaseReporter {
private _rows = new Map<string, Row>();
private _suiteIds = new Map<Suite, string>();
private _lastOrdinal = 0;
private _total: number;
private _processed = 0;
private _totalRows: number;
private _failed = false;
private _throttler = new Throttler(250, () => this._repaint());

onBegin(config: RunnerConfig, rootSuite: Suite) {
super.onBegin(config, rootSuite);
this._total = rootSuite.total();

// We get job rows and up to 3 buffer rows for green suites.
const jobs = Math.min(config.jobs, rootSuite.suites.length);
this._totalRows = jobs + Math.min(jobs, 4);
for (let i = 0; i < this._totalRows; ++i)
process.stdout.write('\n');

for (const s of rootSuite.suites) {
const relativeFile = path.relative(this.config.testDir, s.file);
const configurationString = serializeConfiguration(s.configuration);
const id = relativeFile + `::[${configurationString}]`;
this._suiteIds.set(s, id);
const row = {
id,
relativeFile,
configuration: configurationString,
ordinal: this._lastOrdinal++,
track: [],
total: s.total(),
failed: false,
startTime: 0,
finishTime: 0,
};
this._rows.set(id, row);
}
}

onTest(test: Test) {
super.onTest(test);
const row = this._rows.get(this._id(test));
if (!row.startTime)
row.startTime = Date.now();
}

onPending(test: Test) {
super.onPending(test);
this._append(test, colors.yellow('∘'));
}

onPass(test: Test) {
super.onPass(test);
this._append(test, colors.green('✓'));
}

onFail(test: Test) {
super.onFail(test);
const title = test.duration >= test.timeout ? colors.red('T') : colors.red('F');
const row = this._append(test, title);
row.failed = true;
this._failed = true;
}

onEnd() {
super.onEnd();
this._repaint();
if (this._failed)
this.epilogue();
}

private _append(test: Test, s: string): Row {
++this._processed;
const testId = this._id(test);
const row = this._rows.get(testId);
row.track.push(s);
if (row.track.length === row.total)
row.finishTime = Date.now();
this._throttler.schedule();
return row;
}

private _repaint() {
const rowList = [...this._rows.values()];
const running = rowList.filter(r => r.startTime && !r.finishTime);
const finished = rowList.filter(r => r.finishTime).sort((a, b) => b.finishTime - a.finishTime);
const finishedToPrint = finished.slice(0, this._totalRows - running.length - 1);
const lines = [];
for (const row of finishedToPrint.concat(running)) {
const remaining = row.total - row.track.length;
const remainder = '·'.repeat(remaining);
let title = row.relativeFile;
if (row.finishTime) {
if (row.failed)
title = colors.red(row.relativeFile);
else
title = colors.green(row.relativeFile);
}
const configuration = ` [${colors.gray(row.configuration)}]`;
lines.push(' ' + title + configuration + ' ' + row.track.join('') + colors.gray(remainder));
}

const status = [];
if (this._total - this._processed)
status.push(colors.cyan(`${this._total - this._processed} remaining`));
if (this.passes.length)
status.push(colors.green(`${this.passes.length} passed`));
if (this.pending.length)
status.push(colors.yellow(`${this.pending.length} skipped`));
if (this.failures.length)
status.push(colors.red(`${this.failures.length} failed`));
if (this.timeouts.length)
status.push(colors.red(`${this.timeouts.length} timed out`));
status.push(colors.dim(`(${milliseconds(Date.now() - this.startTime)})`));
lines.push('');
lines.push(' ' + status.join(' '));

process.stdout.write((cursorPrevLine + eraseLine).repeat(this._totalRows));
process.stdout.write(lines.join('\n'));
process.stdout.write('\n'.repeat(this._totalRows - lines.length + 1));
}

private _id(test: Test): string {
for (let suite = test.suite; suite; suite = suite.parent) {
if (this._suiteIds.has(suite))
return this._suiteIds.get(suite);
}
return '';
}
}

function serializeConfiguration(configuration: Configuration): string {
const tokens = [];
for (const { name, value } of configuration)
tokens.push(`${name}=${value}`);
return tokens.join(', ');
}

class Throttler {
private _timeout: number;
private _callback: () => void;
private _lastFire = 0;
private _timer: NodeJS.Timeout | null = null;

constructor(timeout: number, callback: () => void) {
this._timeout = timeout;
this._callback = callback;
}

schedule() {
const time = Date.now();
const timeRemaining = this._lastFire + this._timeout - time;
if (timeRemaining <= 0) {
this._fire();
return;
}
if (!this._timer)
this._timer = setTimeout(() => this._fire(), timeRemaining);
}

private _fire() {
this._timer = null;
this._lastFire = Date.now();
this._callback();
}
}
5 changes: 4 additions & 1 deletion test-runner/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ class Worker extends EventEmitter {
this.runner = runner;
}

run(entry: TestRunnerEntry) {
}

stop() {
}
}
Expand Down Expand Up @@ -290,7 +293,7 @@ class InProcessWorker extends Worker {
initializeImageMatcher(this.runner._config);
}

async run(entry) {
async run(entry: TestRunnerEntry) {
delete require.cache[entry.file];
const { TestRunner } = require('./testRunner');
const testRunner = new TestRunner(entry, this.runner._config, 0);
Expand Down
7 changes: 7 additions & 0 deletions test-runner/src/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,10 @@ export class Suite {
return found;
}
}

export function serializeConfiguration(configuration: Configuration): string {
const tokens = [];
for (const { name, value } of configuration)
tokens.push(`${name}=${value}`);
return tokens.join(', ');
}
Loading

0 comments on commit 90aff69

Please sign in to comment.