feat(html): auto-open report (#8908)

This commit is contained in:
Pavel Feldman 2021-09-14 13:55:31 -07:00 committed by GitHub
parent 5141407c6b
commit e91243ac90
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 133 additions and 38 deletions

View File

@ -14,13 +14,25 @@
* limitations under the License. * limitations under the License.
*/ */
import colors from 'colors/safe';
import fs from 'fs'; import fs from 'fs';
import open from 'open';
import path from 'path'; import path from 'path';
import { FullConfig, Suite } from '../../../types/testReporter'; import { FullConfig, Suite } from '../../../types/testReporter';
import { calculateSha1 } from '../../utils/utils'; import { HttpServer } from '../../utils/httpServer';
import { calculateSha1, removeFolders } from '../../utils/utils';
import { toPosixPath } from '../reporters/json'; import { toPosixPath } from '../reporters/json';
import RawReporter, { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw'; import RawReporter, { JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from './raw';
export type Stats = {
total: number;
expected: number;
unexpected: number;
flaky: number;
skipped: number;
ok: boolean;
};
export type Location = { export type Location = {
file: string; file: string;
line: number; line: number;
@ -30,7 +42,7 @@ export type Location = {
export type ProjectTreeItem = { export type ProjectTreeItem = {
name: string; name: string;
suites: SuiteTreeItem[]; suites: SuiteTreeItem[];
failedTests: number; stats: Stats;
}; };
export type SuiteTreeItem = { export type SuiteTreeItem = {
@ -39,7 +51,7 @@ export type SuiteTreeItem = {
duration: number; duration: number;
suites: SuiteTreeItem[]; suites: SuiteTreeItem[];
tests: TestTreeItem[]; tests: TestTreeItem[];
failedTests: number; stats: Stats;
}; };
export type TestTreeItem = { export type TestTreeItem = {
@ -49,6 +61,7 @@ export type TestTreeItem = {
location: Location; location: Location;
duration: number; duration: number;
outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky'; outcome: 'skipped' | 'expected' | 'unexpected' | 'flaky';
ok: boolean;
}; };
export type TestFile = { export type TestFile = {
@ -99,7 +112,26 @@ class HtmlReporter {
return report; return report;
}); });
const reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report'); const reportFolder = path.resolve(process.cwd(), process.env[`PLAYWRIGHT_HTML_REPORT`] || 'playwright-report');
await removeFolders([reportFolder]);
new HtmlBuilder(reports, reportFolder, this.config.rootDir); new HtmlBuilder(reports, reportFolder, this.config.rootDir);
if (!process.env.CI) {
const server = new HttpServer();
server.routePrefix('/', (request, response) => {
let relativePath = request.url!;
if (relativePath === '/')
relativePath = '/index.html';
const absolutePath = path.join(reportFolder, ...relativePath.split('/'));
return server.serveFile(response, absolutePath);
});
const url = await server.start();
console.log('');
console.log(colors.cyan(` Serving HTML report at ${url}. Press Ctrl+C to quit.`));
console.log('');
open(url);
process.on('SIGINT', () => process.exit(0));
await new Promise(() => {});
}
} }
} }
@ -135,7 +167,7 @@ class HtmlBuilder {
projects.push({ projects.push({
name: projectJson.project.name, name: projectJson.project.name,
suites, suites,
failedTests: suites.reduce((a, s) => a + s.failedTests, 0) stats: suites.reduce((a, s) => addStats(a, s.stats), emptyStats()),
}); });
} }
fs.writeFileSync(path.join(dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2)); fs.writeFileSync(path.join(dataFolder, 'projects.json'), JSON.stringify(projects, undefined, 2));
@ -154,11 +186,22 @@ class HtmlBuilder {
const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector)); const suites = suite.suites.map(s => this._createSuiteTreeItem(s, fileId, testCollector));
const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId)); const tests = suite.tests.map(t => this._createTestTreeItem(t, fileId));
testCollector.push(...suite.tests); testCollector.push(...suite.tests);
const stats = suites.reduce<Stats>((a, s) => addStats(a, s.stats), emptyStats());
for (const test of tests) {
if (test.outcome === 'expected')
++stats.expected;
if (test.outcome === 'unexpected')
++stats.unexpected;
if (test.outcome === 'flaky')
++stats.flaky;
++stats.total;
}
stats.ok = stats.unexpected + stats.flaky === 0;
return { return {
title: suite.title, title: suite.title,
location: this._relativeLocation(suite.location), location: this._relativeLocation(suite.location),
duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0), duration: suites.reduce((a, s) => a + s.duration, 0) + tests.reduce((a, t) => a + t.duration, 0),
failedTests: suites.reduce((a, s) => a + s.failedTests, 0) + tests.reduce((a, t) => t.outcome === 'unexpected' || t.outcome === 'flaky' ? a + 1 : a, 0), stats,
suites, suites,
tests tests
}; };
@ -173,7 +216,8 @@ class HtmlBuilder {
location: this._relativeLocation(test.location), location: this._relativeLocation(test.location),
title: test.title, title: test.title,
duration, duration,
outcome: test.outcome outcome: test.outcome,
ok: test.ok
}; };
} }
@ -183,7 +227,7 @@ class HtmlBuilder {
startTime: result.startTime, startTime: result.startTime,
retry: result.retry, retry: result.retry,
steps: result.steps.map(s => this._createTestStep(s)), steps: result.steps.map(s => this._createTestStep(s)),
error: result.error?.message, error: result.error,
status: result.status, status: result.status,
}; };
} }
@ -195,7 +239,7 @@ class HtmlBuilder {
duration: step.duration, duration: step.duration,
steps: step.steps.map(s => this._createTestStep(s)), steps: step.steps.map(s => this._createTestStep(s)),
log: step.log, log: step.log,
error: step.error?.message error: step.error
}; };
} }
@ -210,4 +254,25 @@ class HtmlBuilder {
} }
} }
const emptyStats = (): Stats => {
return {
total: 0,
expected: 0,
unexpected: 0,
flaky: 0,
skipped: 0,
ok: true
};
};
const addStats = (stats: Stats, delta: Stats): Stats => {
stats.total += delta.total;
stats.skipped += delta.skipped;
stats.expected += delta.expected;
stats.unexpected += delta.unexpected;
stats.flaky += delta.flaky;
stats.ok = stats.ok && delta.ok;
return stats;
};
export default HtmlReporter; export default HtmlReporter;

