chore: include start/endTime and duration in onEnd report callback (#26760)

Fixes https://github.com/microsoft/playwright/issues/23637
This commit is contained in:
Pavel Feldman 2023-08-29 10:56:21 -07:00 committed by GitHub
parent a9bc1a1707
commit 34c6197f9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 94 additions and 45 deletions

View File

@ -113,6 +113,8 @@ Called after all tests have been run, or testing has been interrupted. Note that
* since: v1.10
- `result` <[Object]>
- `status` <[FullStatus]<"passed"|"failed"|"timedout"|"interrupted">>
- `startTime` <[Date]>
- `duration` <[int]>
Result of the full test run.
* `'passed'` - Everything went as expected.

View File

@ -21,7 +21,7 @@
background-color: var(--color-canvas-subtle);
padding: 0 8px;
border-bottom: none;
margin-top: 24px;
margin-top: 12px;
font-weight: 600;
line-height: 38px;
white-space: nowrap;
@ -44,6 +44,7 @@
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 16px;
margin-bottom: 12px;
}
.chip-body-no-insets {

View File

@ -41,10 +41,11 @@ export const TestFilesView: React.FC<{
return result;
}, [report, filter]);
return <>
<div className='p-2' style={{ display: 'flex' }}>
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
{projectNames.length === 1 && !!projectNames[0] && <div data-testid="project-name" style={{ color: 'var(--color-fg-subtle)' }}>Project: {projectNames[0]}</div>}
{!filter.empty() && <div data-testid="filtered-tests-count" style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total}</div>}
<div style={{ flex: 'auto' }}></div>
<div data-testid="overall-time" style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid="overall-duration" style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(filteredStats.duration)}</div>
</div>
{report && filteredFiles.map(({ file, defaultExpanded }) => {

View File

@ -23,11 +23,10 @@ export type Stats = {
flaky: number;
skipped: number;
ok: boolean;
duration: number;
};
export type FilteredStats = {
total: number
total: number,
duration: number,
};
@ -42,6 +41,8 @@ export type HTMLReport = {
files: TestFileSummary[];
stats: Stats;
projectNames: string[];
startTime: number;
duration: number;
};
export type TestFile = {

View File

@ -115,6 +115,12 @@ export type JsonTestStepEnd = {
error?: TestError;
};
export type JsonFullResult = {
status: FullResult['status'];
startTime: number;
duration: number;
};
export type JsonEvent = {
method: string;
params: any
@ -300,8 +306,12 @@ export class TeleReporterReceiver {
}
}
private _onEnd(result: FullResult): Promise<void> | void {
return this._reporter.onEnd?.(result);
private _onEnd(result: JsonFullResult): Promise<void> | void {
return this._reporter.onEnd?.({
status: result.status,
startTime: new Date(result.startTime),
duration: result.duration,
});
}
private _onExit(): Promise<void> | void {

View File

@ -18,7 +18,7 @@ import { colors, ms as milliseconds, parseStackTraceLine } from 'playwright-core
import path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate';
import { getPackageManagerExecCommand, monotonicTime } from 'playwright-core/lib/utils';
import { getPackageManagerExecCommand } from 'playwright-core/lib/utils';
import type { ReporterV2 } from './reporterV2';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
export const kOutputSymbol = Symbol('output');
@ -45,13 +45,11 @@ type TestSummary = {
};
export class BaseReporter implements ReporterV2 {
duration = 0;
config!: FullConfig;
suite!: Suite;
totalTestCount = 0;
result!: FullResult;
private fileDurations = new Map<string, number>();
private monotonicStartTime: number = 0;
private _omitFailures: boolean;
private readonly _ttyWidthForTest: number;
private _fatalErrors: TestError[] = [];
@ -71,7 +69,6 @@ export class BaseReporter implements ReporterV2 {
}
onBegin(suite: Suite) {
this.monotonicStartTime = monotonicTime();
this.suite = suite;
this.totalTestCount = suite.allTests().length;
}
@ -114,7 +111,6 @@ export class BaseReporter implements ReporterV2 {
}
async onEnd(result: FullResult) {
this.duration = monotonicTime() - this.monotonicStartTime;
this.result = result;
}
@ -182,7 +178,7 @@ export class BaseReporter implements ReporterV2 {
if (skipped)
tokens.push(colors.yellow(` ${skipped} skipped`));
if (expected)
tokens.push(colors.green(` ${expected} passed`) + colors.dim(` (${milliseconds(this.duration)})`));
tokens.push(colors.green(` ${expected} passed`) + colors.dim(` (${milliseconds(this.result.duration)})`));
if (this.result.status === 'timedout')
tokens.push(colors.red(` Timed out waiting ${this.config.globalTimeout / 1000}s for the entire test run`));
if (fatalErrors.length && expected + unexpected.length + interrupted.length + flaky.length > 0)

View File

@ -22,7 +22,7 @@ import type { TransformCallback } from 'stream';
import { Transform } from 'stream';
import { toPosixPath } from './json';
import { codeFrameColumns } from '../transform/babelBundle';
import type { FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic } from '../../types/testReporter';
import type { FullResult, FullConfig, Location, Suite, TestCase as TestCasePublic, TestResult as TestResultPublic, TestStep as TestStepPublic } from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate';
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { formatResultFailure, stripAnsiEscapes } from './base';
@ -40,7 +40,6 @@ type TestEntry = {
testCaseSummary: TestCaseSummary
};
const htmlReportOptions = ['always', 'never', 'on-failure'];
type HtmlReportOpenOption = (typeof htmlReportOptions)[number];
@ -112,11 +111,11 @@ class HtmlReporter extends EmptyReporter {
};
}
override async onEnd() {
override async onEnd(result: FullResult) {
const projectSuites = this.suite.suites;
await removeFolders([this._outputFolder]);
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL);
this._buildResult = await builder.build(this.config.metadata, projectSuites);
this._buildResult = await builder.build(this.config.metadata, projectSuites, result);
}
override async onExit() {
@ -218,7 +217,7 @@ class HtmlBuilder {
this._attachmentsBaseURL = attachmentsBaseURL;
}
async build(metadata: Metadata, projectSuites: Suite[]): Promise<{ ok: boolean, singleTestId: string | undefined }> {
async build(metadata: Metadata, projectSuites: Suite[], result: FullResult): Promise<{ ok: boolean, singleTestId: string | undefined }> {
const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
for (const projectSuite of projectSuites) {
@ -257,7 +256,6 @@ class HtmlBuilder {
if (test.outcome === 'flaky')
++stats.flaky;
++stats.total;
stats.duration += test.duration;
}
stats.ok = stats.unexpected + stats.flaky === 0;
if (!stats.ok)
@ -274,9 +272,11 @@ class HtmlBuilder {
}
const htmlReport: HTMLReport = {
metadata,
startTime: result.startTime.getTime(),
duration: result.duration,
files: [...data.values()].map(e => e.testFileSummary),
projectNames: projectSuites.map(r => r.project()!.name),
stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()), duration: metadata.totalTime }
stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()) }
};
htmlReport.files.sort((f1, f2) => {
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
@ -501,7 +501,6 @@ const emptyStats = (): Stats => {
flaky: 0,
skipped: 0,
ok: true,
duration: 0,
};
};
@ -512,7 +511,6 @@ const addStats = (stats: Stats, delta: Stats): Stats => {
stats.unexpected += delta.unexpected;
stats.flaky += delta.flaky;
stats.ok = stats.ok && delta.ok;
stats.duration += delta.duration;
return stats;
};

View File

@ -21,11 +21,14 @@ import type { FullConfig, TestCase, TestError, TestResult, FullResult, TestStep
import { Suite } from '../common/test';
import { prepareErrorStack, relativeFilePath } from './base';
import type { ReporterV2 } from './reporterV2';
import { monotonicTime } from 'playwright-core/lib/utils';
export class InternalReporter implements ReporterV2 {
export class InternalReporter {
private _reporter: ReporterV2;
private _didBegin = false;
private _config!: FullConfig;
private _startTime: Date | undefined;
private _monotonicStartTime: number | undefined;
constructor(reporter: ReporterV2) {
this._reporter = reporter;
@ -37,6 +40,8 @@ export class InternalReporter implements ReporterV2 {
onConfigure(config: FullConfig) {
this._config = config;
this._startTime = new Date();
this._monotonicStartTime = monotonicTime();
this._reporter.onConfigure(config);
}
@ -62,12 +67,16 @@ export class InternalReporter implements ReporterV2 {
this._reporter.onTestEnd(test, result);
}
async onEnd(result: FullResult) {
async onEnd(result: { status: FullResult['status'] }) {
if (!this._didBegin) {
// onBegin was not reported, emit it.
this.onBegin(new Suite('', 'root'));
}
await this._reporter.onEnd(result);
await this._reporter.onEnd({
...result,
startTime: this._startTime!,
duration: monotonicTime() - this._monotonicStartTime!,
});
}
async onExit() {

View File

@ -17,9 +17,8 @@
import fs from 'fs';
import path from 'path';
import type { ReporterDescription } from '../../types/test';
import type { FullResult } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config';
import type { JsonConfig, JsonEvent, JsonProject, JsonSuite, JsonTestResultEnd } from '../isomorphic/teleReceiver';
import type { JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonSuite, JsonTestResultEnd } from '../isomorphic/teleReceiver';
import { TeleReporterReceiver } from '../isomorphic/teleReceiver';
import { JsonStringInternalizer, StringInternPool } from '../isomorphic/stringInternPool';
import { createReporters } from '../runner/reporters';
@ -228,7 +227,6 @@ function mergeConfigureEvents(configureEvents: JsonEvent[]): JsonEvent {
globalTimeout: 0,
maxFailures: 0,
metadata: {
totalTime: 0,
},
rootDir: '',
version: '',
@ -252,7 +250,6 @@ function mergeConfigs(to: JsonConfig, from: JsonConfig): JsonConfig {
metadata: {
...to.metadata,
...from.metadata,
totalTime: to.metadata.totalTime + from.metadata.totalTime,
actualWorkers: (to.metadata.actualWorkers || 0) + (from.metadata.actualWorkers || 0),
},
workers: to.workers + from.workers,
@ -260,16 +257,26 @@ function mergeConfigs(to: JsonConfig, from: JsonConfig): JsonConfig {
}
function mergeEndEvents(endEvents: JsonEvent[]): JsonEvent {
const result: FullResult = { status: 'passed' };
let startTime = endEvents.length ? 10000000000000 : Date.now();
let status: JsonFullResult['status'] = 'passed';
let duration: number = 0;
for (const event of endEvents) {
const shardResult: FullResult = event.params.result;
const shardResult: JsonFullResult = event.params.result;
if (shardResult.status === 'failed')
result.status = 'failed';
else if (shardResult.status === 'timedout' && result.status !== 'failed')
result.status = 'timedout';
else if (shardResult.status === 'interrupted' && result.status !== 'failed' && result.status !== 'timedout')
result.status = 'interrupted';
status = 'failed';
else if (shardResult.status === 'timedout' && status !== 'failed')
status = 'timedout';
else if (shardResult.status === 'interrupted' && status !== 'failed' && status !== 'timedout')
status = 'interrupted';
startTime = Math.min(startTime, shardResult.startTime);
duration = Math.max(duration, shardResult.duration);
}
const result: JsonFullResult = {
status,
startTime,
duration,
};
return {
method: 'onEnd',
params: {

View File

@ -20,7 +20,7 @@ import type { SuitePrivate } from '../../types/reporterPrivate';
import type { FullConfig, FullResult, Location, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter';
import { FullConfigInternal, getProjectId } from '../common/config';
import type { Suite } from '../common/test';
import type { JsonAttachment, JsonConfig, JsonEvent, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver';
import type { JsonAttachment, JsonConfig, JsonEvent, JsonFullResult, JsonProject, JsonStdIOType, JsonSuite, JsonTestCase, JsonTestEnd, JsonTestResultEnd, JsonTestResultStart, JsonTestStepEnd, JsonTestStepStart } from '../isomorphic/teleReceiver';
import { serializeRegexPatterns } from '../isomorphic/teleReceiver';
import type { ReporterV2 } from './reporterV2';
@ -125,7 +125,17 @@ export class TeleReporterEmitter implements ReporterV2 {
}
async onEnd(result: FullResult) {
this._messageSink({ method: 'onEnd', params: { result } });
const resultPayload: JsonFullResult = {
status: result.status,
startTime: result.startTime.getTime(),
duration: result.duration,
};
this._messageSink({
method: 'onEnd',
params: {
result: resultPayload
}
});
}
async onExit() {

View File

@ -29,7 +29,6 @@ import { collectProjectsAndTestFiles, createRootSuite, loadFileSuites, loadGloba
import type { Matcher } from '../util';
import type { Suite } from '../common/test';
import { buildDependentProjects, buildTeardownToSetupsMap } from './projectUtils';
import { monotonicTime } from 'playwright-core/lib/utils';
const readDirAsync = promisify(fs.readdir);
@ -105,15 +104,11 @@ export function createTaskRunnerForList(config: FullConfigInternal, reporter: Re
}
function createReportBeginTask(): Task<TestRun> {
let montonicStartTime = 0;
return {
setup: async ({ config, reporter, rootSuite }) => {
montonicStartTime = monotonicTime();
setup: async ({ reporter, rootSuite }) => {
reporter.onBegin(rootSuite!);
},
teardown: async ({ config }) => {
config.config.metadata.totalTime = monotonicTime() - montonicStartTime;
},
teardown: async ({}) => {},
};
}

View File

@ -305,6 +305,16 @@ export interface FullResult {
* - 'interrupted' - interrupted by the user.
*/
status: 'passed' | 'failed' | 'timedout' | 'interrupted';
/**
* Test start wall time.
*/
startTime: Date;
/**
* Test duration in milliseconds.
*/
duration: number;
}
/**

View File

@ -996,7 +996,6 @@ test('preserve config fields', async ({ runInlineTest, mergeReports }) => {
expect(json.globalTimeout).toBe(config.globalTimeout);
expect(json.maxFailures).toBe(config.maxFailures);
expect(json.metadata).toEqual(expect.objectContaining(config.metadata));
expect(json.metadata.totalTime).toBeTruthy();
expect(json.workers).toBe(2);
expect(json.version).toBeTruthy();
expect(json.version).not.toEqual(test.info().config.version);

View File

@ -41,6 +41,16 @@ export interface FullResult {
* - 'interrupted' - interrupted by the user.
*/
status: 'passed' | 'failed' | 'timedout' | 'interrupted';
/**
* Test start wall time.
*/
startTime: Date;
/**
* Test duration in milliseconds.
*/
duration: number;
}
export interface Reporter {