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 { Link, navigate } from './links';
import { statusIcon } from './statusIcon';
import { msToString } from './uiUtils';
export const HeaderView: React.FC<React.PropsWithChildren<{
stats: Stats,
@ -37,23 +38,26 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
})();
});
return <div className='pt-3'>
<div className='header-view-status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={stats}></StatsNavView>
return (<>
<div className='pt-3'>
<div className='header-view-status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={stats}></StatsNavView>
</div>
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
{icons.search()}
{/* Use navigationId to reset defaultValue */}
<input type='search' spellCheck={false} className='form-control subnav-search-input input-contrast width-full' value={filterText} onChange={e => {
setFilterText(e.target.value);
}}></input>
</form>
</div>
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
{icons.search()}
{/* Use navigationId to reset defaultValue */}
<input type='search' spellCheck={false} className='form-control subnav-search-input input-contrast width-full' value={filterText} onChange={e => {
setFilterText(e.target.value);
}}></input>
</form>
</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<{

View file

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

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Metadata } from '@protocol/channels';
import type { Metadata } from '@playwright/test';
export type Stats = {
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 { FullConfigInternal, ReporterInternal } from '../types';
import { codeFrameColumns } from '../babelBundle';
import { monotonicTime } from 'playwright-core/lib/utils';
export type TestResultOutput = { chunk: string | Buffer, type: 'stdout' | 'stderr' };
export const kOutputSymbol = Symbol('output');
@ -447,11 +448,6 @@ export function prepareErrorStack(stack: string): {
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');
export function stripAnsiEscapes(str: string): string {
return str.replace(ansiRegex, '');

View file

@ -22,7 +22,7 @@ import type { TransformCallback } from 'stream';
import { Transform } from 'stream';
import type { FullConfig, Suite } from '../../types/testReporter';
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 type { JsonAttachment, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw';
import RawReporter from './raw';
@ -52,6 +52,7 @@ type HtmlReporterOptions = {
class HtmlReporter implements ReporterInternal {
private config!: FullConfigInternal;
private suite!: Suite;
private _montonicStartTime: number = 0;
private _options: HtmlReporterOptions;
private _outputFolder!: string;
private _open: string | undefined;
@ -66,6 +67,7 @@ class HtmlReporter implements ReporterInternal {
}
onBegin(config: FullConfig, suite: Suite) {
this._montonicStartTime = monotonicTime();
this.config = config as FullConfigInternal;
const { outputFolder, open } = this._resolveOptions();
this._outputFolder = outputFolder;
@ -100,6 +102,7 @@ class HtmlReporter implements ReporterInternal {
}
async onEnd() {
const duration = monotonicTime() - this._montonicStartTime;
const projectSuites = this.suite.suites;
const reports = projectSuites.map(suite => {
const rawReporter = new RawReporter();
@ -108,7 +111,7 @@ class HtmlReporter implements ReporterInternal {
});
await removeFolders([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() {
@ -201,7 +204,7 @@ class HtmlBuilder {
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 }>();
for (const projectJson of rawReports) {
@ -260,7 +263,7 @@ class HtmlBuilder {
metadata,
files: [...data.values()].map(e => e.testFileSummary),
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) => {
const w1 = f1.stats.unexpected * 1000 + f1.stats.flaky;

View file

@ -301,4 +301,4 @@ export async function normalizeAndSaveAttachment(outputPath: string, name: strin
const contentType = options.contentType ?? (typeof options.body === 'string' ? 'text/plain' : 'application/octet-stream');
return { name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body };
}
}
}

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-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();
});