View File

@ -17,13 +17,14 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { FullProject } from '../../../types/test'; import { FullProject } from '../../../types/test';
import { FullConfig, Location, Suite, TestCase, TestError, TestResult, TestStatus, TestStep } from '../../../types/testReporter'; import { FullConfig, Location, Suite, TestCase, TestResult, TestStatus, TestStep } from '../../../types/testReporter';
import { assert, calculateSha1 } from '../../utils/utils'; import { assert, calculateSha1 } from '../../utils/utils';
import { sanitizeForFilePath } from '../util'; import { sanitizeForFilePath } from '../util';
import { formatResultFailure } from './base';
import { serializePatterns } from './json'; import { serializePatterns } from './json';
export type JsonLocation = Location; export type JsonLocation = Location;
export type JsonError = TestError; export type JsonError = string;
export type JsonStackFrame = { file: string, line: number, column: number }; export type JsonStackFrame = { file: string, line: number, column: number };
export type JsonReport = { export type JsonReport = {
@ -187,7 +188,7 @@ class RawReporter {
startTime: result.startTime.toISOString(), startTime: result.startTime.toISOString(),
duration: result.duration, duration: result.duration,
status: result.status, status: result.status,
error: result.error, error: formatResultFailure(test, result, '').join('').trim(),
attachments: this._createAttachments(result), attachments: this._createAttachments(result),
steps: this._serializeSteps(test, result.steps) steps: this._serializeSteps(test, result.steps)
}; };
@ -200,7 +201,7 @@ class RawReporter {
category: step.category, category: step.category,
startTime: step.startTime.toISOString(), startTime: step.startTime.toISOString(),
duration: step.duration, duration: step.duration,
error: step.error, error: step.error?.message,
steps: this._serializeSteps(test, step.steps), steps: this._serializeSteps(test, step.steps),
log: step.data.log || undefined, log: step.data.log || undefined,
}; };

View File

