chore: include start/endTime and duration in onEnd report callback (#26760)
Fixes https://github.com/microsoft/playwright/issues/23637
This commit is contained in:
		
							parent
							
								
									a9bc1a1707
								
							
						
					
					
						commit
						34c6197f9e
					
				|  | @ -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. | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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 }) => { | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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) | ||||
|  |  | |||
|  | @ -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; | ||||
| }; | ||||
| 
 | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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: { | ||||
|  |  | |||
|  | @ -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() { | ||||
|  |  | |||
|  | @ -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 ({}) => {}, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  |  | |||
|  | @ -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); | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue