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 * since: v1.10
- `result` <[Object]> - `result` <[Object]>
- `status` <[FullStatus]<"passed"|"failed"|"timedout"|"interrupted">> - `status` <[FullStatus]<"passed"|"failed"|"timedout"|"interrupted">>
- `startTime` <[Date]>
- `duration` <[int]>
Result of the full test run. Result of the full test run.
* `'passed'` - Everything went as expected. * `'passed'` - Everything went as expected.

View File

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

View File

@ -41,10 +41,11 @@ export const TestFilesView: React.FC<{
return result; return result;
}, [report, filter]); }, [report, filter]);
return <> 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>} {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>} {!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 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 data-testid="overall-duration" style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(filteredStats.duration)}</div>
</div> </div>
{report && filteredFiles.map(({ file, defaultExpanded }) => { {report && filteredFiles.map(({ file, defaultExpanded }) => {

View File

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

View File

@ -115,6 +115,12 @@ export type JsonTestStepEnd = {
error?: TestError; error?: TestError;
}; };
export type JsonFullResult = {
status: FullResult['status'];
startTime: number;
duration: number;
};
export type JsonEvent = { export type JsonEvent = {
method: string; method: string;
params: any params: any
@ -300,8 +306,12 @@ export class TeleReporterReceiver {
} }
} }
private _onEnd(result: FullResult): Promise<void> | void { private _onEnd(result: JsonFullResult): Promise<void> | void {
return this._reporter.onEnd?.(result); return this._reporter.onEnd?.({
status: result.status,
startTime: new Date(result.startTime),
duration: result.duration,
});
} }
private _onExit(): Promise<void> | void { 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 path from 'path';
import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter'; import type { FullConfig, TestCase, Suite, TestResult, TestError, FullResult, TestStep, Location } from '../../types/testReporter';
import type { SuitePrivate } from '../../types/reporterPrivate'; 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'; import type { ReporterV2 } from './reporterV2';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' }; export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
export const kOutputSymbol = Symbol('output'); export const kOutputSymbol = Symbol('output');
@ -45,13 +45,11 @@ type TestSummary = {
}; };
export class BaseReporter implements ReporterV2 { export class BaseReporter implements ReporterV2 {
duration = 0;
config!: FullConfig; config!: FullConfig;
suite!: Suite; suite!: Suite;
totalTestCount = 0; totalTestCount = 0;
result!: FullResult; result!: FullResult;
private fileDurations = new Map<string, number>(); private fileDurations = new Map<string, number>();
private monotonicStartTime: number = 0;
private _omitFailures: boolean; private _omitFailures: boolean;
private readonly _ttyWidthForTest: number; private readonly _ttyWidthForTest: number;
private _fatalErrors: TestError[] = []; private _fatalErrors: TestError[] = [];
@ -71,7 +69,6 @@ export class BaseReporter implements ReporterV2 {
} }
onBegin(suite: Suite) { onBegin(suite: Suite) {
this.monotonicStartTime = monotonicTime();
this.suite = suite; this.suite = suite;
this.totalTestCount = suite.allTests().length; this.totalTestCount = suite.allTests().length;
} }
@ -114,7 +111,6 @@ export class BaseReporter implements ReporterV2 {
} }
async onEnd(result: FullResult) { async onEnd(result: FullResult) {
this.duration = monotonicTime() - this.monotonicStartTime;
this.result = result; this.result = result;
} }
@ -182,7 +178,7 @@ export class BaseReporter implements ReporterV2 {
if (skipped) if (skipped)
tokens.push(colors.yellow(` ${skipped} skipped`)); tokens.push(colors.yellow(` ${skipped} skipped`));
if (expected) 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') if (this.result.status === 'timedout')
tokens.push(colors.red(` Timed out waiting ${this.config.globalTimeout / 1000}s for the entire test run`)); 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) 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 { Transform } from 'stream';
import { toPosixPath } from './json'; import { toPosixPath } from './json';
import { codeFrameColumns } from '../transform/babelBundle'; 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 type { SuitePrivate } from '../../types/reporterPrivate';
import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils'; import { HttpServer, assert, calculateSha1, copyFileAndMakeWritable, gracefullyProcessExitDoNotHang, removeFolders, sanitizeForFilePath } from 'playwright-core/lib/utils';
import { formatResultFailure, stripAnsiEscapes } from './base'; import { formatResultFailure, stripAnsiEscapes } from './base';
@ -40,7 +40,6 @@ type TestEntry = {
testCaseSummary: TestCaseSummary testCaseSummary: TestCaseSummary
}; };
const htmlReportOptions = ['always', 'never', 'on-failure']; const htmlReportOptions = ['always', 'never', 'on-failure'];
type HtmlReportOpenOption = (typeof htmlReportOptions)[number]; 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; const projectSuites = this.suite.suites;
await removeFolders([this._outputFolder]); await removeFolders([this._outputFolder]);
const builder = new HtmlBuilder(this.config, this._outputFolder, this._attachmentsBaseURL); 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() { override async onExit() {
@ -218,7 +217,7 @@ class HtmlBuilder {
this._attachmentsBaseURL = attachmentsBaseURL; 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 }>(); const data = new Map<string, { testFile: TestFile, testFileSummary: TestFileSummary }>();
for (const projectSuite of projectSuites) { for (const projectSuite of projectSuites) {
@ -257,7 +256,6 @@ class HtmlBuilder {
if (test.outcome === 'flaky') if (test.outcome === 'flaky')
++stats.flaky; ++stats.flaky;
++stats.total; ++stats.total;
stats.duration += test.duration;
} }
stats.ok = stats.unexpected + stats.flaky === 0; stats.ok = stats.unexpected + stats.flaky === 0;
if (!stats.ok) if (!stats.ok)
@ -274,9 +272,11 @@ class HtmlBuilder {
} }
const htmlReport: HTMLReport = { const htmlReport: HTMLReport = {
metadata, metadata,
startTime: result.startTime.getTime(),
duration: result.duration,
files: [...data.values()].map(e => e.testFileSummary), files: [...data.values()].map(e => e.testFileSummary),
projectNames: projectSuites.map(r => r.project()!.name), 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) => { htmlReport.files.sort((f1, f2) => {
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky; const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;
@ -501,7 +501,6 @@ const emptyStats = (): Stats => {
flaky: 0, flaky: 0,
skipped: 0, skipped: 0,
ok: true, ok: true,
duration: 0,
}; };
}; };
@ -512,7 +511,6 @@ const addStats = (stats: Stats, delta: Stats): Stats => {
stats.unexpected += delta.unexpected; stats.unexpected += delta.unexpected;
stats.flaky += delta.flaky; stats.flaky += delta.flaky;
stats.ok = stats.ok && delta.ok; stats.ok = stats.ok && delta.ok;
stats.duration += delta.duration;
return stats; return stats;
}; };

View File

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

View File

@ -17,9 +17,8 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import type { ReporterDescription } from '../../types/test'; import type { ReporterDescription } from '../../types/test';
import type { FullResult } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config'; 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 { TeleReporterReceiver } from '../isomorphic/teleReceiver';
import { JsonStringInternalizer, StringInternPool } from '../isomorphic/stringInternPool'; import { JsonStringInternalizer, StringInternPool } from '../isomorphic/stringInternPool';
import { createReporters } from '../runner/reporters'; import { createReporters } from '../runner/reporters';
@ -228,7 +227,6 @@ function mergeConfigureEvents(configureEvents: JsonEvent[]): JsonEvent {
globalTimeout: 0, globalTimeout: 0,
maxFailures: 0, maxFailures: 0,
metadata: { metadata: {
totalTime: 0,
}, },
rootDir: '', rootDir: '',
version: '', version: '',
@ -252,7 +250,6 @@ function mergeConfigs(to: JsonConfig, from: JsonConfig): JsonConfig {
metadata: { metadata: {
...to.metadata, ...to.metadata,
...from.metadata, ...from.metadata,
totalTime: to.metadata.totalTime + from.metadata.totalTime,
actualWorkers: (to.metadata.actualWorkers || 0) + (from.metadata.actualWorkers || 0), actualWorkers: (to.metadata.actualWorkers || 0) + (from.metadata.actualWorkers || 0),
}, },
workers: to.workers + from.workers, workers: to.workers + from.workers,
@ -260,16 +257,26 @@ function mergeConfigs(to: JsonConfig, from: JsonConfig): JsonConfig {
} }
function mergeEndEvents(endEvents: JsonEvent[]): JsonEvent { 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) { for (const event of endEvents) {
const shardResult: FullResult = event.params.result; const shardResult: JsonFullResult = event.params.result;
if (shardResult.status === 'failed') if (shardResult.status === 'failed')
result.status = 'failed'; status = 'failed';
else if (shardResult.status === 'timedout' && result.status !== 'failed') else if (shardResult.status === 'timedout' && status !== 'failed')
result.status = 'timedout'; status = 'timedout';
else if (shardResult.status === 'interrupted' && result.status !== 'failed' && result.status !== 'timedout') else if (shardResult.status === 'interrupted' && status !== 'failed' && status !== 'timedout')
result.status = 'interrupted'; status = 'interrupted';
startTime = Math.min(startTime, shardResult.startTime);
duration = Math.max(duration, shardResult.duration);
} }
const result: JsonFullResult = {
status,
startTime,
duration,
};
return { return {
method: 'onEnd', method: 'onEnd',
params: { 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 type { FullConfig, FullResult, Location, TestCase, TestError, TestResult, TestStep } from '../../types/testReporter';
import { FullConfigInternal, getProjectId } from '../common/config'; import { FullConfigInternal, getProjectId } from '../common/config';
import type { Suite } from '../common/test'; 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 { serializeRegexPatterns } from '../isomorphic/teleReceiver';
import type { ReporterV2 } from './reporterV2'; import type { ReporterV2 } from './reporterV2';
@ -125,7 +125,17 @@ export class TeleReporterEmitter implements ReporterV2 {
} }
async onEnd(result: FullResult) { 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() { async onExit() {

View File

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

View File

@ -305,6 +305,16 @@ export interface FullResult {
* - 'interrupted' - interrupted by the user. * - 'interrupted' - interrupted by the user.
*/ */
status: 'passed' | 'failed' | 'timedout' | 'interrupted'; 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.globalTimeout).toBe(config.globalTimeout);
expect(json.maxFailures).toBe(config.maxFailures); expect(json.maxFailures).toBe(config.maxFailures);
expect(json.metadata).toEqual(expect.objectContaining(config.metadata)); expect(json.metadata).toEqual(expect.objectContaining(config.metadata));
expect(json.metadata.totalTime).toBeTruthy();
expect(json.workers).toBe(2); expect(json.workers).toBe(2);
expect(json.version).toBeTruthy(); expect(json.version).toBeTruthy();
expect(json.version).not.toEqual(test.info().config.version); expect(json.version).not.toEqual(test.info().config.version);

View File

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