@ -27,7 +27,7 @@ export const TreeItem: React.FunctionComponent<{
const [expanded, setExpanded] = React.useState(expandByDefault || false); const [expanded, setExpanded] = React.useState(expandByDefault || false);
const className = selected ? 'tree-item-title selected' : 'tree-item-title'; const className = selected ? 'tree-item-title selected' : 'tree-item-title';
return <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}> return <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<div className={className} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap', paddingLeft: depth * 20 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} > <div className={className} style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', whiteSpace: 'nowrap', paddingLeft: depth * 16 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
<div className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')} <div className={'codicon codicon-' + (expanded ? 'chevron-down' : 'chevron-right')}
style={{ cursor: 'pointer', color: 'var(--color)', visibility: loadChildren ? 'visible' : 'hidden' }} /> style={{ cursor: 'pointer', color: 'var(--color)', visibility: loadChildren ? 'visible' : 'hidden' }} />
{title} {title}

View File

@ -51,8 +51,9 @@
color: white; color: white;
padding: 5px; padding: 5px;
overflow: auto; overflow: auto;
margin: 20px 0; margin: 20px;
flex: none; flex: none;
box-shadow: var(--box-shadow);
} }
.status-icon { .status-icon {
@ -164,9 +165,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 10px 10px; padding: 0 10px 10px;
color: var(--blue);
text-decoration: underline;
cursor: pointer;
} }
.test-details-column { .test-details-column {
@ -183,3 +181,24 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.stats {
background-color: gray;
border-radius: 2px;
min-width: 10px;
color: white;
margin: 0 2px;
padding: 0 2px;
}
.stats.expected {
background-color: var(--green);
}
.stats.unexpected {
background-color: var(--red);
}
.stats.flaky {
background-color: var(--yellow);
}

View File

@ -21,7 +21,7 @@ import { SplitView } from '../components/splitView';
import { TreeItem } from '../components/treeItem'; import { TreeItem } from '../components/treeItem';
import { TabbedPane } from '../traceViewer/ui/tabbedPane'; import { TabbedPane } from '../traceViewer/ui/tabbedPane';
import { msToString } from '../uiUtils'; import { msToString } from '../uiUtils';
import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile } from '../../test/reporters/html'; import type { ProjectTreeItem, SuiteTreeItem, TestCase, TestResult, TestStep, TestTreeItem, Location, TestFile, Stats } from '../../test/reporters/html';
type Filter = 'Failing' | 'All'; type Filter = 'Failing' | 'All';
@ -60,7 +60,7 @@ export const Report: React.FC = () => {
}}>{item}</div>; }}>{item}</div>;
}) })
}</div> }</div>
{!fetchError && filter === 'All' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId}></ProjectTreeItemView>)} {!fetchError && filter === 'All' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={false}></ProjectTreeItemView>)}
{!fetchError && filter === 'Failing' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)} {!fetchError && filter === 'Failing' && report?.map((project, i) => <ProjectTreeItemView key={i} project={project} setTestId={setTestId} testId={testId} failingOnly={true}></ProjectTreeItemView>)}
</div> </div>
</SplitView> </SplitView>
@ -71,49 +71,52 @@ const ProjectTreeItemView: React.FC<{
project: ProjectTreeItem; project: ProjectTreeItem;
testId?: TestId, testId?: TestId,
setTestId: (id: TestId) => void; setTestId: (id: TestId) => void;
failingOnly?: boolean; failingOnly: boolean;
}> = ({ project, testId, setTestId, failingOnly }) => { }> = ({ project, testId, setTestId, failingOnly }) => {
const hasChildren = !(failingOnly && project.stats.ok);
return <TreeItem title={<div className='hbox'> return <TreeItem title={<div className='hbox'>
{statusIconForFailedTests(project.failedTests)}<div className='tree-text'>{project.name || 'Project'}</div> <div className='tree-text'>{project.name || 'Project'}</div>
<div style={{ flex: 'auto' }}></div>
<StatsView stats={project.stats}></StatsView>
</div> </div>
} loadChildren={() => { } loadChildren={hasChildren ? () => {
return project.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} showFileName={true}></SuiteTreeItemView>) || []; return project.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={1} showFileName={true} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
}} depth={0} expandByDefault={true}></TreeItem>; } : undefined} depth={0} expandByDefault={true}></TreeItem>;
}; };
const SuiteTreeItemView: React.FC<{ const SuiteTreeItemView: React.FC<{
suite: SuiteTreeItem, suite: SuiteTreeItem,
testId?: TestId, testId?: TestId,
setTestId: (id: TestId) => void; setTestId: (id: TestId) => void;
failingOnly: boolean;
depth: number, depth: number,
showFileName: boolean, showFileName: boolean,
}> = ({ suite, testId, setTestId, showFileName, depth }) => { }> = ({ suite, testId, setTestId, showFileName, failingOnly, depth }) => {
const location = renderLocation(suite.location, showFileName); const location = renderLocation(suite.location, showFileName);
return <TreeItem title={<div className='hbox'> return <TreeItem title={<div className='hbox'>
{statusIconForFailedTests(suite.failedTests)}<div className='tree-text'>{suite.title}</div> <div className='tree-text' title={suite.title}>{suite.title}</div>
<div style={{ flex: 'auto' }}></div>
<StatsView stats={suite.stats}></StatsView>
{!!suite.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>} {!!suite.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
</div> </div>
} loadChildren={() => { } loadChildren={() => {
const suiteChildren = suite.suites.map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} showFileName={false}></SuiteTreeItemView>) || []; const suiteChildren = suite.suites.filter(s => !(failingOnly && s.stats.ok)).map((s, i) => <SuiteTreeItemView key={i} suite={s} setTestId={setTestId} testId={testId} depth={depth + 1} showFileName={false} failingOnly={failingOnly}></SuiteTreeItemView>) || [];
const suiteCount = suite.suites.length; const suiteCount = suite.suites.length;
const testChildren = suite.tests.map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} showFileName={false} depth={depth + 1}></TestTreeItemView>) || []; const testChildren = suite.tests.filter(t => !(failingOnly && t.ok)).map((t, i) => <TestTreeItemView key={i + suiteCount} test={t} setTestId={setTestId} testId={testId} depth={depth + 1}></TestTreeItemView>) || [];
return [...suiteChildren, ...testChildren]; return [...suiteChildren, ...testChildren];
}} depth={depth}></TreeItem>; }} depth={depth}></TreeItem>;
}; };
const TestTreeItemView: React.FC<{ const TestTreeItemView: React.FC<{
test: TestTreeItem, test: TestTreeItem,
showFileName: boolean,
testId?: TestId, testId?: TestId,
setTestId: (id: TestId) => void; setTestId: (id: TestId) => void;
depth: number, depth: number,
}> = ({ test, testId, setTestId, showFileName, depth }) => { }> = ({ test, testId, setTestId, depth }) => {
const fileName = test.location.file;
const name = fileName.substring(fileName.lastIndexOf('/') + 1);
return <TreeItem title={<div className='hbox'> return <TreeItem title={<div className='hbox'>
{statusIcon(test.outcome)}<div className='tree-text'>{test.title}</div> {statusIcon(test.outcome)}<div className='tree-text' title={test.title}>{test.title}</div>
{showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{name}:{test.location.line}</div>} <div style={{ flex: 'auto' }}></div>
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.duration)}</div>} {<div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.duration)}</div>}
</div> </div>
} selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}></TreeItem>; } selected={test.testId === testId?.testId} depth={depth} onClick={() => setTestId({ testId: test.testId, fileId: test.fileId })}></TreeItem>;
}; };
@ -189,9 +192,16 @@ const StepTreeItem: React.FC<{
} : undefined} depth={depth}></TreeItem>; } : undefined} depth={depth}></TreeItem>;
}; };
function statusIconForFailedTests(failedTests: number) { const StatsView: React.FC<{
return failedTests ? statusIcon('failed') : statusIcon('passed'); stats: Stats
} }> = ({ stats }) => {
return <div className='hbox' style={{flex: 'none'}}>
{!!stats.expected && <div className='stats expected' title='Passed'>{stats.expected}</div>}
{!!stats.unexpected && <div className='stats unexpected' title='Failed'>{stats.unexpected}</div>}
{!!stats.flaky && <div className='stats flaky' title='Flaky'>{stats.flaky}</div>}
{!!stats.skipped && <div className='stats skipped' title='Skipped'>{stats.skipped}</div>}
</div>;
};
function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element { function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
switch (status) { switch (status) {