chore: split html report into files (#10876)
This commit is contained in:
parent
81ab6b3fde
commit
6521a6f3ab
70
packages/playwright-core/src/web/htmlReport/chip.css
Normal file
70
packages/playwright-core/src/web/htmlReport/chip.css
Normal 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;
|
||||
}
|
||||
}
|
||||
47
packages/playwright-core/src/web/htmlReport/chip.tsx
Normal file
47
packages/playwright-core/src/web/htmlReport/chip.tsx
Normal 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>;
|
||||
}
|
||||
266
packages/playwright-core/src/web/htmlReport/common.css
Normal file
266
packages/playwright-core/src/web/htmlReport/common.css
Normal 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;
|
||||
}
|
||||
}
|
||||
138
packages/playwright-core/src/web/htmlReport/filter.ts
Normal file
138
packages/playwright-core/src/web/htmlReport/filter.ts
Normal 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';
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[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';
|
||||
|
|
|
|||
104
packages/playwright-core/src/web/htmlReport/links.css
Normal file
104
packages/playwright-core/src/web/htmlReport/links.css
Normal 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;
|
||||
}
|
||||
64
packages/playwright-core/src/web/htmlReport/links.tsx
Normal file
64
packages/playwright-core/src/web/htmlReport/links.tsx
Normal 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';
|
||||
43
packages/playwright-core/src/web/htmlReport/statsNavView.tsx
Normal file
43
packages/playwright-core/src/web/htmlReport/statsNavView.tsx
Normal 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>;
|
||||
};
|
||||
43
packages/playwright-core/src/web/htmlReport/statusIcon.tsx
Normal file
43
packages/playwright-core/src/web/htmlReport/statusIcon.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
76
packages/playwright-core/src/web/htmlReport/tabbedPane.css
Normal file
76
packages/playwright-core/src/web/htmlReport/tabbedPane.css
Normal 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;
|
||||
}
|
||||
53
packages/playwright-core/src/web/htmlReport/tabbedPane.tsx
Normal file
53
packages/playwright-core/src/web/htmlReport/tabbedPane.tsx
Normal 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>;
|
||||
};
|
||||
69
packages/playwright-core/src/web/htmlReport/testCaseView.css
Normal file
69
packages/playwright-core/src/web/htmlReport/testCaseView.css
Normal 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;
|
||||
}
|
||||
}
|
||||
57
packages/playwright-core/src/web/htmlReport/testCaseView.tsx
Normal file
57
packages/playwright-core/src/web/htmlReport/testCaseView.tsx
Normal 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}`;
|
||||
}
|
||||
38
packages/playwright-core/src/web/htmlReport/testFileView.css
Normal file
38
packages/playwright-core/src/web/htmlReport/testFileView.css
Normal 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);
|
||||
}
|
||||
54
packages/playwright-core/src/web/htmlReport/testFileView.tsx
Normal file
54
packages/playwright-core/src/web/htmlReport/testFileView.tsx
Normal 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>;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
182
packages/playwright-core/src/web/htmlReport/testResultView.tsx
Normal file
182
packages/playwright-core/src/web/htmlReport/testResultView.tsx
Normal 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 => ({ '&': '&', '"': '"', '<': '<', '>': '>' }[c]!));
|
||||
}
|
||||
30
packages/playwright-core/src/web/htmlReport/treeItem.css
Normal file
30
packages/playwright-core/src/web/htmlReport/treeItem.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue