278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
|
|
/*
|
||
|
|
Copyright (c) Microsoft Corporation.
|
||
|
|
|
||
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
|
you may not use this file except in compliance with the License.
|
||
|
|
You may obtain a copy of the License at
|
||
|
|
|
||
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
|
||
|
|
Unless required by applicable law or agreed to in writing, software
|
||
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
|
See the License for the specific language governing permissions and
|
||
|
|
limitations under the License.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import './htmlReport.css';
|
||
|
|
import * as React from 'react';
|
||
|
|
import { SplitView } from '../components/splitView';
|
||
|
|
import { TreeItem } from '../components/treeItem';
|
||
|
|
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
|
||
|
|
import ansi2html from 'ansi-to-html';
|
||
|
|
import { JsonLocation, JsonReport, JsonSuite, JsonTestCase, JsonTestResult, JsonTestStep } from '../../test/reporters/html';
|
||
|
|
import { msToString } from '../uiUtils';
|
||
|
|
|
||
|
|
type Filter = 'Failing' | 'All';
|
||
|
|
|
||
|
|
export const Report: React.FC = () => {
|
||
|
|
const [report, setReport] = React.useState<JsonReport | undefined>();
|
||
|
|
const [selectedTest, setSelectedTest] = React.useState<JsonTestCase | undefined>();
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
(async () => {
|
||
|
|
const result = await fetch('report.json');
|
||
|
|
const json = await result.json();
|
||
|
|
setReport(json);
|
||
|
|
})();
|
||
|
|
}, []);
|
||
|
|
const [filter, setFilter] = React.useState<Filter>('Failing');
|
||
|
|
|
||
|
|
const failingTests = React.useMemo(() => {
|
||
|
|
const map = new Map<JsonSuite, JsonTestCase[]>();
|
||
|
|
for (const project of report?.suites || [])
|
||
|
|
map.set(project, computeFailingTests(project));
|
||
|
|
return map;
|
||
|
|
}, [report]);
|
||
|
|
|
||
|
|
return <div className='hbox'>
|
||
|
|
<FilterView filter={filter} setFilter={setFilter}></FilterView>
|
||
|
|
<SplitView sidebarSize={500} orientation='horizontal' sidebarIsFirst={true}>
|
||
|
|
<TestCaseView test={selectedTest}></TestCaseView>
|
||
|
|
<div className='suite-tree'>
|
||
|
|
{filter === 'All' && report?.suites.map((s, i) => <ProjectTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest}></ProjectTreeItem>)}
|
||
|
|
{filter === 'Failing' && report?.suites.map((s, i) => {
|
||
|
|
const hasFailingTests = !!failingTests.get(s)?.length;
|
||
|
|
return hasFailingTests && <ProjectFlatTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} failingTests={failingTests.get(s)!}></ProjectFlatTreeItem>;
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
</SplitView>
|
||
|
|
</div>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const FilterView: React.FC<{
|
||
|
|
filter: Filter,
|
||
|
|
setFilter: (filter: Filter) => void
|
||
|
|
}> = ({ filter, setFilter }) => {
|
||
|
|
return <div className='sidebar'>
|
||
|
|
{
|
||
|
|
(['Failing', 'All'] as Filter[]).map(item => {
|
||
|
|
const selected = item === filter;
|
||
|
|
return <div key={item} className={selected ? 'selected' : ''} onClick={e => {
|
||
|
|
setFilter(item);
|
||
|
|
}}>{item}</div>;
|
||
|
|
})
|
||
|
|
}
|
||
|
|
</div>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ProjectTreeItem: React.FC<{
|
||
|
|
suite?: JsonSuite;
|
||
|
|
selectedTest?: JsonTestCase,
|
||
|
|
setSelectedTest: (test: JsonTestCase) => void;
|
||
|
|
}> = ({ suite, setSelectedTest, selectedTest }) => {
|
||
|
|
const location = renderLocation(suite?.location);
|
||
|
|
|
||
|
|
return <TreeItem title={<div className='hbox'>
|
||
|
|
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||
|
|
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||
|
|
</div>
|
||
|
|
} loadChildren={() => {
|
||
|
|
return suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={1}></SuiteTreeItem>) || [];
|
||
|
|
}} depth={0} expandByDefault={true}></TreeItem>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const ProjectFlatTreeItem: React.FC<{
|
||
|
|
suite?: JsonSuite;
|
||
|
|
failingTests: JsonTestCase[],
|
||
|
|
selectedTest?: JsonTestCase,
|
||
|
|
setSelectedTest: (test: JsonTestCase) => void;
|
||
|
|
}> = ({ suite, setSelectedTest, selectedTest, failingTests }) => {
|
||
|
|
const location = renderLocation(suite?.location);
|
||
|
|
|
||
|
|
return <TreeItem title={<div className='hbox'>
|
||
|
|
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||
|
|
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||
|
|
</div>
|
||
|
|
} loadChildren={() => {
|
||
|
|
return failingTests.map((t, i) => <TestTreeItem key={i} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={1}></TestTreeItem>) || [];
|
||
|
|
}} depth={0} expandByDefault={true}></TreeItem>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const SuiteTreeItem: React.FC<{
|
||
|
|
suite?: JsonSuite;
|
||
|
|
selectedTest?: JsonTestCase,
|
||
|
|
setSelectedTest: (test: JsonTestCase) => void;
|
||
|
|
depth: number,
|
||
|
|
}> = ({ suite, setSelectedTest, selectedTest, depth }) => {
|
||
|
|
const location = renderLocation(suite?.location);
|
||
|
|
return <TreeItem title={<div className='hbox'>
|
||
|
|
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}<div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{suite?.title}</div></div>
|
||
|
|
{!!suite?.location?.line && location && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{location}</div>}
|
||
|
|
</div>
|
||
|
|
} loadChildren={() => {
|
||
|
|
const suiteChildren = suite?.suites.map((s, i) => <SuiteTreeItem key={i} suite={s} setSelectedTest={setSelectedTest} selectedTest={selectedTest} depth={depth + 1}></SuiteTreeItem>) || [];
|
||
|
|
const testChildren = suite?.tests.map((t, i) => <TestTreeItem key={i} test={t} setSelectedTest={setSelectedTest} selectedTest={selectedTest} showFileName={false} depth={depth + 1}></TestTreeItem>) || [];
|
||
|
|
return [...suiteChildren, ...testChildren];
|
||
|
|
}} depth={depth}></TreeItem>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const TestTreeItem: React.FC<{
|
||
|
|
expandByDefault?: boolean,
|
||
|
|
test: JsonTestCase;
|
||
|
|
showFileName: boolean,
|
||
|
|
selectedTest?: JsonTestCase,
|
||
|
|
setSelectedTest: (test: JsonTestCase) => void;
|
||
|
|
depth: number,
|
||
|
|
}> = ({ test, setSelectedTest, selectedTest, showFileName, expandByDefault, depth }) => {
|
||
|
|
const fileName = test.location.file;
|
||
|
|
const name = fileName.substring(fileName.lastIndexOf('/') + 1);
|
||
|
|
return <TreeItem title={<div className='hbox'>
|
||
|
|
<div style={{ flex: 'auto', alignItems: 'center', display: 'flex' }}>{testCaseStatusIcon(test)}<div style={{overflow: 'hidden', textOverflow: 'ellipsis'}}>{test.title}</div></div>
|
||
|
|
{showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{name}:{test.location.line}</div>}
|
||
|
|
{!showFileName && <div style={{ flex: 'none', padding: '0 4px', color: '#666' }}>{msToString(test.results.reduce((v, a) => v + a.duration, 0))}</div>}
|
||
|
|
</div>
|
||
|
|
} selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}></TreeItem>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const TestCaseView: React.FC<{
|
||
|
|
test?: JsonTestCase,
|
||
|
|
}> = ({ test }) => {
|
||
|
|
const [selectedTab, setSelectedTab] = React.useState<string>('0');
|
||
|
|
return <div className="test-case vbox">
|
||
|
|
{ test && <TabbedPane tabs={
|
||
|
|
test?.results.map((result, index) => ({
|
||
|
|
id: String(index),
|
||
|
|
title: <div style={{ display: 'flex', alignItems: 'center' }}>{statusIcon(result.status)} {retryLabel(index)}</div>,
|
||
|
|
render: () => <TestOverview test={test} result={result}></TestOverview>
|
||
|
|
})) || []} selectedTab={selectedTab} setSelectedTab={setSelectedTab} /> }
|
||
|
|
</div>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const TestOverview: React.FC<{
|
||
|
|
test: JsonTestCase,
|
||
|
|
result: JsonTestResult,
|
||
|
|
}> = ({ test, result }) => {
|
||
|
|
return <div className="test-result">
|
||
|
|
<div className='test-overview-title'>{test?.title}</div>
|
||
|
|
<div className='test-overview-property'>{renderLocation(test.location)}<div style={{ flex: 'auto' }}></div><div>{msToString(result.duration)}</div></div>
|
||
|
|
{ result.failureSnippet && <div className='error-message' dangerouslySetInnerHTML={{__html: new ansi2html({
|
||
|
|
colors: ansiColors
|
||
|
|
}).toHtml(result.failureSnippet.trim()) }}></div> }
|
||
|
|
{ result.steps.map((step, i) => <StepTreeItem key={i} step={step} depth={0}></StepTreeItem>) }
|
||
|
|
{/* <div style={{whiteSpace: 'pre'}}>{ JSON.stringify(result.steps, undefined, 2) }</div> */}
|
||
|
|
</div>;
|
||
|
|
};
|
||
|
|
|
||
|
|
const StepTreeItem: React.FC<{
|
||
|
|
step: JsonTestStep;
|
||
|
|
depth: number,
|
||
|
|
}> = ({ step, depth }) => {
|
||
|
|
return <TreeItem title={<div style={{ display: 'flex', alignItems: 'center', flex: 'auto', maxWidth: 430 }}>
|
||
|
|
{testStepStatusIcon(step)}
|
||
|
|
{step.title}
|
||
|
|
<div style={{ flex: 'auto' }}></div>
|
||
|
|
<div>{msToString(step.duration)}</div>
|
||
|
|
</div>} loadChildren={step.steps.length ? () => {
|
||
|
|
return step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
||
|
|
} : undefined} depth={depth}></TreeItem>;
|
||
|
|
};
|
||
|
|
|
||
|
|
function testSuiteErrorStatusIcon(suite?: JsonSuite): JSX.Element | undefined {
|
||
|
|
if (!suite)
|
||
|
|
return;
|
||
|
|
for (const child of suite.suites) {
|
||
|
|
const icon = testSuiteErrorStatusIcon(child);
|
||
|
|
if (icon)
|
||
|
|
return icon;
|
||
|
|
}
|
||
|
|
for (const test of suite.tests) {
|
||
|
|
if (test.outcome !== 'expected' && test.outcome !== 'skipped')
|
||
|
|
return testCaseStatusIcon(test);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function testCaseStatusIcon(test?: JsonTestCase): JSX.Element {
|
||
|
|
if (!test)
|
||
|
|
return statusIcon('passed');
|
||
|
|
return statusIcon(test.outcome);
|
||
|
|
}
|
||
|
|
|
||
|
|
function testStepStatusIcon(step: JsonTestStep): JSX.Element {
|
||
|
|
if (step.category === 'internal')
|
||
|
|
return <span></span>;
|
||
|
|
return statusIcon(step.error ? 'failed' : 'passed');
|
||
|
|
}
|
||
|
|
|
||
|
|
function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
|
||
|
|
switch (status) {
|
||
|
|
case 'failed':
|
||
|
|
case 'unexpected':
|
||
|
|
return <span className={'codicon codicon-error status-icon'}></span>;
|
||
|
|
case 'passed':
|
||
|
|
case 'expected':
|
||
|
|
return <span className={'codicon codicon-circle-filled status-icon'}></span>;
|
||
|
|
case 'timedOut':
|
||
|
|
return <span className={'codicon codicon-clock status-icon'}></span>;
|
||
|
|
case 'flaky':
|
||
|
|
return <span className={'codicon codicon-alert status-icon'}></span>;
|
||
|
|
case 'skipped':
|
||
|
|
return <span className={'codicon codicon-tag status-icon'}></span>;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function computeFailingTests(suite: JsonSuite): JsonTestCase[] {
|
||
|
|
const failedTests: JsonTestCase[] = [];
|
||
|
|
const visit = (suite: JsonSuite) => {
|
||
|
|
for (const child of suite.suites)
|
||
|
|
visit(child);
|
||
|
|
for (const test of suite.tests) {
|
||
|
|
if (test.results.find(r => r.status === 'failed' || r.status === 'timedOut'))
|
||
|
|
failedTests.push(test);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
visit(suite);
|
||
|
|
return failedTests;
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderLocation(location?: JsonLocation) {
|
||
|
|
if (!location)
|
||
|
|
return '';
|
||
|
|
return location.file + ':' + location.column;
|
||
|
|
}
|
||
|
|
|
||
|
|
function retryLabel(index: number) {
|
||
|
|
if (!index)
|
||
|
|
return 'Run';
|
||
|
|
return `Retry #${index}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
const ansiColors = {
|
||
|
|
0: '#000',
|
||
|
|
1: '#C00',
|
||
|
|
2: '#0C0',
|
||
|
|
3: '#C50',
|
||
|
|
4: '#00C',
|
||
|
|
5: '#C0C',
|
||
|
|
6: '#0CC',
|
||
|
|
7: '#CCC',
|
||
|
|
8: '#555',
|
||
|
|
9: '#F55',
|
||
|
|
10: '#5F5',
|
||
|
|
11: '#FF5',
|
||
|
|
12: '#55F',
|
||
|
|
13: '#F5F',
|
||
|
|
14: '#5FF',
|
||
|
|
15: '#FFF'
|
||
|
|
};
|