2021-12-12 14:56:12 -08:00
|
|
|
/*
|
|
|
|
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 type { TestAttachment, TestCase, TestResult, TestStep } from '@playwright/test/src/reporters/html';
|
|
|
|
import ansi2html from 'ansi-to-html';
|
|
|
|
import * as React from 'react';
|
|
|
|
import { TreeItem } from './treeItem';
|
|
|
|
import { TabbedPane } from './tabbedPane';
|
2021-12-13 16:38:26 -08:00
|
|
|
import { msToString } from './uiUtils';
|
2021-12-14 19:25:07 -08:00
|
|
|
import { AutoChip } from './chip';
|
2021-12-12 14:56:12 -08:00
|
|
|
import { traceImage } from './images';
|
|
|
|
import { AttachmentLink } from './links';
|
|
|
|
import { statusIcon } from './statusIcon';
|
|
|
|
import './testResultView.css';
|
|
|
|
|
2022-03-11 09:46:13 -07:00
|
|
|
type DiffTab = {
|
|
|
|
id: string,
|
|
|
|
title: string,
|
|
|
|
attachment: TestAttachment,
|
|
|
|
};
|
|
|
|
|
|
|
|
function classifyAttachments(attachments: TestAttachment[]) {
|
|
|
|
const screenshots = new Set(attachments.filter(a => a.contentType.startsWith('image/')));
|
|
|
|
const videos = attachments.filter(a => a.name === 'video');
|
|
|
|
const traces = attachments.filter(a => a.name === 'trace');
|
|
|
|
|
|
|
|
const otherAttachments = new Set<TestAttachment>(attachments);
|
|
|
|
[...screenshots, ...videos, ...traces].forEach(a => otherAttachments.delete(a));
|
|
|
|
|
|
|
|
const snapshotNameToDiffTabs = new Map<string, DiffTab[]>();
|
|
|
|
let tabId = 0;
|
|
|
|
for (const attachment of attachments) {
|
|
|
|
const match = attachment.name.match(/^(.*)-(\w+)(\.[^.]+)?$/);
|
|
|
|
if (!match)
|
|
|
|
continue;
|
|
|
|
const [, name, category, extension = ''] = match;
|
|
|
|
const snapshotName = name + extension;
|
|
|
|
let diffTabs = snapshotNameToDiffTabs.get(snapshotName);
|
|
|
|
if (!diffTabs) {
|
|
|
|
diffTabs = [];
|
|
|
|
snapshotNameToDiffTabs.set(snapshotName, diffTabs);
|
|
|
|
}
|
|
|
|
diffTabs.push({
|
|
|
|
id: 'tab-' + (++tabId),
|
|
|
|
title: category,
|
|
|
|
attachment,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
const diffs = [...snapshotNameToDiffTabs].map(([snapshotName, diffTabs]) => {
|
|
|
|
diffTabs.sort((tab1: DiffTab, tab2: DiffTab) => {
|
|
|
|
if (tab1.title === 'diff' || tab2.title === 'diff')
|
|
|
|
return tab1.title === 'diff' ? -1 : 1;
|
|
|
|
if (tab1.title !== tab2.title)
|
|
|
|
return tab1.title < tab2.title ? -1 : 1;
|
|
|
|
return 0;
|
|
|
|
});
|
|
|
|
const isImageDiff = diffTabs.some(tab => screenshots.has(tab.attachment));
|
|
|
|
for (const tab of diffTabs)
|
|
|
|
screenshots.delete(tab.attachment);
|
|
|
|
return {
|
|
|
|
tabs: diffTabs,
|
|
|
|
isImageDiff,
|
|
|
|
snapshotName,
|
|
|
|
};
|
|
|
|
}).filter(diff => diff.tabs.some(tab => ['diff', 'actual', 'expected'].includes(tab.title.toLowerCase())));
|
|
|
|
return { diffs, screenshots: [...screenshots], videos, otherAttachments, traces };
|
|
|
|
}
|
2021-12-15 19:19:43 -08:00
|
|
|
|
2021-12-12 14:56:12 -08:00
|
|
|
export const TestResultView: React.FC<{
|
|
|
|
test: TestCase,
|
|
|
|
result: TestResult,
|
|
|
|
}> = ({ result }) => {
|
|
|
|
|
2022-03-11 09:46:13 -07:00
|
|
|
const { screenshots, videos, traces, otherAttachments, diffs } = React.useMemo(() => {
|
|
|
|
return classifyAttachments(result?.attachments || []);
|
2021-12-12 14:56:12 -08:00
|
|
|
}, [ result ]);
|
|
|
|
|
|
|
|
return <div className='test-result'>
|
2022-02-02 19:33:51 -07:00
|
|
|
{!!result.errors.length && <AutoChip header='Errors'>
|
|
|
|
{result.errors.map((error, index) => <ErrorMessage key={'test-result-error-message-' + index} error={error}></ErrorMessage>)}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
|
|
|
{!!result.steps.length && <AutoChip header='Test Steps'>
|
2021-12-12 14:56:12 -08:00
|
|
|
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
2021-12-12 14:56:12 -08:00
|
|
|
|
2022-03-11 09:46:13 -07:00
|
|
|
{diffs.map(({ tabs, snapshotName, isImageDiff }, index) =>
|
|
|
|
<AutoChip key={`diff-${index}`} header={`${isImageDiff ? 'Image' : 'Snapshot'} mismatch: ${snapshotName}`}>
|
|
|
|
{isImageDiff && <ImageDiff key='image-diff' tabs={tabs}></ImageDiff>}
|
|
|
|
{tabs.map((tab: DiffTab) => <AttachmentLink key={tab.id} attachment={tab.attachment}></AttachmentLink>)}
|
|
|
|
</AutoChip>
|
|
|
|
)}
|
2021-12-12 14:56:12 -08:00
|
|
|
|
2021-12-14 19:25:07 -08:00
|
|
|
{!!screenshots.length && <AutoChip header='Screenshots'>
|
2021-12-12 14:56:12 -08:00
|
|
|
{screenshots.map((a, i) => {
|
|
|
|
return <div key={`screenshot-${i}`}>
|
|
|
|
<img src={a.path} />
|
|
|
|
<AttachmentLink attachment={a}></AttachmentLink>
|
|
|
|
</div>;
|
|
|
|
})}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
2021-12-12 14:56:12 -08:00
|
|
|
|
2021-12-14 19:25:07 -08:00
|
|
|
{!!traces.length && <AutoChip header='Traces'>
|
2022-02-16 09:09:42 -08:00
|
|
|
{<div>
|
|
|
|
<a href={`trace/index.html?${traces.map((a, i) => `trace=${new URL(a.path!, window.location.href)}`).join('&')}`}>
|
2021-12-12 14:56:12 -08:00
|
|
|
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
|
|
|
|
</a>
|
2022-02-16 09:09:42 -08:00
|
|
|
{traces.map((a, i) => <AttachmentLink key={`trace-${i}`} attachment={a} linkName={traces.length === 1 ? 'trace' : `trace-${i + 1}`}></AttachmentLink>)}
|
|
|
|
</div>}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
2021-12-12 14:56:12 -08:00
|
|
|
|
2021-12-14 19:25:07 -08:00
|
|
|
{!!videos.length && <AutoChip header='Videos'>
|
2021-12-12 14:56:12 -08:00
|
|
|
{videos.map((a, i) => <div key={`video-${i}`}>
|
|
|
|
<video controls>
|
|
|
|
<source src={a.path} type={a.contentType}/>
|
|
|
|
</video>
|
|
|
|
<AttachmentLink attachment={a}></AttachmentLink>
|
|
|
|
</div>)}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
2021-12-12 14:56:12 -08:00
|
|
|
|
2021-12-15 19:19:43 -08:00
|
|
|
{!!otherAttachments.size && <AutoChip header='Attachments'>
|
|
|
|
{[...otherAttachments].map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
|
2021-12-14 19:25:07 -08:00
|
|
|
</AutoChip>}
|
2021-12-12 14:56:12 -08:00
|
|
|
</div>;
|
|
|
|
};
|
|
|
|
|
|
|
|
const StepTreeItem: React.FC<{
|
|
|
|
step: TestStep;
|
|
|
|
depth: number,
|
|
|
|
}> = ({ step, depth }) => {
|
|
|
|
return <TreeItem title={<span>
|
|
|
|
<span style={{ float: 'right' }}>{msToString(step.duration)}</span>
|
|
|
|
{statusIcon(step.error || step.duration === -1 ? 'failed' : 'passed')}
|
|
|
|
<span>{step.title}</span>
|
2022-01-03 21:17:17 -08:00
|
|
|
{step.count > 1 && <> ✕ <span className='test-result-counter'>{step.count}</span></>}
|
2021-12-12 14:56:12 -08:00
|
|
|
{step.location && <span className='test-result-path'>— {step.location.file}:{step.location.line}</span>}
|
|
|
|
</span>} loadChildren={step.steps.length + (step.snippet ? 1 : 0) ? () => {
|
|
|
|
const children = step.steps.map((s, i) => <StepTreeItem key={i} step={s} depth={depth + 1}></StepTreeItem>);
|
|
|
|
if (step.snippet)
|
|
|
|
children.unshift(<ErrorMessage key='line' error={step.snippet}></ErrorMessage>);
|
|
|
|
return children;
|
|
|
|
} : undefined} depth={depth}></TreeItem>;
|
|
|
|
};
|
|
|
|
|
|
|
|
const ImageDiff: React.FunctionComponent<{
|
2022-03-11 09:46:13 -07:00
|
|
|
tabs: DiffTab[],
|
|
|
|
}> = ({ tabs }) => {
|
|
|
|
// Pre-select a tab called "actual", if any.
|
|
|
|
const preselectedTab = tabs.find(tab => tab.title.toLowerCase() === 'actual') || tabs[0];
|
|
|
|
const [selectedTab, setSelectedTab] = React.useState<string>(preselectedTab.id);
|
2022-01-06 15:19:16 -08:00
|
|
|
const diffElement = React.useRef<HTMLImageElement>(null);
|
2022-03-11 09:46:13 -07:00
|
|
|
const paneTabs = tabs.map(tab => ({
|
|
|
|
id: tab.id,
|
|
|
|
title: tab.title,
|
|
|
|
render: () => <img src={tab.attachment.path} onLoad={() => {
|
2022-01-06 15:19:16 -08:00
|
|
|
if (diffElement.current)
|
|
|
|
diffElement.current.style.minHeight = diffElement.current.offsetHeight + 'px';
|
|
|
|
}}/>
|
2022-03-11 09:46:13 -07:00
|
|
|
}));
|
2022-01-06 15:19:16 -08:00
|
|
|
return <div className='vbox' data-testid='test-result-image-mismatch' ref={diffElement}>
|
2022-03-11 09:46:13 -07:00
|
|
|
<TabbedPane tabs={paneTabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
|
2021-12-12 14:56:12 -08:00
|
|
|
</div>;
|
|
|
|
};
|
|
|
|
|
|
|
|
const ErrorMessage: React.FC<{
|
|
|
|
error: string;
|
|
|
|
}> = ({ error }) => {
|
|
|
|
const html = React.useMemo(() => {
|
|
|
|
const config: any = {
|
|
|
|
bg: 'var(--color-canvas-subtle)',
|
|
|
|
fg: 'var(--color-fg-default)',
|
|
|
|
};
|
|
|
|
config.colors = ansiColors;
|
|
|
|
return new ansi2html(config).toHtml(escapeHTML(error));
|
|
|
|
}, [error]);
|
|
|
|
return <div className='test-result-error-message' dangerouslySetInnerHTML={{ __html: html || '' }}></div>;
|
|
|
|
};
|
|
|
|
|
|
|
|
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'
|
|
|
|
};
|
|
|
|
|
|
|
|
function escapeHTML(text: string): string {
|
|
|
|
return text.replace(/[&"<>]/g, c => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!));
|
|
|
|
}
|