chore: split html report into files (#10876)

This commit is contained in:
Pavel Feldman 2021-12-12 14:56:12 -08:00 committed by GitHub
parent 81ab6b3fde
commit 6521a6f3ab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1464 additions and 1051 deletions

View file

@ -0,0 +1,70 @@
/*
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.
*/
.chip-header {
border: 1px solid var(--color-border-default);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-color: var(--color-canvas-subtle);
padding: 0 8px;
border-bottom: none;
margin-top: 24px;
font-weight: 600;
line-height: 38px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-header.expanded-false {
border: 1px solid var(--color-border-default);
border-radius: 6px;
}
.chip-header.expanded-false,
.chip-header.expanded-true {
cursor: pointer;
}
.chip-body {
border: 1px solid var(--color-border-default);
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 16px;
}
.chip-body-no-insets {
padding: 0;
}
@media only screen and (max-width: 600px) {
.chip-header {
border-radius: 0;
border-right: none;
border-left: none;
}
.chip-body {
border-radius: 0;
border-right: none;
border-left: none;
padding: 8px;
}
.chip-body-no-insets {
padding: 0;
}
}

View file

@ -0,0 +1,47 @@
/*
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 * as React from 'react';
import './chip.css';
export const Chip: React.FunctionComponent<{
header: JSX.Element | string,
expanded?: boolean,
noInsets?: boolean,
setExpanded?: (expanded: boolean) => void,
children?: any,
}> = ({ header, expanded, setExpanded, children, noInsets }) => {
return <div className='chip'>
<div className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} onClick={() => setExpanded?.(!expanded)}>
{setExpanded && !!expanded && downArrow()}
{setExpanded && !expanded && rightArrow()}
{header}
</div>
{(!setExpanded || expanded) && <div className={'chip-body' + (noInsets ? ' chip-body-no-insets' : '')}>{children}</div>}
</div>;
};
function downArrow() {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z'></path>
</svg>;
}
function rightArrow() {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z'></path>
</svg>;
}

View file

@ -0,0 +1,266 @@
/*
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.
*/
* {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
svg {
fill: currentColor;
}
.vbox {
display: flex;
flex-direction: column;
flex: auto;
position: relative;
}
.hbox {
display: flex;
flex: auto;
position: relative;
}
.d-flex {
display: flex !important;
}
.d-inline {
display: inline !important;
}
.m-1 { margin: 4px; }
.m-2 { margin: 8px; }
.m-3 { margin: 16px; }
.m-4 { margin: 24px; }
.m-5 { margin: 32px; }
.mx-1 { margin: 0 4px; }
.mx-2 { margin: 0 8px; }
.mx-3 { margin: 0 16px; }
.mx-4 { margin: 0 24px; }
.mx-5 { margin: 0 32px; }
.my-1 { margin: 4px 0; }
.my-2 { margin: 8px 0; }
.my-3 { margin: 16px 0; }
.my-4 { margin: 24px 0; }
.my-5 { margin: 32px 0; }
.mt-1 { margin-top: 4px; }
.mt-2 { margin-top: 8px; }
.mt-3 { margin-top: 16px; }
.mt-4 { margin-top: 24px; }
.mt-5 { margin-top: 32px; }
.mr-1 { margin-right: 4px; }
.mr-2 { margin-right: 8px; }
.mr-3 { margin-right: 16px; }
.mr-4 { margin-right: 24px; }
.mr-5 { margin-right: 32px; }
.mb-1 { margin-bottom: 4px; }
.mb-2 { margin-bottom: 8px; }
.mb-3 { margin-bottom: 16px; }
.mb-4 { margin-bottom: 24px; }
.mb-5 { margin-bottom: 32px; }
.ml-1 { margin-left: 4px; }
.ml-2 { margin-left: 8px; }
.ml-3 { margin-left: 16px; }
.ml-4 { margin-left: 24px; }
.ml-5 { margin-left: 32px; }
.p-1 { padding: 4px; }
.p-2 { padding: 8px; }
.p-3 { padding: 16px; }
.p-4 { padding: 24px; }
.p-5 { padding: 32px; }
.px-1 { padding: 0 4px; }
.px-2 { padding: 0 8px; }
.px-3 { padding: 0 16px; }
.px-4 { padding: 0 24px; }
.px-5 { padding: 0 32px; }
.py-1 { padding: 4px 0; }
.py-2 { padding: 8px 0; }
.py-3 { padding: 16px 0; }
.py-4 { padding: 24px 0; }
.py-5 { padding: 32px 0; }
.pt-1 { padding-top: 4px; }
.pt-2 { padding-top: 8px; }
.pt-3 { padding-top: 16px; }
.pt-4 { padding-top: 24px; }
.pt-5 { padding-top: 32px; }
.pr-1 { padding-right: 4px; }
.pr-2 { padding-right: 8px; }
.pr-3 { padding-right: 16px; }
.pr-4 { padding-right: 24px; }
.pr-5 { padding-right: 32px; }
.pb-1 { padding-bottom: 4px; }
.pb-2 { padding-bottom: 8px; }
.pb-3 { padding-bottom: 16px; }
.pb-4 { padding-bottom: 24px; }
.pb-5 { padding-bottom: 32px; }
.pl-1 { padding-left: 4px; }
.pl-2 { padding-left: 8px; }
.pl-3 { padding-left: 16px; }
.pl-4 { padding-left: 24px; }
.pl-5 { padding-left: 32px; }
.no-wrap {
white-space: nowrap !important;
}
.float-left {
float: left !important;
}
article, aside, details, figcaption, figure, footer, header, main, menu, nav, section {
display: block;
}
.form-control, .form-select {
padding: 5px 12px;
font-size: 14px;
line-height: 20px;
color: var(--color-fg-default);
vertical-align: middle;
background-color: var(--color-canvas-default);
background-repeat: no-repeat;
background-position: right 8px center;
border: 1px solid var(--color-border-default);
border-radius: 6px;
outline: none;
box-shadow: var(--color-primer-shadow-inset);
}
.input-contrast {
background-color: var(--color-canvas-inset);
}
.subnav-search {
position: relative;
flex: auto;
display: flex;
}
.subnav-search-input {
flex: auto;
padding-left: 32px;
color: var(--color-fg-muted);
}
.subnav-search-icon {
position: absolute;
top: 9px;
left: 8px;
display: block;
color: var(--color-fg-muted);
text-align: center;
pointer-events: none;
}
.subnav-search-context + .subnav-search {
margin-left: -1px;
}
.subnav-item {
flex: none;
position: relative;
float: left;
padding: 5px 10px;
font-weight: 500;
line-height: 20px;
color: var(--color-fg-default);
border: 1px solid var(--color-border-default);
}
.subnav-item:hover {
background-color: var(--color-canvas-subtle);
}
.subnav-item:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.subnav-item:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
.subnav-item + .subnav-item {
margin-left: -1px;
}
.counter {
display: inline-block;
min-width: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--color-fg-default);
text-align: center;
background-color: var(--color-neutral-muted);
border: 1px solid transparent;
border-radius: 2em;
}
.color-icon-success {
color: var(--color-success-fg) !important;
}
.color-text-danger {
color: var(--color-danger-fg) !important;
}
.color-text-warning {
color: var(--color-checks-step-warning-text) !important;
}
.color-fg-muted {
color: var(--color-fg-muted) !important;
}
.octicon {
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
margin-right: 7px;
flex: none;
}
@media only screen and (max-width: 600px) {
.subnav-item, .form-control {
border-radius: 0 !important;
}
.subnav-item {
padding: 5px 3px;
border: none;
}
}

View file

@ -0,0 +1,138 @@
/*
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 { TestCaseSummary } from '@playwright/test/src/reporters/html';
import './htmlReport.css';
export class Filter {
project: string[] = [];
status: string[] = [];
text: string[] = [];
empty(): boolean {
return this.project.length + this.status.length + this.text.length === 0;
}
static parse(expression: string): Filter {
const tokens = Filter.tokenize(expression);
const project = new Set<string>();
const status = new Set<string>();
const text: string[] = [];
for (const token of tokens) {
if (token.startsWith('p:')) {
project.add(token.slice(2));
continue;
}
if (token.startsWith('s:')) {
status.add(token.slice(2));
continue;
}
text.push(token.toLowerCase());
}
const filter = new Filter();
filter.text = text;
filter.project = [...project];
filter.status = [...status];
return filter;
}
private static tokenize(expression: string): string[] {
const result: string[] = [];
let quote: '\'' | '"' | undefined;
let token: string[] = [];
for (let i = 0; i < expression.length; ++i) {
const c = expression[i];
if (quote && c === '\\' && expression[i + 1] === quote) {
token.push(quote);
++i;
continue;
}
if (c === '"' || c === '\'') {
if (quote === c) {
result.push(token.join('').toLowerCase());
token = [];
quote = undefined;
} else if (quote) {
token.push(c);
} else {
quote = c;
}
continue;
}
if (quote) {
token.push(c);
continue;
}
if (c === ' ') {
if (token.length) {
result.push(token.join('').toLowerCase());
token = [];
}
continue;
}
token.push(c);
}
if (token.length)
result.push(token.join('').toLowerCase());
return result;
}
matches(test: TestCaseSummary): boolean {
if (!(test as any).searchValues) {
let status = 'passed';
if (test.outcome === 'unexpected')
status = 'failed';
if (test.outcome === 'flaky')
status = 'flaky';
if (test.outcome === 'skipped')
status = 'skipped';
const searchValues: SearchValues = {
text: (status + ' ' + test.projectName + ' ' + test.path.join(' ') + test.title).toLowerCase(),
project: test.projectName.toLowerCase(),
status: status as any
};
(test as any).searchValues = searchValues;
}
const searchValues = (test as any).searchValues as SearchValues;
if (this.project.length) {
const matches = !!this.project.find(p => searchValues.project.includes(p));
if (!matches)
return false;
}
if (this.status.length) {
const matches = !!this.status.find(s => searchValues.status.includes(s));
if (!matches)
return false;
}
if (this.text.length) {
const matches = this.text.filter(t => searchValues.text.includes(t)).length === this.text.length;
if (!matches)
return false;
}
return true;
}
}
type SearchValues = {
text: string;
project: string;
status: 'passed' | 'failed' | 'flaky' | 'skipped';
};

View file

@ -24,22 +24,6 @@
rgb(0 0 0 / 25%) 0px 6px 12px;
}
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overscroll-behavior-x: none;
}
body {
width: 100vw;
}
body {
overflow: auto;
}
#root {
width: 100%;
height: 100%;
@ -49,575 +33,42 @@ body {
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
min-width: 0;
min-height: 0;
}
svg {
fill: currentColor;
}
.vbox {
display: flex;
flex-direction: column;
flex: auto;
position: relative;
}
.hbox {
display: flex;
flex: auto;
position: relative;
}
.global-stats {
padding-left: 34px;
margin-top: 20px;
font-weight: 600;
}
.test-case-column {
border-radius: 6px;
margin: 20px 0;
}
.tree-item {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.tree-item-title {
cursor: pointer;
}
.chip-body > .tree-item {
line-height: 38px;
}
.tree-item-body {
min-height: 18px;
}
.error-message {
white-space: pre;
font-family: monospace;
overflow: auto;
flex: none;
html, body {
width: 100%;
height: 100%;
padding: 0;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
padding: 16px;
line-height: initial;
margin: 0;
overscroll-behavior-x: none;
}
.status-icon {
padding-right: 3px;
}
.test-result {
flex: auto;
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.test-result .tabbed-pane .tab-content {
display: flex;
align-items: center;
justify-content: center;
}
.attachment-body {
white-space: pre-wrap;
font-family: monospace;
background-color: var(--color-canvas-subtle);
margin-left: 24px;
line-height: normal;
padding: 5px;
}
.test-result > div {
flex: none;
}
.columns > .tab-strip {
font-size: 14px;
line-height: 30px;
color: var(--color-fg-default);
height: 48px;
background-color: var(--color-canvas-subtle);
min-width: 70px;
}
.tab-strip {
box-shadow: inset 0 -1px 0 var(--color-border-muted) !important;
}
.test-case-column .tab-element.selected {
font-weight: 600;
border-bottom-color: var(--color-primer-border-active);
}
.test-case-column .tab-element {
border: none;
color: var(--color-fg-default);
border-bottom: 2px solid transparent;
}
.test-case-column .tab-element:hover {
color: var(--color-fg-default);
}
.test-case-column .tab-strip {
margin-top: 10px;
background-color: inherit;
}
.test-case-title {
flex: none;
padding: 10px;
font-weight: 400;
font-size: 32px !important;
line-height: 1.25 !important;
}
.test-case-location {
flex: none;
align-items: center;
padding: 0 10px 20px;
}
.test-case-path {
flex: none;
align-items: center;
padding: 0 10px;
}
.test-case-annotation {
flex: none;
align-items: center;
padding: 0 10px;
line-height: 24px;
}
.test-details-column {
overflow-y: auto;
}
.step-log {
line-height: 20px;
white-space: pre;
padding: 8px;
}
.tree-text {
overflow: hidden;
text-overflow: ellipsis;
}
.stats-line {
font-weight: normal;
padding-left: 25px;
}
.stats {
margin: 0 2px;
padding: 0 2px;
}
video, img {
flex: none;
box-shadow: var(--box-shadow-thick);
margin: 20px auto;
min-width: 200px;
max-width: 80%;
}
.flow-container {
body {
overflow: auto;
max-width: 1024px;
margin: 0 auto;
width: 100%;
padding: 0 16px;
}
.file-summary-list {
padding: 20px 0;
}
.file-summary-list .chip-body .test-summary:not(:first-child),
.failed-test:not(:first-child) {
.test-file-test:not(:first-child) {
border-top: 1px solid var(--color-border-default);
}
.failed-file-subtitle {
padding-left: 5px;
font-weight: 600;
color: var(--color-danger-fg);
}
.failed-test {
padding: 0 15px 0 10px;
line-height: 28px;
}
.failed-test-title {
font-weight: 600;
}
.failed-test-path {
padding: 5px 5px 0 0;
color: var(--color-fg-muted);
}
.failed-test .error-message {
margin: 20px 0 0;
}
.failed-test:hover {
background-color: var(--color-canvas-subtle);
}
a.no-decorations {
text-decoration: none;
color: initial;
}
.chip-header {
border: 1px solid var(--color-border-default);
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background-color: var(--color-canvas-subtle);
padding: 0 10px;
border-bottom: none;
margin-top: 20px;
font-weight: 600;
line-height: 38px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chip-header.expanded-false {
border: 1px solid var(--color-border-default);
border-radius: 6px;
}
.chip-header.expanded-false,
.chip-header.expanded-true {
cursor: pointer;
}
.chip-body {
border: 1px solid var(--color-border-default);
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
padding: 15px;
}
.failed-tests {
padding-bottom: 20px;
}
.file-summary-list .chip-body,
.failed-tests .chip-body {
padding: 0;
}
.test-summary {
height: 38px;
line-height: 38px;
align-items: center;
padding: 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.test-summary:hover {
background-color: var(--color-canvas-subtle);
}
.test-summary-path {
padding: 0 0 0 5px;
color: var(--color-fg-muted);
}
.test-summary.outcome-skipped {
color: var(--color-fg-muted);
}
.status-container {
float: right;
}
.octicon {
display: inline-block;
overflow: visible !important;
vertical-align: text-bottom;
fill: currentColor;
}
.color-icon-success {
color: var(--color-success-fg) !important;
}
.color-text-danger {
color: var(--color-danger-fg) !important;
}
.color-text-warning {
color: var(--color-checks-step-warning-text) !important;
}
.color-fg-muted {
color: var(--color-fg-muted) !important;
}
.octicon {
margin-right: 7px;
flex: none;
}
.label {
display: inline-block;
padding: 0 7px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
background-color: var(--color-scale-gray-4);
color: white;
margin: 0 10px;
flex: none;
font-weight: 600;
}
@media(prefers-color-scheme: light) {
.label-color-0 {
background-color: var(--color-scale-blue-0);
color: var(--color-scale-blue-6);
border: 1px solid var(--color-scale-blue-4);
}
.label-color-1 {
background-color: var(--color-scale-yellow-0);
color: var(--color-scale-yellow-6);
border: 1px solid var(--color-scale-yellow-4);
}
.label-color-2 {
background-color: var(--color-scale-purple-0);
color: var(--color-scale-purple-6);
border: 1px solid var(--color-scale-purple-4);
}
.label-color-3 {
background-color: var(--color-scale-pink-0);
color: var(--color-scale-pink-6);
border: 1px solid var(--color-scale-pink-4);
}
.label-color-4 {
background-color: var(--color-scale-coral-0);
color: var(--color-scale-coral-6);
border: 1px solid var(--color-scale-coral-4);
}
.label-color-5 {
background-color: var(--color-scale-orange-0);
color: var(--color-scale-orange-6);
border: 1px solid var(--color-scale-orange-4);
}
}
@media(prefers-color-scheme: dark) {
.label-color-0 {
background-color: var(--color-scale-blue-9);
color: var(--color-scale-blue-2);
border: 1px solid var(--color-scale-blue-4);
}
.label-color-1 {
background-color: var(--color-scale-yellow-9);
color: var(--color-scale-yellow-2);
border: 1px solid var(--color-scale-yellow-4);
}
.label-color-2 {
background-color: var(--color-scale-purple-9);
color: var(--color-scale-purple-2);
border: 1px solid var(--color-scale-purple-4);
}
.label-color-3 {
background-color: var(--color-scale-pink-9);
color: var(--color-scale-pink-2);
border: 1px solid var(--color-scale-pink-4);
}
.label-color-4 {
background-color: var(--color-scale-coral-9);
color: var(--color-scale-coral-2);
border: 1px solid var(--color-scale-coral-4);
}
.label-color-5 {
background-color: var(--color-scale-orange-9);
color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4);
}
}
.d-flex {
display: flex !important;
}
.d-inline {
display: inline !important;
}
.pl-2 {
padding-left: 8px !important;
}
.ml-2 {
margin-left: 8px !important;
}
.no-wrap {
white-space: nowrap !important;
}
.float-left {
float: left !important;
}
article, aside, details, figcaption, figure, footer, header, main, menu, nav, section {
display: block;
}
.form-control, .form-select {
padding: 5px 12px;
font-size: 14px;
line-height: 20px;
color: var(--color-fg-default);
vertical-align: middle;
background-color: var(--color-canvas-default);
background-repeat: no-repeat;
background-position: right 8px center;
border: 1px solid var(--color-border-default);
border-radius: 6px;
outline: none;
box-shadow: var(--color-primer-shadow-inset);
}
.input-contrast {
background-color: var(--color-canvas-inset);
}
.subnav-search {
position: relative;
flex: auto;
display: flex;
}
.subnav-search-input {
flex: auto;
padding-left: 32px;
color: var(--color-fg-muted);
}
.subnav-search-icon {
position: absolute;
top: 9px;
left: 8px;
display: block;
color: var(--color-fg-muted);
text-align: center;
pointer-events: none;
}
.subnav-search-context + .subnav-search {
margin-left: -1px;
}
.subnav-item {
flex: none;
position: relative;
float: left;
padding: 5px 10px;
font-weight: 500;
line-height: 20px;
color: var(--color-fg-default);
border: 1px solid var(--color-border-default);
}
.subnav-item:hover {
background-color: var(--color-canvas-subtle);
}
.subnav-item:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
}
.subnav-item:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
.subnav-item + .subnav-item {
margin-left: -1px;
}
.counter {
display: inline-block;
min-width: 20px;
padding: 0 6px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
color: var(--color-fg-default);
text-align: center;
background-color: var(--color-neutral-muted);
border: 1px solid transparent;
border-radius: 2em;
}
@media only screen and (max-width: 600px) {
.flow-container {
padding: 0;
}
.chip-header {
border-radius: 0 !important;
border-right: none !important;
border-left: none !important;
}
.chip-body {
border-radius: 0 !important;
border-right: none !important;
border-left: none !important;
padding: 5px !important;
}
.test-result {
.htmlreport {
padding: 0 !important;
}
.test-case-column {
border-radius: 0 !important;
margin: 0 !important;
}
.subnav-item, .form-control {
border-radius: 0 !important;
}
.subnav-item {
padding: 5px 3px;
border: none;
}
.status-container {
float: none;
margin: 0 !important;
margin: 0 0 10px 0 !important;
overflow: hidden;
}
.subnav-search-input {
border-left: none;
border-right: none;
}
}

View file

@ -14,15 +14,16 @@
limitations under the License.
*/
import './htmlReport.css';
import * as React from 'react';
import ansi2html from 'ansi-to-html';
import { downArrow, rightArrow, TreeItem } from '../components/treeItem';
import { TabbedPane } from '../traceViewer/ui/tabbedPane';
import { msToString } from '../uiUtils';
import { traceImage } from './images';
import type { TestCase, TestResult, TestStep, TestFile, Stats, TestAttachment, HTMLReport, TestFileSummary, TestCaseSummary } from '@playwright/test/src/reporters/html';
import type { HTMLReport, TestFileSummary, TestCase, TestFile } from '@playwright/test/src/reporters/html';
import type zip from '@zip.js/zip.js';
import * as React from 'react';
import { Filter } from './filter';
import './colors.css';
import './common.css';
import './htmlReport.css';
import { StatsNavView } from './statsNavView';
import { TestCaseView } from './testCaseView';
import { TestFileView } from './testFileView';
const zipjs = (self as any).zip;
@ -58,8 +59,27 @@ export const Report: React.FC = () => {
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
return <div className='vbox columns'>
{<div className='flow-container'>
return <div className='htmlreport vbox px-4'>
{report && <div className='pt-3'>
<div className='status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={report.stats}></StatsNavView>
</div>
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'>
<path fillRule='evenodd' d='M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z'></path>
</svg>
{/* 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>}
{<>
<Route params=''>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
</Route>
@ -67,9 +87,9 @@ export const Report: React.FC = () => {
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
</Route>
<Route params='testId'>
{!!report && <TestCaseView report={report}></TestCaseView>}
{!!report && <TestCaseViewWrapper report={report}></TestCaseViewWrapper>}
</Route>
</div>}
</>}
</div>;
};
@ -93,28 +113,9 @@ const AllTestFilesSummaryView: React.FC<{
}
return result;
}, [report, filter]);
return <div className='file-summary-list'>
{report && <div>
<div className='status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={report.stats}></StatsNavView>
</div>
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon subnav-search-icon'>
<path fillRule='evenodd' d='M11.5 7a4.499 4.499 0 11-8.998 0A4.499 4.499 0 0111.5 7zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z'></path>
</svg>
{/* 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>}
return <>
{report && filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileSummaryView
return <TestFileView
key={`file-${file.fileId}`}
report={report}
file={file}
@ -130,41 +131,12 @@ const AllTestFilesSummaryView: React.FC<{
setExpandedFiles(newExpanded);
}}
filter={filter}>
</TestFileSummaryView>;
</TestFileView>;
})}
</div>;
</>;
};
const TestFileSummaryView: React.FC<{
report: HTMLReport;
file: TestFileSummary;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
filter: Filter;
}> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
return <Chip
expanded={isFileExpanded(file.fileId)}
setExpanded={(expanded => setFileExpanded(file.fileId, expanded))}
header={<span>
<span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span>
{file.fileName}
</span>}>
{file.tests.filter(t => filter.matches(t)).map(test =>
<div key={`test-${test.testId}`} className={'test-summary outcome-' + test.outcome}>
<span style={{ float: 'right' }}>{msToString(test.duration)}</span>
{report.projectNames.length > 1 && !!test.projectName &&
<span style={{ float: 'right' }}><ProjectLink report={report} projectName={test.projectName}></ProjectLink></span>}
{statusIcon(test.outcome)}
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' ')}>
{[...test.path, test.title].join(' ')}
<span className='test-summary-path'> {test.location.file}:{test.location.line}</span>
</Link>
</div>
)}
</Chip>;
};
const TestCaseView: React.FC<{
const TestCaseViewWrapper: React.FC<{
report: HTMLReport,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
@ -186,273 +158,7 @@ const TestCaseView: React.FC<{
}
})();
}, [test, report, testId]);
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
return <div className='test-case-column vbox'>
<div className='status-container ml-2 pl-2 d-flex' style={{ flexFlow: 'row-reverse' }}>
<StatsNavView stats={report.stats}></StatsNavView>
</div>
{test && <div className='test-case-path'>{test.path.join(' ')}</div>}
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='test-case-location'>{test.location.file}:{test.location.line}</div>}
{test && !!test.projectName && <ProjectLink report={report} projectName={test.projectName}></ProjectLink>}
{test && !!test.annotations.length && <Chip header='Annotations'>
{test.annotations.map(a => <div className='test-case-annotation'>
<span style={{ fontWeight: 'bold' }}>{a.type}</span>
{a.description && <span>: {a.description}</span>}
</div>)}
</Chip>}
{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: () => <TestResultView test={test!} result={result}></TestResultView>
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
</div>;
};
const TestResultView: React.FC<{
test: TestCase,
result: TestResult,
}> = ({ result }) => {
const { screenshots, videos, traces, otherAttachments, attachmentsMap } = React.useMemo(() => {
const attachmentsMap = new Map<string, TestAttachment>();
const attachments = result?.attachments || [];
const otherAttachments: TestAttachment[] = [];
const screenshots = attachments.filter(a => a.name === 'screenshot');
const videos = attachments.filter(a => a.name === 'video');
const traces = attachments.filter(a => a.name === 'trace');
const knownNames = new Set(['screenshot', 'image', 'expected', 'actual', 'diff', 'video', 'trace']);
for (const a of attachments) {
attachmentsMap.set(a.name, a);
if (!knownNames.has(a.name))
otherAttachments.push(a);
}
return { attachmentsMap, screenshots, videos, otherAttachments, traces };
}, [ result ]);
const expected = attachmentsMap.get('expected');
const actual = attachmentsMap.get('actual');
const diff = attachmentsMap.get('diff');
return <div className='test-result'>
{result.error && <Chip header='Errors'>
<ErrorMessage key='error-message' error={result.error}></ErrorMessage>
</Chip>}
{!!result.steps.length && <Chip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
</Chip>}
{expected && actual && <Chip header='Image mismatch'>
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
</Chip>}
{!!screenshots.length && <Chip header='Screenshots'>
{screenshots.map((a, i) => {
return <div key={`screenshot-${i}`}>
<img src={a.path} />
<AttachmentLink attachment={a}></AttachmentLink>
</div>;
})}
</Chip>}
{!!traces.length && <Chip header='Traces'>
{traces.map((a, i) => <div key={`trace-${i}`}>
<a href={`trace/index.html?trace=${new URL(a.path!, window.location.href)}`}>
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
</a>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
</Chip>}
{!!videos.length && <Chip header='Videos'>
{videos.map((a, i) => <div key={`video-${i}`}>
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
</Chip>}
{!!otherAttachments.length && <Chip header='Attachments'>
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</Chip>}
</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>
{step.location && <span className='test-summary-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 StatsNavView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <nav className='subnav-links d-flex no-wrap'>
<Link className='subnav-item' href='#?'>
All <span className='d-inline counter'>{stats.total}</span>
</Link>
<Link className='subnav-item' href='#?q=s:passed'>
Passed <span className='d-inline counter'>{stats.expected}</span>
</Link>
<Link className='subnav-item' href='#?q=s:failed'>
{!!stats.unexpected && statusIcon('unexpected')} Failed <span className='d-inline counter'>{stats.unexpected}</span>
</Link>
<Link className='subnav-item' href='#?q=s:flaky'>
{!!stats.flaky && statusIcon('flaky')} Flaky <span className='d-inline counter'>{stats.flaky}</span>
</Link>
<Link className='subnav-item' href='#?q=s:skipped'>
Skipped <span className='d-inline counter'>{stats.skipped}</span>
</Link>
</nav>;
};
const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment,
href?: string,
}> = ({ attachment, href }) => {
return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ?
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-warning'>
<path fillRule='evenodd' d='M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z'></path>
</svg> :
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M3.5 1.75a.25.25 0 01.25-.25h3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h2.086a.25.25 0 01.177.073l2.914 2.914a.25.25 0 01.073.177v8.586a.25.25 0 01-.25.25h-.5a.75.75 0 000 1.5h.5A1.75 1.75 0 0014 13.25V4.664c0-.464-.184-.909-.513-1.237L10.573.513A1.75 1.75 0 009.336 0H3.75A1.75 1.75 0 002 1.75v11.5c0 .649.353 1.214.874 1.515a.75.75 0 10.752-1.298.25.25 0 01-.126-.217V1.75zM8.75 3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM6 5.25a.75.75 0 01.75-.75h.5a.75.75 0 010 1.5h-.5A.75.75 0 016 5.25zm2 1.5A.75.75 0 018.75 6h.5a.75.75 0 010 1.5h-.5A.75.75 0 018 6.75zm-1.25.75a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM8 9.75A.75.75 0 018.75 9h.5a.75.75 0 010 1.5h-.5A.75.75 0 018 9.75zm-.75.75a1.75 1.75 0 00-1.75 1.75v3c0 .414.336.75.75.75h2.5a.75.75 0 00.75-.75v-3a1.75 1.75 0 00-1.75-1.75h-.5zM7 12.25a.25.25 0 01.25-.25h.5a.25.25 0 01.25.25v2.25H7v-2.25z'></path>
</svg>
}
{attachment.path && <a href={href || attachment.path} target='_blank'>{attachment.name}</a>}
{attachment.body && <span>{attachment.name}</span>}
</span>} loadChildren={attachment.body ? () => {
return [<div className='attachment-body'>{attachment.body}</div>];
} : undefined} depth={0}></TreeItem>;
};
const ImageDiff: React.FunctionComponent<{
actual: TestAttachment,
expected: TestAttachment,
diff?: TestAttachment,
}> = ({ actual, expected, diff }) => {
const [selectedTab, setSelectedTab] = React.useState<string>('actual');
const tabs = [];
tabs.push({
id: 'actual',
title: 'Actual',
render: () => <img src={actual.path}/>
});
tabs.push({
id: 'expected',
title: 'Expected',
render: () => <img src={expected.path}/>
});
if (diff) {
tabs.push({
id: 'diff',
title: 'Diff',
render: () => <img src={diff.path}/>
});
}
return <div className='vbox test-image-mismatch'>
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
</div>;
};
function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
switch (status) {
case 'failed':
case 'unexpected':
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
</svg>;
case 'passed':
case 'expected':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
</svg>;
case 'timedOut':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-danger'>
<path fillRule='evenodd' d='M5.75.75A.75.75 0 016.5 0h3a.75.75 0 010 1.5h-.75v1l-.001.041a6.718 6.718 0 013.464 1.435l.007-.006.75-.75a.75.75 0 111.06 1.06l-.75.75-.006.007a6.75 6.75 0 11-10.548 0L2.72 5.03l-.75-.75a.75.75 0 011.06-1.06l.75.75.007.006A6.718 6.718 0 017.25 2.541a.756.756 0 010-.041v-1H6.5a.75.75 0 01-.75-.75zM8 14.5A5.25 5.25 0 108 4a5.25 5.25 0 000 10.5zm.389-6.7l1.33-1.33a.75.75 0 111.061 1.06L9.45 8.861A1.502 1.502 0 018 10.75a1.5 1.5 0 11.389-2.95z'></path>
</svg>;
case 'flaky':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-warning'>
<path fillRule='evenodd' d='M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z'></path>
</svg>;
case 'skipped':
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
}
}
function retryLabel(index: number) {
if (!index)
return 'Run';
return `Retry #${index}`;
}
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='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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}
const Chip: React.FunctionComponent<{
header: JSX.Element | string,
expanded?: boolean,
setExpanded?: (expanded: boolean) => void,
children?: any
}> = ({ header, expanded, setExpanded, children }) => {
return <div className='chip'>
<div className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} onClick={() => setExpanded?.(!expanded)}>
{setExpanded && !!expanded && downArrow()}
{setExpanded && !expanded && rightArrow()}
{header}
</div>
{ (!setExpanded || expanded) && <div className='chip-body'>{children}</div>}
</div>;
return <TestCaseView report={report} test={test}></TestCaseView>;
};
function navigate(href: string) {
@ -461,28 +167,6 @@ function navigate(href: string) {
window.dispatchEvent(navEvent);
}
const ProjectLink: React.FunctionComponent<{
report: HTMLReport,
projectName: string,
}> = ({ report, projectName }) => {
const encoded = encodeURIComponent(projectName);
const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`;
return <Link href={`#?q=p:${value}`}>
<span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 6)}>
{projectName}
</span>
</Link>;
};
const Link: React.FunctionComponent<{
href: string,
className?: string,
title?: string,
children: any,
}> = ({ href, className, children, title }) => {
return <a className={`no-decorations${className ? ' ' + className : ''}`} href={href} title={title}>{children}</a>;
};
const Route: React.FunctionComponent<{
params: string,
children: any
@ -500,130 +184,9 @@ const Route: React.FunctionComponent<{
return currentParams === params ? children : null;
};
class Filter {
project: string[] = [];
status: string[] = [];
text: string[] = [];
empty(): boolean {
return this.project.length + this.status.length + this.text.length === 0;
}
static parse(expression: string): Filter {
const tokens = Filter.tokenize(expression);
const project = new Set<string>();
const status = new Set<string>();
const text: string[] = [];
for (const token of tokens) {
if (token.startsWith('p:')) {
project.add(token.slice(2));
continue;
}
if (token.startsWith('s:')) {
status.add(token.slice(2));
continue;
}
text.push(token.toLowerCase());
}
const filter = new Filter();
filter.text = text;
filter.project = [...project];
filter.status = [...status];
return filter;
}
private static tokenize(expression: string): string[] {
const result: string[] = [];
let quote: '\'' | '"' | undefined;
let token: string[] = [];
for (let i = 0; i < expression.length; ++i) {
const c = expression[i];
if (quote && c === '\\' && expression[i + 1] === quote) {
token.push(quote);
++i;
continue;
}
if (c === '"' || c === '\'') {
if (quote === c) {
result.push(token.join('').toLowerCase());
token = [];
quote = undefined;
} else if (quote) {
token.push(c);
} else {
quote = c;
}
continue;
}
if (quote) {
token.push(c);
continue;
}
if (c === ' ') {
if (token.length) {
result.push(token.join('').toLowerCase());
token = [];
}
continue;
}
token.push(c);
}
if (token.length)
result.push(token.join('').toLowerCase());
return result;
}
matches(test: TestCaseSummary): boolean {
if (!(test as any).searchValues) {
let status = 'passed';
if (test.outcome === 'unexpected')
status = 'failed';
if (test.outcome === 'flaky')
status = 'flaky';
if (test.outcome === 'skipped')
status = 'skipped';
const searchValues: SearchValues = {
text: (status + ' ' + test.projectName + ' ' + test.path.join(' ') + test.title).toLowerCase(),
project: test.projectName.toLowerCase(),
status: status as any
};
(test as any).searchValues = searchValues;
}
const searchValues = (test as any).searchValues as SearchValues;
if (this.project.length) {
const matches = !!this.project.find(p => searchValues.project.includes(p));
if (!matches)
return false;
}
if (this.status.length) {
const matches = !!this.status.find(s => searchValues.status.includes(s));
if (!matches)
return false;
}
if (this.text.length) {
const matches = this.text.filter(t => searchValues.text.includes(t)).length === this.text.length;
if (!matches)
return false;
}
return true;
}
}
async function readJsonEntry(entryName: string): Promise<any> {
const reportEntry = window.entries.get(entryName);
const writer = new zipjs.TextWriter() as zip.TextWriter;
await reportEntry!.getData!(writer);
return JSON.parse(await writer.getData());
}
type SearchValues = {
text: string;
project: string;
status: 'passed' | 'failed' | 'flaky' | 'skipped';
};
const kMissingContentType = 'x-playwright/missing';

View file

@ -0,0 +1,104 @@
/*
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.
*/
.label {
display: inline-block;
padding: 0 8px;
font-size: 12px;
font-weight: 500;
line-height: 18px;
border: 1px solid transparent;
border-radius: 2em;
background-color: var(--color-scale-gray-4);
color: white;
margin: 0 10px;
flex: none;
font-weight: 600;
}
@media(prefers-color-scheme: light) {
.label-color-0 {
background-color: var(--color-scale-blue-0);
color: var(--color-scale-blue-6);
border: 1px solid var(--color-scale-blue-4);
}
.label-color-1 {
background-color: var(--color-scale-yellow-0);
color: var(--color-scale-yellow-6);
border: 1px solid var(--color-scale-yellow-4);
}
.label-color-2 {
background-color: var(--color-scale-purple-0);
color: var(--color-scale-purple-6);
border: 1px solid var(--color-scale-purple-4);
}
.label-color-3 {
background-color: var(--color-scale-pink-0);
color: var(--color-scale-pink-6);
border: 1px solid var(--color-scale-pink-4);
}
.label-color-4 {
background-color: var(--color-scale-coral-0);
color: var(--color-scale-coral-6);
border: 1px solid var(--color-scale-coral-4);
}
.label-color-5 {
background-color: var(--color-scale-orange-0);
color: var(--color-scale-orange-6);
border: 1px solid var(--color-scale-orange-4);
}
}
@media(prefers-color-scheme: dark) {
.label-color-0 {
background-color: var(--color-scale-blue-9);
color: var(--color-scale-blue-2);
border: 1px solid var(--color-scale-blue-4);
}
.label-color-1 {
background-color: var(--color-scale-yellow-9);
color: var(--color-scale-yellow-2);
border: 1px solid var(--color-scale-yellow-4);
}
.label-color-2 {
background-color: var(--color-scale-purple-9);
color: var(--color-scale-purple-2);
border: 1px solid var(--color-scale-purple-4);
}
.label-color-3 {
background-color: var(--color-scale-pink-9);
color: var(--color-scale-pink-2);
border: 1px solid var(--color-scale-pink-4);
}
.label-color-4 {
background-color: var(--color-scale-coral-9);
color: var(--color-scale-coral-2);
border: 1px solid var(--color-scale-coral-4);
}
.label-color-5 {
background-color: var(--color-scale-orange-9);
color: var(--color-scale-orange-2);
border: 1px solid var(--color-scale-orange-4);
}
}
.attachment-link {
white-space: pre-wrap;
background-color: var(--color-canvas-subtle);
margin-left: 24px;
line-height: normal;
padding: 8px;
}

View file

@ -0,0 +1,64 @@
/*
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 { HTMLReport, TestAttachment } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import { TreeItem } from './treeItem';
import './links.css';
export const Link: React.FunctionComponent<{
href: string,
className?: string,
title?: string,
children: any,
}> = ({ href, className, children, title }) => {
return <a style={{ textDecoration: 'none', color: 'initial' }} className={`${className || ''}`} href={href} title={title}>{children}</a>;
};
export const ProjectLink: React.FunctionComponent<{
report: HTMLReport,
projectName: string,
}> = ({ report, projectName }) => {
const encoded = encodeURIComponent(projectName);
const value = projectName === encoded ? projectName : `"${encoded.replace(/%22/g, '%5C%22')}"`;
return <Link href={`#?q=p:${value}`}>
<span className={'label label-color-' + (report.projectNames.indexOf(projectName) % 6)}>
{projectName}
</span>
</Link>;
};
export const AttachmentLink: React.FunctionComponent<{
attachment: TestAttachment,
href?: string,
}> = ({ attachment, href }) => {
return <TreeItem title={<span>
{attachment.contentType === kMissingContentType ?
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-warning'>
<path fillRule='evenodd' d='M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z'></path>
</svg> :
<svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M3.5 1.75a.25.25 0 01.25-.25h3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h2.086a.25.25 0 01.177.073l2.914 2.914a.25.25 0 01.073.177v8.586a.25.25 0 01-.25.25h-.5a.75.75 0 000 1.5h.5A1.75 1.75 0 0014 13.25V4.664c0-.464-.184-.909-.513-1.237L10.573.513A1.75 1.75 0 009.336 0H3.75A1.75 1.75 0 002 1.75v11.5c0 .649.353 1.214.874 1.515a.75.75 0 10.752-1.298.25.25 0 01-.126-.217V1.75zM8.75 3a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM6 5.25a.75.75 0 01.75-.75h.5a.75.75 0 010 1.5h-.5A.75.75 0 016 5.25zm2 1.5A.75.75 0 018.75 6h.5a.75.75 0 010 1.5h-.5A.75.75 0 018 6.75zm-1.25.75a.75.75 0 000 1.5h.5a.75.75 0 000-1.5h-.5zM8 9.75A.75.75 0 018.75 9h.5a.75.75 0 010 1.5h-.5A.75.75 0 018 9.75zm-.75.75a1.75 1.75 0 00-1.75 1.75v3c0 .414.336.75.75.75h2.5a.75.75 0 00.75-.75v-3a1.75 1.75 0 00-1.75-1.75h-.5zM7 12.25a.25.25 0 01.25-.25h.5a.25.25 0 01.25.25v2.25H7v-2.25z'></path>
</svg>
}
{attachment.path && <a href={href || attachment.path} target='_blank'>{attachment.name}</a>}
{attachment.body && <span>{attachment.name}</span>}
</span>} loadChildren={attachment.body ? () => {
return [<div className='attachment-link'>{attachment.body}</div>];
} : undefined} depth={0}></TreeItem>;
};
const kMissingContentType = 'x-playwright/missing';

View file

@ -0,0 +1,43 @@
/*
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 { Stats } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import './htmlReport.css';
import { Link } from './links';
import { statusIcon } from './statusIcon';
export const StatsNavView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <nav className='d-flex no-wrap'>
<Link className='subnav-item' href='#?'>
All <span className='d-inline counter'>{stats.total}</span>
</Link>
<Link className='subnav-item' href='#?q=s:passed'>
Passed <span className='d-inline counter'>{stats.expected}</span>
</Link>
<Link className='subnav-item' href='#?q=s:failed'>
{!!stats.unexpected && statusIcon('unexpected')} Failed <span className='d-inline counter'>{stats.unexpected}</span>
</Link>
<Link className='subnav-item' href='#?q=s:flaky'>
{!!stats.flaky && statusIcon('flaky')} Flaky <span className='d-inline counter'>{stats.flaky}</span>
</Link>
<Link className='subnav-item' href='#?q=s:skipped'>
Skipped <span className='d-inline counter'>{stats.skipped}</span>
</Link>
</nav>;
};

View file

@ -0,0 +1,43 @@
/*
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 * as React from 'react';
import './htmlReport.css';
export function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
switch (status) {
case 'failed':
case 'unexpected':
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
<path fillRule='evenodd' d='M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z'></path>
</svg>;
case 'passed':
case 'expected':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-icon-success'>
<path fillRule='evenodd' d='M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z'></path>
</svg>;
case 'timedOut':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-danger'>
<path fillRule='evenodd' d='M5.75.75A.75.75 0 016.5 0h3a.75.75 0 010 1.5h-.75v1l-.001.041a6.718 6.718 0 013.464 1.435l.007-.006.75-.75a.75.75 0 111.06 1.06l-.75.75-.006.007a6.75 6.75 0 11-10.548 0L2.72 5.03l-.75-.75a.75.75 0 011.06-1.06l.75.75.007.006A6.718 6.718 0 017.25 2.541a.756.756 0 010-.041v-1H6.5a.75.75 0 01-.75-.75zM8 14.5A5.25 5.25 0 108 4a5.25 5.25 0 000 10.5zm.389-6.7l1.33-1.33a.75.75 0 111.061 1.06L9.45 8.861A1.502 1.502 0 018 10.75a1.5 1.5 0 11.389-2.95z'></path>
</svg>;
case 'flaky':
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-text-warning'>
<path fillRule='evenodd' d='M8.22 1.754a.25.25 0 00-.44 0L1.698 13.132a.25.25 0 00.22.368h12.164a.25.25 0 00.22-.368L8.22 1.754zm-1.763-.707c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0114.082 15H1.918a1.75 1.75 0 01-1.543-2.575L6.457 1.047zM9 11a1 1 0 11-2 0 1 1 0 012 0zm-.25-5.25a.75.75 0 00-1.5 0v2.5a.75.75 0 001.5 0v-2.5z'></path>
</svg>;
case 'skipped':
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
}
}

View file

@ -0,0 +1,76 @@
/*
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.
*/
.tabbed-pane {
display: flex;
flex: auto;
overflow: hidden;
}
.tabbed-pane-tab-content {
display: flex;
flex: auto;
overflow: hidden;
}
.tabbed-pane-tab-strip {
display: flex;
align-items: center;
padding-right: 10px;
flex: none;
width: 100%;
z-index: 2;
font-size: 14px;
line-height: 32px;
color: var(--color-fg-default);
height: 48px;
min-width: 70px;
box-shadow: inset 0 -1px 0 var(--color-border-muted) !important;
}
.tabbed-pane-tab-strip:focus {
outline: none;
}
.tabbed-pane-tab-element {
padding: 4px 8px 0 8px;
margin-right: 4px;
cursor: pointer;
display: flex;
flex: none;
align-items: center;
justify-content: center;
user-select: none;
border-bottom: 2px solid transparent;
outline: none;
height: 100%;
}
.tabbed-pane-tab-label {
max-width: 250px;
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.tabbed-pane-tab-element.selected {
border-bottom-color: #666;
}
.tabbed-pane-tab-element:hover {
color: #333;
}

View file

@ -0,0 +1,53 @@
/**
* 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 './tabbedPane.css';
import * as React from 'react';
export interface TabbedPaneTab {
id: string;
title: string | JSX.Element;
count?: number;
render: () => React.ReactElement;
}
export const TabbedPane: React.FunctionComponent<{
tabs: TabbedPaneTab[],
selectedTab: string,
setSelectedTab: (tab: string) => void
}> = ({ tabs, selectedTab, setSelectedTab }) => {
return <div className='tabbed-pane'>
<div className='vbox'>
<div className='hbox' style={{ flex: 'none' }}>
<div className='tabbed-pane-tab-strip'>{
tabs.map(tab => (
<div className={'tabbed-pane-tab-element ' + (selectedTab === tab.id ? 'selected' : '')}
onClick={() => setSelectedTab(tab.id)}
key={tab.id}>
<div className='tabbed-pane-tab-label'>{tab.title}</div>
</div>
))
}</div>
</div>
{
tabs.map(tab => {
if (selectedTab === tab.id)
return <div key={tab.id} className='tab-content'>{tab.render()}</div>;
})
}
</div>
</div>;
};

View file

@ -0,0 +1,69 @@
/*
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.
*/
.test-case-column {
border-radius: 6px;
margin: 24px 0;
}
.test-case-column .tab-element.selected {
font-weight: 600;
border-bottom-color: var(--color-primer-border-active);
}
.test-case-column .tab-element {
border: none;
color: var(--color-fg-default);
border-bottom: 2px solid transparent;
}
.test-case-column .tab-element:hover {
color: var(--color-fg-default);
}
.test-case-title {
flex: none;
padding: 8px;
font-weight: 400;
font-size: 32px !important;
line-height: 1.25 !important;
}
.test-case-location {
flex: none;
align-items: center;
padding: 0 8px 24px;
}
.test-case-path {
flex: none;
align-items: center;
padding: 0 8px;
}
.test-case-annotation {
flex: none;
align-items: center;
padding: 0 8px;
line-height: 24px;
}
@media only screen and (max-width: 600px) {
.test-case-column {
border-radius: 0 !important;
margin: 0 !important;
}
}

View file

@ -0,0 +1,57 @@
/*
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 { HTMLReport, TestCase } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import { TabbedPane } from './tabbedPane';
import { Chip } from './chip';
import './common.css';
import { ProjectLink } from './links';
import { statusIcon } from './statusIcon';
import './testCaseView.css';
import { TestResultView } from './testResultView';
export const TestCaseView: React.FC<{
report: HTMLReport,
test: TestCase | undefined,
}> = ({ report, test }) => {
const [selectedResultIndex, setSelectedResultIndex] = React.useState(0);
return <div className='test-case-column vbox'>
{test && <div className='test-case-path'>{test.path.join(' ')}</div>}
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='test-case-location'>{test.location.file}:{test.location.line}</div>}
{test && !!test.projectName && <ProjectLink report={report} projectName={test.projectName}></ProjectLink>}
{test && !!test.annotations.length && <Chip header='Annotations'>
{test.annotations.map(a => <div className='test-case-annotation'>
<span style={{ fontWeight: 'bold' }}>{a.type}</span>
{a.description && <span>: {a.description}</span>}
</div>)}
</Chip>}
{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: () => <TestResultView test={test!} result={result}></TestResultView>
})) || []} selectedTab={String(selectedResultIndex)} setSelectedTab={id => setSelectedResultIndex(+id)} />}
</div>;
};
function retryLabel(index: number) {
if (!index)
return 'Run';
return `Retry #${index}`;
}

View file

@ -0,0 +1,38 @@
/*
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.
*/
.test-file-test {
height: 38px;
line-height: 38px;
align-items: center;
padding: 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.test-file-test:hover {
background-color: var(--color-canvas-subtle);
}
.test-file-path {
padding: 0 0 0 8px;
color: var(--color-fg-muted);
}
.test-file-test-outcome-skipped {
color: var(--color-fg-muted);
}

View file

@ -0,0 +1,54 @@
/*
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 { HTMLReport, TestFileSummary } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import { msToString } from '../uiUtils';
import { Chip } from './chip';
import { Filter } from './filter';
import { Link, ProjectLink } from './links';
import { statusIcon } from './statusIcon';
import './testFileView.css';
export const TestFileView: React.FC<{
report: HTMLReport;
file: TestFileSummary;
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
filter: Filter;
}> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
return <Chip
expanded={isFileExpanded(file.fileId)}
noInsets={true}
setExpanded={(expanded => setFileExpanded(file.fileId, expanded))}
header={<span>
<span style={{ float: 'right' }}>{msToString(file.stats.duration)}</span>
{file.fileName}
</span>}>
{file.tests.filter(t => filter.matches(t)).map(test =>
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}>
<span style={{ float: 'right' }}>{msToString(test.duration)}</span>
{report.projectNames.length > 1 && !!test.projectName &&
<span style={{ float: 'right' }}><ProjectLink report={report} projectName={test.projectName}></ProjectLink></span>}
{statusIcon(test.outcome)}
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' ')}>
{[...test.path, test.title].join(' ')}
<span className='test-file-path'> {test.location.file}:{test.location.line}</span>
</Link>
</div>
)}
</Chip>;
};

View file

@ -0,0 +1,64 @@
/*
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.
*/
.test-result {
flex: auto;
display: flex;
flex-direction: column;
margin-bottom: 24px;
}
.test-result .tabbed-pane .tab-content {
display: flex;
align-items: center;
justify-content: center;
}
.test-result > div {
flex: none;
}
.test-result video,
.test-result img {
flex: none;
box-shadow: var(--box-shadow-thick);
margin: 24px auto;
min-width: 200px;
max-width: 80%;
}
.test-result-path {
padding: 0 0 0 5px;
color: var(--color-fg-muted);
}
.test-result-error-message {
white-space: pre;
font-family: monospace;
overflow: auto;
flex: none;
padding: 0;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
padding: 16px;
line-height: initial;
}
@media only screen and (max-width: 600px) {
.test-result {
padding: 0 !important;
}
}

View file

@ -0,0 +1,182 @@
/*
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';
import { msToString } from '../uiUtils';
import { Chip } from './chip';
import { traceImage } from './images';
import { AttachmentLink } from './links';
import { statusIcon } from './statusIcon';
import './testResultView.css';
export const TestResultView: React.FC<{
test: TestCase,
result: TestResult,
}> = ({ result }) => {
const { screenshots, videos, traces, otherAttachments, attachmentsMap } = React.useMemo(() => {
const attachmentsMap = new Map<string, TestAttachment>();
const attachments = result?.attachments || [];
const otherAttachments: TestAttachment[] = [];
const screenshots = attachments.filter(a => a.name === 'screenshot');
const videos = attachments.filter(a => a.name === 'video');
const traces = attachments.filter(a => a.name === 'trace');
const knownNames = new Set(['screenshot', 'image', 'expected', 'actual', 'diff', 'video', 'trace']);
for (const a of attachments) {
attachmentsMap.set(a.name, a);
if (!knownNames.has(a.name))
otherAttachments.push(a);
}
return { attachmentsMap, screenshots, videos, otherAttachments, traces };
}, [ result ]);
const expected = attachmentsMap.get('expected');
const actual = attachmentsMap.get('actual');
const diff = attachmentsMap.get('diff');
return <div className='test-result'>
{result.error && <Chip header='Errors'>
<ErrorMessage key='test-result-error-message' error={result.error}></ErrorMessage>
</Chip>}
{!!result.steps.length && <Chip header='Test Steps'>
{result.steps.map((step, i) => <StepTreeItem key={`step-${i}`} step={step} depth={0}></StepTreeItem>)}
</Chip>}
{expected && actual && <Chip header='Image mismatch'>
<ImageDiff actual={actual} expected={expected} diff={diff}></ImageDiff>
<AttachmentLink key={`expected`} attachment={expected}></AttachmentLink>
<AttachmentLink key={`actual`} attachment={actual}></AttachmentLink>
{diff && <AttachmentLink key={`diff`} attachment={diff}></AttachmentLink>}
</Chip>}
{!!screenshots.length && <Chip header='Screenshots'>
{screenshots.map((a, i) => {
return <div key={`screenshot-${i}`}>
<img src={a.path} />
<AttachmentLink attachment={a}></AttachmentLink>
</div>;
})}
</Chip>}
{!!traces.length && <Chip header='Traces'>
{traces.map((a, i) => <div key={`trace-${i}`}>
<a href={`trace/index.html?trace=${new URL(a.path!, window.location.href)}`}>
<img src={traceImage} style={{ width: 192, height: 117, marginLeft: 20 }} />
</a>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
</Chip>}
{!!videos.length && <Chip header='Videos'>
{videos.map((a, i) => <div key={`video-${i}`}>
<video controls>
<source src={a.path} type={a.contentType}/>
</video>
<AttachmentLink attachment={a}></AttachmentLink>
</div>)}
</Chip>}
{!!otherAttachments.length && <Chip header='Attachments'>
{otherAttachments.map((a, i) => <AttachmentLink key={`attachment-link-${i}`} attachment={a}></AttachmentLink>)}
</Chip>}
</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>
{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<{
actual: TestAttachment,
expected: TestAttachment,
diff?: TestAttachment,
}> = ({ actual, expected, diff }) => {
const [selectedTab, setSelectedTab] = React.useState<string>('actual');
const tabs = [];
tabs.push({
id: 'actual',
title: 'Actual',
render: () => <img src={actual.path}/>
});
tabs.push({
id: 'expected',
title: 'Expected',
render: () => <img src={expected.path}/>
});
if (diff) {
tabs.push({
id: 'diff',
title: 'Diff',
render: () => <img src={diff.path}/>
});
}
return <div className='vbox' data-testid='test-result-image-mismatch'>
<TabbedPane tabs={tabs} selectedTab={selectedTab} setSelectedTab={setSelectedTab} />
</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 => ({ '&': '&amp;', '"': '&quot;', '<': '&lt;', '>': '&gt;' }[c]!));
}

View file

@ -0,0 +1,30 @@
/*
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.
*/
.tree-item {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
line-height: 38px;
}
.tree-item-title {
cursor: pointer;
}
.tree-item-body {
min-height: 18px;
}

View file

@ -15,6 +15,7 @@
*/
import * as React from 'react';
import './treeItem.css';
export const TreeItem: React.FunctionComponent<{
title: JSX.Element,
@ -37,13 +38,13 @@ export const TreeItem: React.FunctionComponent<{
</div>;
};
export function downArrow() {
function downArrow() {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M12.78 6.22a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06 0L3.22 7.28a.75.75 0 011.06-1.06L8 9.94l3.72-3.72a.75.75 0 011.06 0z'></path>
</svg>;
}
export function rightArrow() {
function rightArrow() {
return <svg aria-hidden='true' height='16' viewBox='0 0 16 16' version='1.1' width='16' data-view-component='true' className='octicon color-fg-muted'>
<path fillRule='evenodd' d='M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z'></path>
</svg>;

View file

@ -61,10 +61,10 @@ test('should generate report', async ({ runInlineTest, showReport, page }) => {
await expect(page.locator('.subnav-item:has-text("Flaky") .counter')).toHaveText('1');
await expect(page.locator('.subnav-item:has-text("Skipped") .counter')).toHaveText('1');
await expect(page.locator('.test-summary.outcome-unexpected >> text=fails')).toBeVisible();
await expect(page.locator('.test-summary.outcome-flaky >> text=flaky')).toBeVisible();
await expect(page.locator('.test-summary.outcome-expected >> text=passes')).toBeVisible();
await expect(page.locator('.test-summary.outcome-skipped >> text=skipped')).toBeVisible();
await expect(page.locator('.test-file-test-outcome-unexpected >> text=fails')).toBeVisible();
await expect(page.locator('.test-file-test-outcome-flaky >> text=flaky')).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();
});
test('should not throw when attachment is missing', async ({ runInlineTest, page, showReport }, testInfo) => {
@ -88,7 +88,7 @@ test('should not throw when attachment is missing', async ({ runInlineTest, page
await page.click('text=passes');
await page.locator('text=Missing attachment "screenshot"').click();
const screenshotFile = testInfo.outputPath('test-results' , 'a-passes', 'screenshot.png');
await expect(page.locator('.attachment-body')).toHaveText(`Attachment file ${screenshotFile} is missing`);
await expect(page.locator('.attachment-link')).toHaveText(`Attachment file ${screenshotFile} is missing`);
});
test('should include image diff', async ({ runInlineTest, page, showReport }) => {
@ -114,7 +114,7 @@ test('should include image diff', async ({ runInlineTest, page, showReport }) =>
await showReport();
await page.click('text=fails');
const imageDiff = page.locator('.test-image-mismatch');
const imageDiff = page.locator('data-testid=test-result-image-mismatch');
const image = imageDiff.locator('img');
await expect(image).toHaveAttribute('src', /.*png/);
const actualSrc = await image.getAttribute('src');
@ -173,9 +173,9 @@ test('should include stdio', async ({ runInlineTest, page, showReport }) => {
await showReport();
await page.click('text=fails');
await page.locator('text=stdout').click();
await expect(page.locator('.attachment-body')).toHaveText('First line\nSecond line');
await expect(page.locator('.attachment-link')).toHaveText('First line\nSecond line');
await page.locator('text=stderr').click();
await expect(page.locator('.attachment-body').nth(1)).toHaveText('Third line');
await expect(page.locator('.attachment-link').nth(1)).toHaveText('Third line');
});
test('should highlight error', async ({ runInlineTest, page, showReport }) => {
@ -192,7 +192,7 @@ test('should highlight error', async ({ runInlineTest, page, showReport }) => {
await showReport();
await page.click('text=fails');
await expect(page.locator('.error-message span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
await expect(page.locator('.test-result-error-message span:has-text("received")').nth(1)).toHaveCSS('color', 'rgb(204, 0, 0)');
});
test('should show trace source', async ({ runInlineTest, page, showReport }) => {
@ -333,5 +333,5 @@ test('should render text attachments as text', async ({ runInlineTest, page, sho
await page.click('text=example.txt');
await page.click('text=example.json');
await page.click('text=example-utf16.txt');
await expect(page.locator('.attachment-body')).toHaveText(['foo', '{"foo":1}', 'utf16 encoded']);
await expect(page.locator('.attachment-link')).toHaveText(['foo', '{"foo":1}', 'utf16 encoded']);
});