chore: more html reporter components (#10896)

This commit is contained in:
Pavel Feldman 2021-12-13 15:37:01 -08:00 committed by GitHub
parent 486ca66fd0
commit f166c67707
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 356 additions and 251 deletions

View File

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

View File

@ -15,7 +15,6 @@
*/
import type { TestCaseSummary } from '@playwright/test/src/reporters/html';
import './htmlReport.css';
export class Filter {
project: string[] = [];

View File

@ -16,11 +16,45 @@
import type { Stats } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import './htmlReport.css';
import { Link } from './links';
import './common.css';
import * as icons from './icons';
import { Link, navigate } from './links';
import { statusIcon } from './statusIcon';
export const StatsNavView: React.FC<{
export const HeaderView: React.FC<{
stats: Stats,
filterText: string,
setFilterText: (filterText: string) => void,
}> = ({ stats, filterText, setFilterText }) => {
React.useEffect(() => {
(async () => {
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.hash.slice(1));
setFilterText(params.get('q') || '');
});
})();
});
return <div className='pt-3'>
<div className='status-container ml-2 pl-2 d-flex'>
<StatsNavView stats={stats}></StatsNavView>
</div>
<form className='subnav-search' onSubmit={
event => {
event.preventDefault();
navigate(`#?q=${filterText ? encodeURIComponent(filterText) : ''}`);
}
}>
{icons.search()}
{/* 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>;
};
const StatsNavView: React.FC<{
stats: Stats
}> = ({ stats }) => {
return <nav className='d-flex no-wrap'>

View File

@ -1,192 +0,0 @@
/*
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, 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;
declare global {
interface Window {
playwrightReportBase64?: string;
entries: Map<string, zip.Entry>;
}
}
export const Report: React.FC = () => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [report, setReport] = React.useState<HTMLReport | undefined>();
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
React.useEffect(() => {
if (report)
return;
(async () => {
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(window.playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
window.entries = new Map<string, zip.Entry>();
for (const entry of await zipReader.getEntries())
window.entries.set(entry.filename, entry);
setReport(await readJsonEntry('report.json') as HTMLReport);
window.addEventListener('popstate', () => {
const params = new URLSearchParams(window.location.hash.slice(1));
setFilterText(params.get('q') || '');
});
})();
}, [report]);
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
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>
<Route params='q'>
<AllTestFilesSummaryView report={report} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} filterText={filterText} setFilterText={setFilterText}></AllTestFilesSummaryView>
</Route>
<Route params='testId'>
{!!report && <TestCaseViewWrapper report={report}></TestCaseViewWrapper>}
</Route>
</>}
</div>;
};
const AllTestFilesSummaryView: React.FC<{
report?: HTMLReport,
expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void,
filter: Filter,
filterText: string,
setFilterText: (filter: string) => void,
}> = ({ report, filter, expandedFiles, setExpandedFiles, filterText, setFilterText }) => {
const filteredFiles = React.useMemo(() => {
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
let visibleTests = 0;
for (const file of report?.files || []) {
const tests = file.tests.filter(t => filter.matches(t));
visibleTests += tests.length;
if (tests.length)
result.push({ file, defaultExpanded: visibleTests < 200 });
}
return result;
}, [report, filter]);
return <>
{report && filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileView
key={`file-${file.fileId}`}
report={report}
file={file}
isFileExpanded={fileId => {
const value = expandedFiles.get(fileId);
if (value === undefined)
return defaultExpanded;
return !!value;
}}
setFileExpanded={(fileId, expanded) => {
const newExpanded = new Map(expandedFiles);
newExpanded.set(fileId, expanded);
setExpandedFiles(newExpanded);
}}
filter={filter}>
</TestFileView>;
})}
</>;
};
const TestCaseViewWrapper: React.FC<{
report: HTMLReport,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [test, setTest] = React.useState<TestCase | undefined>();
const testId = searchParams.get('testId');
React.useEffect(() => {
(async () => {
if (!testId || testId === test?.testId)
return;
const fileId = testId.split('-')[0];
if (!fileId)
return;
const file = await readJsonEntry(`${fileId}.json`) as TestFile;
for (const t of file.tests) {
if (t.testId === testId) {
setTest(t);
break;
}
}
})();
}, [test, report, testId]);
return <TestCaseView report={report} test={test}></TestCaseView>;
};
function navigate(href: string) {
window.history.pushState({}, '', href);
const navEvent = new PopStateEvent('popstate');
window.dispatchEvent(navEvent);
}
const Route: React.FunctionComponent<{
params: string,
children: any
}> = ({ params, children }) => {
const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
const [currentParams, setCurrentParam] = React.useState(initialParams);
React.useEffect(() => {
const listener = () => {
const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
setCurrentParam(newParams);
};
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, []);
return currentParams === params ? children : null;
};
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());
}

View File

@ -0,0 +1,71 @@
/*
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 './colors.css';
import './common.css';
export const search = () => {
return <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>;
};
export const 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 const 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>;
};
export const warning = () => {
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>;
};
export const attachment = () => {
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='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>;
};
export const cross = () => {
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>;
};
export const check = () => {
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>;
};
export const clock = () => {
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>;
};
export const blank = () => {
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
};

View File

@ -14,11 +14,50 @@
* limitations under the License.
*/
import type { HTMLReport } from '@playwright/test/src/reporters/html';
import type zip from '@zip.js/zip.js';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Report } from './htmlReport';
import './colors.css';
import { LoadedReport } from './loadedReport';
import { ReportView } from './reportView';
const zipjs = (self as any).zip;
const ReportLoader: React.FC = () => {
const [report, setReport] = React.useState<LoadedReport | undefined>();
React.useEffect(() => {
if (report)
return;
const zipReport = new ZipReport();
zipReport.load().then(() => setReport(zipReport));
}, [report]);
return <ReportView report={report}></ReportView>;
};
window.onload = () => {
ReactDOM.render(<Report />, document.querySelector('#root'));
ReactDOM.render(<ReportLoader />, document.querySelector('#root'));
};
class ZipReport implements LoadedReport {
private _entries = new Map<string, zip.Entry>();
private _json!: HTMLReport;
async load() {
const zipReader = new zipjs.ZipReader(new zipjs.Data64URIReader(window.playwrightReportBase64), { useWebWorkers: false }) as zip.ZipReader;
for (const entry of await zipReader.getEntries())
this._entries.set(entry.filename, entry);
this._json = await this.entry('report.json') as HTMLReport;
}
json(): HTMLReport {
return this._json;
}
async entry(name: string): Promise<Object> {
const reportEntry = this._entries.get(name);
const writer = new zipjs.TextWriter() as zip.TextWriter;
await reportEntry!.getData!(writer);
return JSON.parse(await writer.getData());
}
}

View File

@ -16,9 +16,33 @@
import type { HTMLReport, TestAttachment } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import * as icons from './icons';
import { TreeItem } from './treeItem';
import './links.css';
export function navigate(href: string) {
window.history.pushState({}, '', href);
const navEvent = new PopStateEvent('popstate');
window.dispatchEvent(navEvent);
}
export const Route: React.FunctionComponent<{
params: string,
children: any
}> = ({ params, children }) => {
const initialParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
const [currentParams, setCurrentParam] = React.useState(initialParams);
React.useEffect(() => {
const listener = () => {
const newParams = [...new URLSearchParams(window.location.hash.slice(1)).keys()].join('&');
setCurrentParam(newParams);
};
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, []);
return currentParams === params ? children : null;
};
export const Link: React.FunctionComponent<{
href: string,
className?: string,
@ -46,14 +70,7 @@ export const AttachmentLink: React.FunctionComponent<{
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.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
{attachment.path && <a href={href || attachment.path} target='_blank'>{attachment.name}</a>}
{attachment.body && <span>{attachment.name}</span>}
</span>} loadChildren={attachment.body ? () => {

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { HTMLReport } from '@playwright/test/src/reporters/html';
export interface LoadedReport {
json(): HTMLReport;
entry(name: string): Promise<Object | undefined>;
}

View File

@ -57,7 +57,7 @@ body {
}
@media only screen and (max-width: 600px) {
.htmlreport {
.report {
padding: 0 !important;
}

View File

@ -0,0 +1,83 @@
/*
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 { TestCase, TestFile } from '@playwright/test/src/reporters/html';
import * as React from 'react';
import './colors.css';
import './common.css';
import { Filter } from './filter';
import { HeaderView } from './headerView';
import { Route } from './links';
import { LoadedReport } from './loadedReport';
import './reportView.css';
import { TestCaseView } from './testCaseView';
import { TestFilesView } from './testFilesView';
declare global {
interface Window {
playwrightReportBase64?: string;
}
}
export const ReportView: React.FC<{
report: LoadedReport | undefined,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
return <div className='htmlreport vbox px-4'>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
{<>
<Route params=''>
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
</Route>
<Route params='q'>
<TestFilesView report={report?.json()} filter={filter} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles}></TestFilesView>
</Route>
<Route params='testId'>
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
</Route>
</>}
</div>;
};
const TestCaseViewLoader: React.FC<{
report: LoadedReport,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const [test, setTest] = React.useState<TestCase | undefined>();
const testId = searchParams.get('testId');
React.useEffect(() => {
(async () => {
if (!testId || testId === test?.testId)
return;
const fileId = testId.split('-')[0];
if (!fileId)
return;
const file = await report.entry(`${fileId}.json`) as TestFile;
for (const t of file.tests) {
if (t.testId === testId) {
setTest(t);
break;
}
}
})();
}, [test, report, testId]);
return <TestCaseView report={report.json()} test={test}></TestCaseView>;
};

View File

@ -14,30 +14,23 @@
limitations under the License.
*/
import * as React from 'react';
import './htmlReport.css';
import * as icons from './icons';
import './colors.css';
import './common.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>;
return icons.cross();
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>;
return icons.check();
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>;
return icons.clock();
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>;
return icons.warning();
case 'skipped':
return <svg className='octicon' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'></svg>;
return icons.blank();
}
}

View File

@ -0,0 +1,61 @@
/*
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 { Filter } from './filter';
import { TestFileView } from './testFileView';
import './testFileView.css';
export const TestFilesView: React.FC<{
report?: HTMLReport,
expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void,
filter: Filter,
}> = ({ report, filter, expandedFiles, setExpandedFiles }) => {
const filteredFiles = React.useMemo(() => {
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
let visibleTests = 0;
for (const file of report?.files || []) {
const tests = file.tests.filter(t => filter.matches(t));
visibleTests += tests.length;
if (tests.length)
result.push({ file, defaultExpanded: visibleTests < 200 });
}
return result;
}, [report, filter]);
return <>
{report && filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileView
key={`file-${file.fileId}`}
report={report}
file={file}
isFileExpanded={fileId => {
const value = expandedFiles.get(fileId);
if (value === undefined)
return defaultExpanded;
return !!value;
}}
setFileExpanded={(fileId, expanded) => {
const newExpanded = new Map(expandedFiles);
newExpanded.set(fileId, expanded);
setExpandedFiles(newExpanded);
}}
filter={filter}>
</TestFileView>;
})}
</>;
};

View File

@ -16,6 +16,7 @@
import * as React from 'react';
import './treeItem.css';
import * as icons from './icons';
export const TreeItem: React.FunctionComponent<{
title: JSX.Element,
@ -29,23 +30,11 @@ export const TreeItem: React.FunctionComponent<{
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
return <div className={'tree-item'}>
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
{loadChildren && !!expanded && downArrow()}
{loadChildren && !expanded && rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{rightArrow()}</span>}
{loadChildren && !!expanded && icons.downArrow()}
{loadChildren && !expanded && icons.rightArrow()}
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
{title}
</span>
{expanded && loadChildren?.()}
</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>;
}