/* 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 type { JsonAttachment, 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(); const [selectedTest, setSelectedTest] = React.useState(); React.useEffect(() => { (async () => { const result = await fetch('report.json'); const json = await result.json(); setReport(json); })(); }, []); const [filter, setFilter] = React.useState('Failing'); const { unexpectedTests, unexpectedTestCount } = React.useMemo(() => { const unexpectedTests = new Map(); let unexpectedTestCount = 0; for (const project of report?.suites || []) { const unexpected = computeUnexpectedTests(project); unexpectedTestCount += unexpected.length; unexpectedTests.set(project, unexpected); } return { unexpectedTests, unexpectedTestCount }; }, [report]); return
{filter === 'All' && report?.suites.map((s, i) => )} {filter === 'Failing' && !!unexpectedTestCount && report?.suites.map((s, i) => { const hasUnexpectedOutcomes = !!unexpectedTests.get(s)?.length; return hasUnexpectedOutcomes && ; })} {filter === 'Failing' && !unexpectedTestCount &&
You are awesome!
}
; }; const FilterView: React.FC<{ filter: Filter, setFilter: (filter: Filter) => void }> = ({ filter, setFilter }) => { return
{ (['Failing', 'All'] as Filter[]).map(item => { const selected = item === filter; return
{ setFilter(item); }}>{item}
; }) }
; }; const ProjectTreeItem: React.FC<{ suite?: JsonSuite; selectedTest?: JsonTestCase, setSelectedTest: (test: JsonTestCase) => void; }> = ({ suite, setSelectedTest, selectedTest }) => { const location = renderLocation(suite?.location, true); return
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
{!!suite?.location?.line && location &&
{location}
} } loadChildren={() => { return suite?.suites.map((s, i) => ) || []; }} depth={0} expandByDefault={true}>
; }; const ProjectFlatTreeItem: React.FC<{ suite?: JsonSuite; unexpectedTests: JsonTestCase[], selectedTest?: JsonTestCase, setSelectedTest: (test: JsonTestCase) => void; }> = ({ suite, setSelectedTest, selectedTest, unexpectedTests }) => { const location = renderLocation(suite?.location, true); return
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title || 'Project'}
{!!suite?.location?.line && location &&
{location}
} } loadChildren={() => { return unexpectedTests.map((t, i) => ) || []; }} depth={0} expandByDefault={true}>
; }; const SuiteTreeItem: React.FC<{ suite?: JsonSuite; selectedTest?: JsonTestCase, setSelectedTest: (test: JsonTestCase) => void; depth: number, showFileName: boolean, }> = ({ suite, setSelectedTest, selectedTest, showFileName, depth }) => { const location = renderLocation(suite?.location, showFileName); return
{testSuiteErrorStatusIcon(suite) || statusIcon('passed')}
{suite?.title}
{!!suite?.location?.line && location &&
{location}
} } loadChildren={() => { const suiteChildren = suite?.suites.map((s, i) => ) || []; const testChildren = suite?.tests.map((t, i) => ) || []; return [...suiteChildren, ...testChildren]; }} depth={depth}>
; }; 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
{testCaseStatusIcon(test)}
{test.title}
{showFileName &&
{name}:{test.location.line}
} {!showFileName &&
{msToString(test.results.reduce((v, a) => v + a.duration, 0))}
} } selected={test === selectedTest} depth={depth} expandByDefault={expandByDefault} onClick={() => setSelectedTest(test)}>
; }; const TestCaseView: React.FC<{ test?: JsonTestCase, }> = ({ test }) => { const [selectedTab, setSelectedTab] = React.useState('0'); return
{ test && ({ id: String(index), title:
{statusIcon(result.status)} {retryLabel(index)}
, render: () => })) || []} selectedTab={selectedTab} setSelectedTab={setSelectedTab} /> }
; }; const TestOverview: React.FC<{ test: JsonTestCase, result: JsonTestResult, }> = ({ test, result }) => { const { screenshots, attachmentsMap } = React.useMemo(() => { const attachmentsMap = new Map(); const screenshots = result.attachments.filter(a => a.name === 'screenshot'); for (const a of result.attachments) attachmentsMap.set(a.name, a); return { attachmentsMap, screenshots }; }, [ result ]); return
{test?.title}
{renderLocation(test.location, true)}
{msToString(result.duration)}
{result.failureSnippet &&
} {result.steps.map((step, i) => )} {attachmentsMap.has('expected') && attachmentsMap.has('actual') && } {!!screenshots.length &&
Screenshots
} {screenshots.map(a =>
)} {!!result.attachments &&
Attachments
} {result.attachments.map(a => )}
; }; const StepTreeItem: React.FC<{ step: JsonTestStep; depth: number, }> = ({ step, depth }) => { return {testStepStatusIcon(step)} {step.title}
{msToString(step.duration)}
} loadChildren={step.steps.length ? () => { return step.steps.map((s, i) => ); } : undefined} depth={depth}>
; }; export const ImageDiff: React.FunctionComponent<{ actual: JsonAttachment, expected: JsonAttachment, diff?: JsonAttachment, }> = ({ actual, expected, diff }) => { const [selectedTab, setSelectedTab] = React.useState('actual'); const tabs = []; tabs.push({ id: 'actual', title: 'Actual', render: () =>
}); tabs.push({ id: 'expected', title: 'Expected', render: () =>
}); if (diff) { tabs.push({ id: 'diff', title: 'Diff', render: () =>
, }); } return
Image mismatch
; }; export const AttachmentLink: React.FunctionComponent<{ attachment: JsonAttachment, }> = ({ attachment }) => { return {attachment.sha1 && {attachment.name}} {attachment.body && {attachment.name}} } loadChildren={attachment.body ? () => { return [
${attachment.body}
]; } : undefined} depth={0}>
; }; 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 ; 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 ; case 'passed': case 'expected': return ; case 'timedOut': return ; case 'flaky': return ; case 'skipped': return ; } } function computeUnexpectedTests(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.outcome !== 'expected' && test.outcome !== 'skipped') failedTests.push(test); } }; visit(suite); return failedTests; } function renderLocation(location: JsonLocation | undefined, showFileName: boolean) { if (!location) return ''; return (showFileName ? location.file : '') + ':' + location.line; } 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' };