mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: more html reporter components (#10896)
This commit is contained in:
parent
486ca66fd0
commit
f166c67707
@ -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>;
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@
|
||||
*/
|
||||
|
||||
import type { TestCaseSummary } from '@playwright/test/src/reporters/html';
|
||||
import './htmlReport.css';
|
||||
|
||||
export class Filter {
|
||||
project: string[] = [];
|
||||
|
||||
@ -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'>
|
||||
@ -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());
|
||||
}
|
||||
71
packages/playwright-core/src/web/htmlReport/icons.tsx
Normal file
71
packages/playwright-core/src/web/htmlReport/icons.tsx
Normal 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>;
|
||||
};
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ? () => {
|
||||
|
||||
22
packages/playwright-core/src/web/htmlReport/loadedReport.ts
Normal file
22
packages/playwright-core/src/web/htmlReport/loadedReport.ts
Normal 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>;
|
||||
}
|
||||
@ -57,7 +57,7 @@ body {
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.htmlreport {
|
||||
.report {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
83
packages/playwright-core/src/web/htmlReport/reportView.tsx
Normal file
83
packages/playwright-core/src/web/htmlReport/reportView.tsx
Normal 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>;
|
||||
};
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
})}
|
||||
</>;
|
||||
};
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user