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:
parent
95cc5c2a2e
commit
0844394270
|
|
@ -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,23 +38,26 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
return <div className='pt-3'>
|
return (<>
|
||||||
<div className='header-view-status-container ml-2 pl-2 d-flex'>
|
<div className='pt-3'>
|
||||||
<StatsNavView stats={stats}></StatsNavView>
|
<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>
|
</div>
|
||||||
<form className='subnav-search' onSubmit={
|
<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>
|
||||||
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>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatsNavView: React.FC<{
|
const StatsNavView: React.FC<{
|
||||||
|
|
|
||||||
|
|
@ -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 =>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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, '');
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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');
|
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 };
|
return { name, contentType, body: typeof options.body === 'string' ? Buffer.from(options.body) : options.body };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue