feat(html): display overall duration (#19576)

<img width="1390" alt="Screenshot 2022-12-19 at 4 15 33 PM"
src="https://user-images.githubusercontent.com/11915034/208552484-c0127615-d2c6-414f-ae3b-e7836553d890.png">

* Adds duration (time ellapsed from `onBegin` to `onEnd`); roughly
equivalent to `time npx playwright test …`.
* Removes cumulative per-file time

Resolves #19566.
This commit is contained in:
Ross Wollman 2022-12-20 17:13:10 -05:00 committed by GitHub
parent 95cc5c2a2e
commit 0844394270
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 32 additions and 28 deletions

View file

@ -22,6 +22,7 @@ import './headerView.css';
import * as icons from './icons'; import * as icons from './icons';
import { Link, navigate } from './links'; import { Link, navigate } from './links';
import { statusIcon } from './statusIcon'; import { statusIcon } from './statusIcon';
import { msToString } from './uiUtils';
export const HeaderView: React.FC<React.PropsWithChildren<{ export const HeaderView: React.FC<React.PropsWithChildren<{
stats: Stats, stats: Stats,
@ -37,7 +38,8 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
})(); })();
}); });
return <div className='pt-3'> return (<>
<div className='pt-3'>
<div className='header-view-status-container ml-2 pl-2 d-flex'> <div className='header-view-status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={stats}></StatsNavView> <StatsNavView stats={stats}></StatsNavView>
</div> </div>
@ -53,7 +55,9 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
setFilterText(e.target.value); setFilterText(e.target.value);
}}></input> }}></input>
</form> </form>
</div>; </div>
<div className='pt-2'><span data-testid="overall-duration" style={{ color: 'var(--color-fg-subtle)', paddingRight: '10px', float: 'right' }}>Total time: {msToString(stats.duration)}</span></div>
</>);
}; };
const StatsNavView: React.FC<{ const StatsNavView: React.FC<{

View file

@ -36,7 +36,6 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
noInsets={true} noInsets={true}
setExpanded={(expanded => setFileExpanded(file.fileId, expanded))} setExpanded={(expanded => setFileExpanded(file.fileId, expanded))}
header={<span> header={<span>
<span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span>
{file.fileName} {file.fileName}
</span>}> </span>}>
{file.tests.filter(t => filter.matches(t)).map(test => {file.tests.filter(t => filter.matches(t)).map(test =>

View file

@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import type { Metadata } from '@protocol/channels'; import type { Metadata } from '@playwright/test';
export type Stats = { export type Stats = {
total: number; total: number;

View file

@ -20,6 +20,7 @@ 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 { FullConfigInternal, ReporterInternal } from '../types'; import type { FullConfigInternal, ReporterInternal } from '../types';
import { codeFrameColumns } from '../babelBundle'; import { codeFrameColumns } from '../babelBundle';
import { monotonicTime } from 'playwright-core/lib/utils';
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');
@ -447,11 +448,6 @@ export function prepareErrorStack(stack: string): {
return { message, stackLines, location }; return { message, stackLines, location };
} }
function monotonicTime(): number {
const [seconds, nanoseconds] = process.hrtime();
return seconds * 1000 + (nanoseconds / 1000000 | 0);
}
const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g'); const ansiRegex = new RegExp('([\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~])))', 'g');
export function stripAnsiEscapes(str: string): string { export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, ''); return str.replace(ansiRegex, '');

View file

@ -22,7 +22,7 @@ import type { TransformCallback } from 'stream';
import { Transform } from 'stream'; import { Transform } from 'stream';
import type { FullConfig, Suite } from '../../types/testReporter'; import type { FullConfig, Suite } from '../../types/testReporter';
import { HttpServer } from 'playwright-core/lib/utils/httpServer'; import { HttpServer } from 'playwright-core/lib/utils/httpServer';
import { assert, calculateSha1 } from 'playwright-core/lib/utils'; import { assert, calculateSha1, monotonicTime } from 'playwright-core/lib/utils';
import { copyFileAndMakeWritable, removeFolders } from 'playwright-core/lib/utils/fileUtils'; import { copyFileAndMakeWritable, removeFolders } from 'playwright-core/lib/utils/fileUtils';
import type { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw'; import type { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw';
import RawReporter from './raw'; import RawReporter from './raw';
@ -52,6 +52,7 @@ type HtmlReporterOptions = {
class HtmlReporter implements ReporterInternal { class HtmlReporter implements ReporterInternal {
private config!: FullConfigInternal; private config!: FullConfigInternal;
private suite!: Suite; private suite!: Suite;
private _montonicStartTime: number = 0;
private _options: HtmlReporterOptions; private _options: HtmlReporterOptions;
private _outputFolder!: string; private _outputFolder!: string;
private _open: string | undefined; private _open: string | undefined;
@ -66,6 +67,7 @@ class HtmlReporter implements ReporterInternal {
} }
onBegin(config: FullConfig, suite: Suite) { onBegin(config: FullConfig, suite: Suite) {
this._montonicStartTime = monotonicTime();
this.config = config as FullConfigInternal; this.config = config as FullConfigInternal;
const { outputFolder, open } = this._resolveOptions(); const { outputFolder, open } = this._resolveOptions();
this._outputFolder = outputFolder; this._outputFolder = outputFolder;
@ -100,6 +102,7 @@ class HtmlReporter implements ReporterInternal {
} }
async onEnd() { async onEnd() {
const duration = monotonicTime() - this._montonicStartTime;
const projectSuites = this.suite.suites; const projectSuites = this.suite.suites;
const reports = projectSuites.map(suite => { const reports = projectSuites.map(suite => {
const rawReporter = new RawReporter(); const rawReporter = new RawReporter();
@ -108,7 +111,7 @@ class HtmlReporter implements ReporterInternal {
}); });
await removeFolders([this._outputFolder]); await removeFolders([this._outputFolder]);
const builder = new HtmlBuilder(this._outputFolder); const builder = new HtmlBuilder(this._outputFolder);
this._buildResult = await builder.build(this.config.metadata, reports); this._buildResult = await builder.build({ ...this.config.metadata, duration }, reports);
} }
async _onExit() { async _onExit() {
@ -201,7 +204,7 @@ class HtmlBuilder {
this._dataZipFile = new yazl.ZipFile(); this._dataZipFile = new yazl.ZipFile();
} }
async build(metadata: Metadata, rawReports: JsonReport[]): Promise<{ ok: boolean, singleTestId: string | undefined }> { async build(metadata: Metadata & { duration: number }, rawReports: JsonReport[]): 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 projectJson of rawReports) { for (const projectJson of rawReports) {
@ -260,7 +263,7 @@ class HtmlBuilder {
metadata, metadata,
files: [...data.values()].map(e => e.testFileSummary), files: [...data.values()].map(e => e.testFileSummary),
projectNames: rawReports.map(r => r.project.name), projectNames: rawReports.map(r => r.project.name),
stats: [...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()) stats: { ...[...data.values()].reduce((a, e) => addStats(a, e.testFileSummary.stats), emptyStats()), duration: metadata.duration }
}; };
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;

View file

@ -70,6 +70,8 @@ test('should generate report', async ({ runInlineTest, showReport, page }) => {
await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible(); await expect(page.locator('.test-file-test-outcome-expected >> text=passes')).toBeVisible();
await expect(page.locator('.test-file-test-outcome-skipped >> text=skipped')).toBeVisible(); await expect(page.locator('.test-file-test-outcome-skipped >> text=skipped')).toBeVisible();
await expect(page.getByTestId('overall-duration')).toContainText(/^Total time: \d+(\.\d+)?(ms|s|m)$/); // e.g. 1.2s
await expect(page.locator('.metadata-view')).not.toBeVisible(); await expect(page.locator('.metadata-view')).not.toBeVisible();
}); });