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 * as React from 'react';
|
||||||
import './chip.css';
|
import './chip.css';
|
||||||
|
import * as icons from './icons';
|
||||||
|
|
||||||
export const Chip: React.FunctionComponent<{
|
export const Chip: React.FunctionComponent<{
|
||||||
header: JSX.Element | string,
|
header: JSX.Element | string,
|
||||||
@ -26,22 +27,10 @@ export const Chip: React.FunctionComponent<{
|
|||||||
}> = ({ header, expanded, setExpanded, children, noInsets }) => {
|
}> = ({ header, expanded, setExpanded, children, noInsets }) => {
|
||||||
return <div className='chip'>
|
return <div className='chip'>
|
||||||
<div className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} onClick={() => setExpanded?.(!expanded)}>
|
<div className={'chip-header' + (setExpanded ? ' expanded-' + expanded : '')} onClick={() => setExpanded?.(!expanded)}>
|
||||||
{setExpanded && !!expanded && downArrow()}
|
{setExpanded && !!expanded && icons.downArrow()}
|
||||||
{setExpanded && !expanded && rightArrow()}
|
{setExpanded && !expanded && icons.rightArrow()}
|
||||||
{header}
|
{header}
|
||||||
</div>
|
</div>
|
||||||
{(!setExpanded || expanded) && <div className={'chip-body' + (noInsets ? ' chip-body-no-insets' : '')}>{children}</div>}
|
{(!setExpanded || expanded) && <div className={'chip-body' + (noInsets ? ' chip-body-no-insets' : '')}>{children}</div>}
|
||||||
</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 type { TestCaseSummary } from '@playwright/test/src/reporters/html';
|
||||||
import './htmlReport.css';
|
|
||||||
|
|
||||||
export class Filter {
|
export class Filter {
|
||||||
project: string[] = [];
|
project: string[] = [];
|
||||||
|
|||||||
@ -16,11 +16,45 @@
|
|||||||
|
|
||||||
import type { Stats } from '@playwright/test/src/reporters/html';
|
import type { Stats } from '@playwright/test/src/reporters/html';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './htmlReport.css';
|
import './common.css';
|
||||||
import { Link } from './links';
|
import * as icons from './icons';
|
||||||
|
import { Link, navigate } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
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: Stats
|
||||||
}> = ({ stats }) => {
|
}> = ({ stats }) => {
|
||||||
return <nav className='d-flex no-wrap'>
|
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.
|
* 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 React from 'react';
|
||||||
import * as ReactDOM from 'react-dom';
|
import * as ReactDOM from 'react-dom';
|
||||||
import { Report } from './htmlReport';
|
|
||||||
import './colors.css';
|
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 = () => {
|
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 type { HTMLReport, TestAttachment } from '@playwright/test/src/reporters/html';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import * as icons from './icons';
|
||||||
import { TreeItem } from './treeItem';
|
import { TreeItem } from './treeItem';
|
||||||
import './links.css';
|
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<{
|
export const Link: React.FunctionComponent<{
|
||||||
href: string,
|
href: string,
|
||||||
className?: string,
|
className?: string,
|
||||||
@ -46,14 +70,7 @@ export const AttachmentLink: React.FunctionComponent<{
|
|||||||
href?: string,
|
href?: string,
|
||||||
}> = ({ attachment, href }) => {
|
}> = ({ attachment, href }) => {
|
||||||
return <TreeItem title={<span>
|
return <TreeItem title={<span>
|
||||||
{attachment.contentType === kMissingContentType ?
|
{attachment.contentType === kMissingContentType ? icons.warning() : icons.attachment()}
|
||||||
<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.path && <a href={href || attachment.path} target='_blank'>{attachment.name}</a>}
|
||||||
{attachment.body && <span>{attachment.name}</span>}
|
{attachment.body && <span>{attachment.name}</span>}
|
||||||
</span>} loadChildren={attachment.body ? () => {
|
</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) {
|
@media only screen and (max-width: 600px) {
|
||||||
.htmlreport {
|
.report {
|
||||||
padding: 0 !important;
|
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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as icons from './icons';
|
||||||
import './htmlReport.css';
|
import './colors.css';
|
||||||
|
import './common.css';
|
||||||
|
|
||||||
export function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
|
export function statusIcon(status: 'failed' | 'timedOut' | 'skipped' | 'passed' | 'expected' | 'unexpected' | 'flaky'): JSX.Element {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'unexpected':
|
case 'unexpected':
|
||||||
return <svg className='octicon color-text-danger' viewBox='0 0 16 16' version='1.1' width='16' height='16' aria-hidden='true'>
|
return icons.cross();
|
||||||
<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 'passed':
|
||||||
case 'expected':
|
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'>
|
return icons.check();
|
||||||
<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':
|
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'>
|
return icons.clock();
|
||||||
<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':
|
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'>
|
return icons.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':
|
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 * as React from 'react';
|
||||||
import './treeItem.css';
|
import './treeItem.css';
|
||||||
|
import * as icons from './icons';
|
||||||
|
|
||||||
export const TreeItem: React.FunctionComponent<{
|
export const TreeItem: React.FunctionComponent<{
|
||||||
title: JSX.Element,
|
title: JSX.Element,
|
||||||
@ -29,23 +30,11 @@ export const TreeItem: React.FunctionComponent<{
|
|||||||
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
|
const className = selected ? 'tree-item-title selected' : 'tree-item-title';
|
||||||
return <div className={'tree-item'}>
|
return <div className={'tree-item'}>
|
||||||
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
<span className={className} style={{ whiteSpace: 'nowrap', paddingLeft: depth * 22 + 4 }} onClick={() => { onClick?.(); setExpanded(!expanded); }} >
|
||||||
{loadChildren && !!expanded && downArrow()}
|
{loadChildren && !!expanded && icons.downArrow()}
|
||||||
{loadChildren && !expanded && rightArrow()}
|
{loadChildren && !expanded && icons.rightArrow()}
|
||||||
{!loadChildren && <span style={{ visibility: 'hidden' }}>{rightArrow()}</span>}
|
{!loadChildren && <span style={{ visibility: 'hidden' }}>{icons.rightArrow()}</span>}
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
{expanded && loadChildren?.()}
|
{expanded && loadChildren?.()}
|
||||||
</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>;
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user