feat(html-reporter): add navigation to test details
This commit is contained in:
parent
b535139b32
commit
724725bf68
|
|
@ -35,6 +35,12 @@ export const rightArrow = () => {
|
||||||
</svg>;
|
</svg>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const leftArrow = () => {
|
||||||
|
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' transform='rotate(180)'>
|
||||||
|
<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 = () => {
|
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'>
|
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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { MetadataView } from './metadataView';
|
||||||
import { TestCaseView } from './testCaseView';
|
import { TestCaseView } from './testCaseView';
|
||||||
import { TestFilesView } from './testFilesView';
|
import { TestFilesView } from './testFilesView';
|
||||||
import './theme.css';
|
import './theme.css';
|
||||||
|
import { useSearchParams } from './use-search-params';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|
@ -42,29 +43,49 @@ const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId')
|
||||||
export const ReportView: React.FC<{
|
export const ReportView: React.FC<{
|
||||||
report: LoadedReport | undefined,
|
report: LoadedReport | undefined,
|
||||||
}> = ({ report }) => {
|
}> = ({ report }) => {
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
const searchParams = useSearchParams();
|
||||||
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
|
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
|
||||||
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
|
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
|
||||||
|
|
||||||
|
const reportData = report?.json();
|
||||||
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
|
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
|
||||||
const filteredStats = React.useMemo(() => computeStats(report?.json().files || [], filter), [report, filter]);
|
|
||||||
|
const filteredFiles = React.useMemo(() => {
|
||||||
|
const files = reportData?.files ?? [];
|
||||||
|
|
||||||
|
return files
|
||||||
|
.map(file => {
|
||||||
|
const tests = file.tests.filter(test => filter.matches(test));
|
||||||
|
return { ...file, tests };
|
||||||
|
})
|
||||||
|
.filter(file => file.tests.length !== 0);
|
||||||
|
}, [filter, reportData?.files]);
|
||||||
|
|
||||||
|
const filteredStats = React.useMemo(() => computeStats(filteredFiles), [filteredFiles]);
|
||||||
|
|
||||||
return <div className='htmlreport vbox px-4 pb-4'>
|
return <div className='htmlreport vbox px-4 pb-4'>
|
||||||
<main>
|
<main>
|
||||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
{reportData && <HeaderView stats={reportData.stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||||
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
{reportData?.metadata && <MetadataView {...reportData.metadata as Metainfo} />}
|
||||||
<Route predicate={testFilesRoutePredicate}>
|
<Route predicate={testFilesRoutePredicate}>
|
||||||
<TestFilesView
|
<TestFilesView
|
||||||
report={report?.json()}
|
report={reportData}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
expandedFiles={expandedFiles}
|
expandedFiles={expandedFiles}
|
||||||
setExpandedFiles={setExpandedFiles}
|
setExpandedFiles={setExpandedFiles}
|
||||||
projectNames={report?.json().projectNames || []}
|
projectNames={reportData?.projectNames ?? []}
|
||||||
filteredStats={filteredStats}
|
filteredStats={filteredStats}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route predicate={testCaseRoutePredicate}>
|
<Route predicate={testCaseRoutePredicate}>
|
||||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
{!!report && (
|
||||||
|
<TestCaseViewLoader
|
||||||
|
report={report}
|
||||||
|
filteredFiles={filteredFiles}
|
||||||
|
filter={filter}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
|
|
@ -72,21 +93,25 @@ export const ReportView: React.FC<{
|
||||||
|
|
||||||
const TestCaseViewLoader: React.FC<{
|
const TestCaseViewLoader: React.FC<{
|
||||||
report: LoadedReport,
|
report: LoadedReport,
|
||||||
}> = ({ report }) => {
|
filteredFiles: TestFileSummary[],
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
filter: Filter
|
||||||
|
}> = ({ report, filteredFiles, filter }) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [test, setTest] = React.useState<TestCase | undefined>();
|
const [test, setTest] = React.useState<TestCase | undefined>();
|
||||||
const testId = searchParams.get('testId');
|
const testId = searchParams.get('testId') ?? '';
|
||||||
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
|
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
|
||||||
const run = +(searchParams.get('run') || '0');
|
const run = +(searchParams.get('run') || '0');
|
||||||
|
|
||||||
const testIdToFileIdMap = React.useMemo(() => {
|
const testIdToFileIdMap = React.useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const file of report.json().files) {
|
for (const file of filteredFiles) {
|
||||||
for (const test of file.tests)
|
for (const test of file.tests)
|
||||||
map.set(test.testId, file.fileId);
|
map.set(test.testId, file.fileId);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [report]);
|
}, [filteredFiles]);
|
||||||
|
|
||||||
|
const { prevTestId, nextTestId } = getAdjacentTestIds(testIdToFileIdMap, testId);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
|
@ -104,19 +129,43 @@ const TestCaseViewLoader: React.FC<{
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [test, report, testId, testIdToFileIdMap]);
|
}, [test, report, testId, testIdToFileIdMap]);
|
||||||
return <TestCaseView projectNames={report.json().projectNames} test={test} anchor={anchor} run={run}></TestCaseView>;
|
|
||||||
|
return (
|
||||||
|
<TestCaseView
|
||||||
|
projectNames={report.json().projectNames}
|
||||||
|
test={test}
|
||||||
|
anchor={anchor}
|
||||||
|
run={run}
|
||||||
|
prevTestId={prevTestId}
|
||||||
|
nextTestId={nextTestId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
|
|
||||||
|
function computeStats(filteredFiles: TestFileSummary[]): FilteredStats {
|
||||||
const stats: FilteredStats = {
|
const stats: FilteredStats = {
|
||||||
total: 0,
|
total: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
};
|
};
|
||||||
for (const file of files) {
|
for (const file of filteredFiles) {
|
||||||
const tests = file.tests.filter(t => filter.matches(t));
|
stats.total += file.tests.length;
|
||||||
stats.total += tests.length;
|
for (const test of file.tests)
|
||||||
for (const test of tests)
|
|
||||||
stats.duration += test.duration;
|
stats.duration += test.duration;
|
||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdjacentTestIds(testIdToFileIdMap: Map<string, string>, currentTestId: string) {
|
||||||
|
const testIds = [...testIdToFileIdMap.keys()];
|
||||||
|
const currentIndex = testIds.indexOf(currentTestId);
|
||||||
|
|
||||||
|
const lastIndex = testIds.length - 1;
|
||||||
|
const nextIndex = currentIndex === lastIndex ? 0 : currentIndex + 1;
|
||||||
|
const prevIndex = currentIndex === 0 ? lastIndex : currentIndex - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prevTestId: testIds[prevIndex],
|
||||||
|
nextTestId: testIds[nextIndex],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,26 @@
|
||||||
color: var(--color-fg-default);
|
color: var(--color-fg-default);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-case-title-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 32px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.test-case-title {
|
.test-case-title {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 8px;
|
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 32px !important;
|
font-size: 32px !important;
|
||||||
line-height: 1.25 !important;
|
line-height: 1.25 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.test-case-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.test-case-location,
|
.test-case-location,
|
||||||
.test-case-duration {
|
.test-case-duration {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|
@ -73,4 +85,4 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ const testCase: TestCase = {
|
||||||
};
|
};
|
||||||
|
|
||||||
test('should render test case', async ({ mount }) => {
|
test('should render test case', async ({ mount }) => {
|
||||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>);
|
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor='' prevTestId='' nextTestId=''></TestCaseView>);
|
||||||
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
|
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
|
||||||
await expect(component.getByText('Hidden annotation')).toBeHidden();
|
await expect(component.getByText('Hidden annotation')).toBeHidden();
|
||||||
await component.getByText('Annotations').click();
|
await component.getByText('Annotations').click();
|
||||||
|
|
@ -96,7 +96,7 @@ const annotationLinkRenderingTestCase: TestCase = {
|
||||||
};
|
};
|
||||||
|
|
||||||
test('should correctly render links in annotations', async ({ mount }) => {
|
test('should correctly render links in annotations', async ({ mount }) => {
|
||||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} run={0} anchor=''></TestCaseView>);
|
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={annotationLinkRenderingTestCase} run={0} anchor='' prevTestId='' nextTestId=''></TestCaseView>);
|
||||||
|
|
||||||
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
|
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
|
||||||
await expect(firstLink).toBeVisible();
|
await expect(firstLink).toBeVisible();
|
||||||
|
|
@ -154,7 +154,7 @@ const attachmentLinkRenderingTestCase: TestCase = {
|
||||||
};
|
};
|
||||||
|
|
||||||
test('should correctly render links in attachments', async ({ mount }) => {
|
test('should correctly render links in attachments', async ({ mount }) => {
|
||||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor=''></TestCaseView>);
|
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor='' prevTestId='' nextTestId=''></TestCaseView>);
|
||||||
await component.getByText('first attachment').click();
|
await component.getByText('first attachment').click();
|
||||||
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
|
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
|
||||||
await expect(body).toBeVisible();
|
await expect(body).toBeVisible();
|
||||||
|
|
@ -163,8 +163,24 @@ test('should correctly render links in attachments', async ({ mount }) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should correctly render links in attachment name', async ({ mount }) => {
|
test('should correctly render links in attachment name', async ({ mount }) => {
|
||||||
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor=''></TestCaseView>);
|
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor='' prevTestId='' nextTestId=''></TestCaseView>);
|
||||||
const link = component.getByText('attachment with inline link').locator('a');
|
const link = component.getByText('attachment with inline link').locator('a');
|
||||||
await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
|
await expect(link).toHaveAttribute('href', 'https://github.com/microsoft/playwright/issues/31284');
|
||||||
await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284');
|
await expect(link).toHaveText('https://github.com/microsoft/playwright/issues/31284');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should correctly render navigation links', async ({ mount, page }) => {
|
||||||
|
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} run={0} anchor='' prevTestId='prev-test-id' nextTestId='next-test-id'></TestCaseView>);
|
||||||
|
|
||||||
|
const prevLink = component.getByRole('link').and(page.getByTitle('Prev test case'));
|
||||||
|
const nextLink = component.getByRole('link').and(page.getByTitle('Next test case'));
|
||||||
|
expect(prevLink).toBeVisible();
|
||||||
|
expect(nextLink).toBeVisible();
|
||||||
|
|
||||||
|
await prevLink.click();
|
||||||
|
await expect(page).toHaveURL(/testId=prev-test-id/);
|
||||||
|
|
||||||
|
await nextLink.click();
|
||||||
|
await expect(page).toHaveURL(/testId=next-test-id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,26 @@ import * as React from 'react';
|
||||||
import { TabbedPane } from './tabbedPane';
|
import { TabbedPane } from './tabbedPane';
|
||||||
import { AutoChip } from './chip';
|
import { AutoChip } from './chip';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import { ProjectLink } from './links';
|
import { Link, ProjectLink } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testCaseView.css';
|
import './testCaseView.css';
|
||||||
import { TestResultView } from './testResultView';
|
import { TestResultView } from './testResultView';
|
||||||
import { linkifyText } from './renderUtils';
|
import { linkifyText } from './renderUtils';
|
||||||
import { hashStringToInt, msToString } from './utils';
|
import { hashStringToInt, msToString } from './utils';
|
||||||
|
import * as icons from './icons';
|
||||||
|
import { useSearchParams } from './use-search-params';
|
||||||
|
|
||||||
export const TestCaseView: React.FC<{
|
export const TestCaseView: React.FC<{
|
||||||
projectNames: string[],
|
projectNames: string[],
|
||||||
test: TestCase | undefined,
|
test: TestCase | undefined,
|
||||||
anchor: 'video' | 'diff' | '',
|
anchor: 'video' | 'diff' | '',
|
||||||
run: number,
|
run: number,
|
||||||
}> = ({ projectNames, test, run, anchor }) => {
|
prevTestId: string
|
||||||
|
nextTestId: string
|
||||||
|
}> = ({ projectNames, test, run, anchor, prevTestId, nextTestId }) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const q = searchParams.get('q') ?? '';
|
||||||
|
|
||||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
||||||
|
|
||||||
const labels = React.useMemo(() => {
|
const labels = React.useMemo(() => {
|
||||||
|
|
@ -46,7 +53,15 @@ export const TestCaseView: React.FC<{
|
||||||
|
|
||||||
return <div className='test-case-column vbox'>
|
return <div className='test-case-column vbox'>
|
||||||
{test && <div className='test-case-path'>{test.path.join(' › ')}</div>}
|
{test && <div className='test-case-path'>{test.path.join(' › ')}</div>}
|
||||||
{test && <div className='test-case-title'>{test?.title}</div>}
|
{test && (
|
||||||
|
<div className='test-case-title-wrapper'>
|
||||||
|
<div className='test-case-title'>{test?.title}</div>
|
||||||
|
<div className="test-case-navigation">
|
||||||
|
<Link href={`#?q=${q}&testId=${prevTestId}`} title="Prev test case">{icons.leftArrow()}</Link>
|
||||||
|
<Link href={`#?q=${q}&testId=${nextTestId}`} title="Next test case">{icons.rightArrow()}</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
<div className='test-case-location'>{test.location.file}:{test.location.line}</div>
|
<div className='test-case-location'>{test.location.file}:{test.location.line}</div>
|
||||||
<div style={{ flex: 'auto' }}></div>
|
<div style={{ flex: 'auto' }}></div>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testFileView.css';
|
import './testFileView.css';
|
||||||
import { video, image, trace } from './icons';
|
import { video, image, trace } from './icons';
|
||||||
|
import { useSearchParams } from './use-search-params';
|
||||||
|
|
||||||
export const TestFileView: React.FC<React.PropsWithChildren<{
|
export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
report: HTMLReport;
|
report: HTMLReport;
|
||||||
|
|
@ -31,6 +32,9 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
||||||
filter: Filter;
|
filter: Filter;
|
||||||
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
|
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const q = searchParams.get('q') ?? '';
|
||||||
|
|
||||||
return <Chip
|
return <Chip
|
||||||
expanded={isFileExpanded(file.fileId)}
|
expanded={isFileExpanded(file.fileId)}
|
||||||
noInsets={true}
|
noInsets={true}
|
||||||
|
|
@ -38,7 +42,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
header={<span>
|
header={<span>
|
||||||
{file.fileName}
|
{file.fileName}
|
||||||
</span>}>
|
</span>}>
|
||||||
{file.tests.filter(t => filter.matches(t)).map(test =>
|
{file.tests.map(test =>
|
||||||
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}>
|
<div key={`test-${test.testId}`} className={'test-file-test test-file-test-outcome-' + test.outcome}>
|
||||||
<div className='hbox' style={{ alignItems: 'flex-start' }}>
|
<div className='hbox' style={{ alignItems: 'flex-start' }}>
|
||||||
<div className="hbox">
|
<div className="hbox">
|
||||||
|
|
@ -46,7 +50,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
{statusIcon(test.outcome)}
|
{statusIcon(test.outcome)}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')}>
|
<Link href={`#?q=${q}&testId=${test.testId}`} title={[...test.path, test.title].join(' › ')}>
|
||||||
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
<span className='test-file-title'>{[...test.path, test.title].join(' › ')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{report.projectNames.length > 1 && !!test.projectName &&
|
{report.projectNames.length > 1 && !!test.projectName &&
|
||||||
|
|
@ -57,11 +61,11 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
<span data-testid='test-duration' style={{ minWidth: '50px', textAlign: 'right' }}>{msToString(test.duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='test-file-details-row'>
|
<div className='test-file-details-row'>
|
||||||
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
<Link href={`#?q=${q}&testId=${test.testId}`} title={[...test.path, test.title].join(' › ')} className='test-file-path-link'>
|
||||||
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
<span className='test-file-path'>{test.location.file}:{test.location.line}</span>
|
||||||
</Link>
|
</Link>
|
||||||
{imageDiffBadge(test)}
|
{imageDiffBadge(test, q)}
|
||||||
{videoBadge(test)}
|
{videoBadge(test, q)}
|
||||||
{traceBadge(test)}
|
{traceBadge(test)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,16 +73,16 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
</Chip>;
|
</Chip>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function imageDiffBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function imageDiffBadge(test: TestCaseSummary, q: string): JSX.Element | undefined {
|
||||||
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
|
const resultWithImageDiff = test.results.find(result => result.attachments.some(attachment => {
|
||||||
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
|
return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/);
|
||||||
}));
|
}));
|
||||||
return resultWithImageDiff ? <Link href={`#?testId=${test.testId}&anchor=diff&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined;
|
return resultWithImageDiff ? <Link href={`#?q=${q}&testId=${test.testId}&anchor=diff&run=${test.results.indexOf(resultWithImageDiff)}`} title='View images' className='test-file-badge'>{image()}</Link> : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function videoBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function videoBadge(test: TestCaseSummary, q: string): JSX.Element | undefined {
|
||||||
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
const resultWithVideo = test.results.find(result => result.attachments.some(attachment => attachment.name === 'video'));
|
||||||
return resultWithVideo ? <Link href={`#?testId=${test.testId}&anchor=video&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
return resultWithVideo ? <Link href={`#?q=${q}&testId=${test.testId}&anchor=video&run=${test.results.indexOf(resultWithVideo)}`} title='View video' className='test-file-badge'>{video()}</Link> : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
||||||
|
|
|
||||||
|
|
@ -30,18 +30,18 @@ export const TestFilesView: React.FC<{
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
filteredStats: FilteredStats,
|
filteredStats: FilteredStats,
|
||||||
projectNames: string[],
|
projectNames: string[],
|
||||||
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => {
|
filteredFiles: TestFileSummary[]
|
||||||
const filteredFiles = React.useMemo(() => {
|
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats, filteredFiles }) => {
|
||||||
|
const filesSummary = React.useMemo(() => {
|
||||||
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
|
const result: { file: TestFileSummary, defaultExpanded: boolean }[] = [];
|
||||||
let visibleTests = 0;
|
let visibleTests = 0;
|
||||||
for (const file of report?.files || []) {
|
for (const file of filteredFiles) {
|
||||||
const tests = file.tests.filter(t => filter.matches(t));
|
visibleTests += file.tests.length;
|
||||||
visibleTests += tests.length;
|
result.push({ file, defaultExpanded: visibleTests < 200 });
|
||||||
if (tests.length)
|
|
||||||
result.push({ file, defaultExpanded: visibleTests < 200 });
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [report, filter]);
|
}, [filteredFiles]);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
|
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
|
||||||
{projectNames.length === 1 && !!projectNames[0] && <div data-testid="project-name" style={{ color: 'var(--color-fg-subtle)' }}>Project: {projectNames[0]}</div>}
|
{projectNames.length === 1 && !!projectNames[0] && <div data-testid="project-name" style={{ color: 'var(--color-fg-subtle)' }}>Project: {projectNames[0]}</div>}
|
||||||
|
|
@ -53,7 +53,7 @@ export const TestFilesView: React.FC<{
|
||||||
{report && !!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
|
{report && !!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
|
||||||
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
|
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
|
||||||
</AutoChip>}
|
</AutoChip>}
|
||||||
{report && filteredFiles.map(({ file, defaultExpanded }) => {
|
{report && filesSummary.map(({ file, defaultExpanded }) => {
|
||||||
return <TestFileView
|
return <TestFileView
|
||||||
key={`file-${file.fileId}`}
|
key={`file-${file.fileId}`}
|
||||||
report={report}
|
report={report}
|
||||||
|
|
|
||||||
34
packages/html-reporter/src/use-search-params.ts
Normal file
34
packages/html-reporter/src/use-search-params.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* 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 React from 'react';
|
||||||
|
|
||||||
|
export function useSearchParams() {
|
||||||
|
const [searchParams, setSearchParams] = React.useState(getCurrentSearchParams());
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = () => setSearchParams(getCurrentSearchParams());
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handler);
|
||||||
|
return () => window.removeEventListener('popstate', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return searchParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentSearchParams() {
|
||||||
|
return new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
}
|
||||||
|
|
@ -2437,6 +2437,111 @@ for (const useIntermediateMergeReport of [false] as const) {
|
||||||
await testFilePathLink.click();
|
await testFilePathLink.click();
|
||||||
await expect(page.locator('.test-case-path')).toHaveText('Root describe');
|
await expect(page.locator('.test-case-path')).toHaveText('Root describe');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('test details navigation', () => {
|
||||||
|
test('should allow navigating between test cases', async ({ page, runInlineTest, showReport }) => {
|
||||||
|
await runInlineTest({
|
||||||
|
'a.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('first test', async ({}) => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
'b.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('second test', async ({}) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
'c.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('third test', async ({}) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
|
||||||
|
|
||||||
|
await showReport();
|
||||||
|
|
||||||
|
await page.getByText('first test').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('first test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('second test');
|
||||||
|
|
||||||
|
await page.getByTitle('Prev test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('first test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('third test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('first test');
|
||||||
|
|
||||||
|
await page.getByTitle('Prev test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('third test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters should be preserved when navigating between test cases', async ({ page, runInlineTest, showReport }) => {
|
||||||
|
await runInlineTest({
|
||||||
|
'a.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('@regression first failed test', async ({}) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@regression first passed test', async ({}) => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
'b.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('@smoke second failed test', async ({}) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('@smoke second passed test', async ({}) => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
'c.test.js': `
|
||||||
|
const { expect, test } = require('@playwright/test');
|
||||||
|
test('third failed test', async ({}) => {
|
||||||
|
expect(1).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('third passed test', async ({}) => {
|
||||||
|
expect(1).toBe(1);
|
||||||
|
});
|
||||||
|
`
|
||||||
|
}, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' });
|
||||||
|
|
||||||
|
await showReport();
|
||||||
|
|
||||||
|
await page.locator('.subnav-item:has-text("Failed")').click();
|
||||||
|
await page.getByText('@regression first failed test').click();
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('@smoke second failed test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('third failed test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('@regression first failed test');
|
||||||
|
|
||||||
|
await page.locator('.subnav-item:has-text("All")').click();
|
||||||
|
await page.locator('.label:has-text("smoke")').first().click();
|
||||||
|
await page.getByText('@smoke second failed test').click();
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('@smoke second passed test');
|
||||||
|
|
||||||
|
await page.getByTitle('Next test case').click();
|
||||||
|
await expect(page.locator('.test-case-title')).toHaveText('@smoke second failed test');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue