feat(html): render prev/next test buttons (#33356)

This commit is contained in:
Pavel Feldman 2024-10-29 18:29:07 -07:00 committed by GitHub
parent 9ce401d44a
commit 6f5c7b4358
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 158 additions and 77 deletions

View File

@ -20,7 +20,7 @@ import './colors.css';
import './common.css';
import './headerView.css';
import * as icons from './icons';
import { Link, navigate } from './links';
import { Link, navigate, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon';
import { filterWithToken } from './filter';
@ -65,7 +65,7 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
const StatsNavView: React.FC<{
stats: Stats
}> = ({ stats }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const searchParams = React.useContext(SearchParamsContext);
const q = searchParams.get('q')?.toString() || '';
const tokens = q.split(' ');
return <nav>

View File

@ -27,6 +27,7 @@ import { ReportView } from './reportView';
const zipjs = zipImport as typeof zip;
import logo from '@web/assets/playwright-logo.svg';
import { SearchParamsProvider } from './links';
const link = document.createElement('link');
link.rel = 'shortcut icon';
link.href = logo;
@ -40,7 +41,9 @@ const ReportLoader: React.FC = () => {
const zipReport = new ZipReport();
zipReport.load().then(() => setReport(zipReport));
}, [report]);
return <ReportView report={report}></ReportView>;
return <SearchParamsProvider>
<ReportView report={report} />
</SearchParamsProvider>;
};
window.onload = () => {

View File

@ -33,13 +33,8 @@ export const Route: React.FunctionComponent<{
predicate: (params: URLSearchParams) => boolean,
children: any
}> = ({ predicate, children }) => {
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1))));
React.useEffect(() => {
const listener = () => setMatches(predicate(new URLSearchParams(window.location.hash.slice(1))));
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, [predicate]);
return matches ? children : null;
const searchParams = React.useContext(SearchParamsContext);
return predicate(searchParams) ? children : null;
};
export const Link: React.FunctionComponent<{
@ -90,6 +85,20 @@ export const AttachmentLink: React.FunctionComponent<{
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
};
export const SearchParamsContext = React.createContext<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
export const SearchParamsProvider: React.FunctionComponent<React.PropsWithChildren> = ({ children }) => {
const [searchParams, setSearchParams] = React.useState<URLSearchParams>(new URLSearchParams(window.location.hash.slice(1)));
React.useEffect(() => {
const listener = () => setSearchParams(new URLSearchParams(window.location.hash.slice(1)));
window.addEventListener('popstate', listener);
return () => window.removeEventListener('popstate', listener);
}, []);
return <SearchParamsContext.Provider value={searchParams}>{children}</SearchParamsContext.Provider>;
};
function downloadFileNameForAttachment(attachment: TestAttachment): string {
if (attachment.name.includes('.') || !attachment.path)
return attachment.name;

View File

@ -14,19 +14,19 @@
limitations under the License.
*/
import type { FilteredStats, TestCase, TestFile, TestFileSummary } from './types';
import type { FilteredStats, TestCase, TestCaseSummary, TestFile, TestFileSummary } from './types';
import * as React from 'react';
import './colors.css';
import './common.css';
import { Filter } from './filter';
import { HeaderView } from './headerView';
import { Route } from './links';
import { Route, SearchParamsContext } from './links';
import type { LoadedReport } from './loadedReport';
import './reportView.css';
import type { Metainfo } from './metadataView';
import { MetadataView } from './metadataView';
import { TestCaseView } from './testCaseView';
import { TestFilesView } from './testFilesView';
import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css';
declare global {
@ -39,32 +39,55 @@ declare global {
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
type TestModelSummary = {
files: TestFileSummary[];
tests: TestCaseSummary[];
};
export const ReportView: React.FC<{
report: LoadedReport | undefined,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const searchParams = React.useContext(SearchParamsContext);
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
const [filterText, setFilterText] = React.useState(searchParams.get('q') || '');
const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>();
for (const file of report?.json().files || []) {
for (const test of file.tests)
map.set(test.testId, file.fileId);
}
return map;
}, [report]);
const filter = React.useMemo(() => Filter.parse(filterText), [filterText]);
const filteredStats = React.useMemo(() => computeStats(report?.json().files || [], filter), [report, filter]);
const filteredStats = React.useMemo(() => filter.empty() ? undefined : computeStats(report?.json().files || [], filter), [report, filter]);
const filteredTests = React.useMemo(() => {
const result: TestModelSummary = { files: [], tests: [] };
for (const file of report?.json().files || []) {
const tests = file.tests.filter(t => filter.matches(t));
if (tests.length)
result.files.push({ ...file, tests });
result.tests.push(...tests);
}
return result;
}, [report, filter]);
return <div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
<Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
<TestFilesView
report={report?.json()}
filter={filter}
tests={filteredTests.files}
expandedFiles={expandedFiles}
setExpandedFiles={setExpandedFiles}
projectNames={report?.json().projectNames || []}
filteredStats={filteredStats}
/>
</Route>
<Route predicate={testCaseRoutePredicate}>
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route>
</main>
</div>;
@ -72,21 +95,21 @@ export const ReportView: React.FC<{
const TestCaseViewLoader: React.FC<{
report: LoadedReport,
}> = ({ report }) => {
const searchParams = new URLSearchParams(window.location.hash.slice(1));
tests: TestCaseSummary[],
testIdToFileIdMap: Map<string, string>,
}> = ({ report, testIdToFileIdMap, tests }) => {
const searchParams = React.useContext(SearchParamsContext);
const [test, setTest] = React.useState<TestCase | undefined>();
const testId = searchParams.get('testId');
const anchor = (searchParams.get('anchor') || '') as 'video' | 'diff' | '';
const run = +(searchParams.get('run') || '0');
const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>();
for (const file of report.json().files) {
for (const test of file.tests)
map.set(test.testId, file.fileId);
}
return map;
}, [report]);
const { prev, next } = React.useMemo(() => {
const index = tests.findIndex(t => t.testId === testId);
const prev = index > 0 ? tests[index - 1] : undefined;
const next = index < tests.length - 1 ? tests[index + 1] : undefined;
return { prev, next };
}, [testId, tests]);
React.useEffect(() => {
(async () => {
@ -104,7 +127,15 @@ const TestCaseViewLoader: React.FC<{
}
})();
}, [test, report, testId, testIdToFileIdMap]);
return <TestCaseView projectNames={report.json().projectNames} test={test} anchor={anchor} run={run}></TestCaseView>;
return <TestCaseView
projectNames={report.json().projectNames}
next={next}
prev={prev}
test={test}
anchor={anchor}
run={run}
/>;
};
function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
@ -119,4 +150,4 @@ function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
stats.duration += test.duration;
}
return stats;
}
}

View File

@ -16,7 +16,7 @@
.test-case-column {
border-radius: 6px;
margin: 24px 0;
margin: 12px 0 24px 0;
}
.test-case-column .tab-element.selected {

View File

@ -16,7 +16,7 @@
import { test, expect } from '@playwright/experimental-ct-react';
import { TestCaseView } from './testCaseView';
import type { TestCase, TestResult } from './types';
import type { TestCase, TestCaseSummary, TestResult } from './types';
test.use({ viewport: { width: 800, height: 600 } });
@ -63,7 +63,7 @@ const testCase: TestCase = {
};
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} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await expect(component.getByText('Hidden annotation')).toBeHidden();
await component.getByText('Annotations').click();
@ -79,7 +79,7 @@ test('should render test case', async ({ mount }) => {
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} run={0} anchor=''></TestCaseView>);
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={testCase} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
await component.getByText('Annotation text', { exact: false }).first().hover();
await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible();
@ -108,7 +108,7 @@ const annotationLinkRenderingTestCase: TestCase = {
};
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} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
await expect(firstLink).toBeVisible();
@ -165,8 +165,23 @@ const attachmentLinkRenderingTestCase: TestCase = {
results: [resultWithAttachment]
};
const testCaseSummary: TestCaseSummary = {
testId: 'nextTestId',
title: 'next test',
path: [],
projectName: 'chromium',
location: { file: 'test.spec.ts', line: 42, column: 0 },
tags: [],
outcome: 'expected',
duration: 10,
ok: true,
annotations: [],
results: [resultWithAttachment]
};
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} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
await component.getByText('first attachment').click();
const body = await component.getByText('The body with https://playwright.dev/docs/intro link');
await expect(body).toBeVisible();
@ -175,8 +190,17 @@ test('should correctly render links in attachments', 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} prev={undefined} next={undefined} run={0} anchor=''></TestCaseView>);
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).toHaveText('https://github.com/microsoft/playwright/issues/31284');
});
test('should correctly render prev and next', async ({ mount }) => {
const component = await mount(<TestCaseView projectNames={['chromium', 'webkit']} test={attachmentLinkRenderingTestCase} prev={testCaseSummary} next={testCaseSummary} run={0} anchor=''></TestCaseView>);
await expect(component).toMatchAriaSnapshot(`
- link "« previous"
- link "next »"
- text: "My test test.spec.ts:42 10ms"
`);
});

View File

@ -14,12 +14,12 @@
limitations under the License.
*/
import type { TestCase, TestCaseAnnotation } from './types';
import type { TestCase, TestCaseAnnotation, TestCaseSummary } from './types';
import * as React from 'react';
import { TabbedPane } from './tabbedPane';
import { AutoChip } from './chip';
import './common.css';
import { ProjectLink } from './links';
import { Link, ProjectLink, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon';
import './testCaseView.css';
import { TestResultView } from './testResultView';
@ -31,10 +31,14 @@ import { CopyToClipboardContainer } from './copyToClipboard';
export const TestCaseView: React.FC<{
projectNames: string[],
test: TestCase | undefined,
next: TestCaseSummary | undefined,
prev: TestCaseSummary | undefined,
anchor: 'video' | 'diff' | '',
run: number,
}> = ({ projectNames, test, run, anchor }) => {
}> = ({ projectNames, test, run, anchor, next, prev }) => {
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
const searchParams = React.useContext(SearchParamsContext);
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
const labels = React.useMemo(() => {
if (!test)
@ -47,6 +51,11 @@ export const TestCaseView: React.FC<{
}, [test?.annotations]);
return <div className='test-case-column vbox'>
<div className='hbox'>
{prev && <Link href={`#?testId=${prev.testId}${filterParam}`}>« previous</Link>}
<div style={{ flex: 'auto' }}></div>
{next && <Link href={`#?testId=${next.testId}${filterParam}`}>next »</Link>}
</div>
{test && <div className='test-case-path'>{test.path.join(' ')}</div>}
{test && <div className='test-case-title'>{test?.title}</div>}
{test && <div className='hbox'>

View File

@ -14,24 +14,25 @@
limitations under the License.
*/
import type { HTMLReport, TestCaseSummary, TestFileSummary } from './types';
import type { TestCaseSummary, TestFileSummary } from './types';
import * as React from 'react';
import { hashStringToInt, msToString } from './utils';
import { Chip } from './chip';
import { filterWithToken, type Filter } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
import { filterWithToken } from './filter';
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } from './links';
import { statusIcon } from './statusIcon';
import './testFileView.css';
import { video, image, trace } from './icons';
import { clsx } from '@web/uiUtils';
export const TestFileView: React.FC<React.PropsWithChildren<{
report: HTMLReport;
file: TestFileSummary;
projectNames: string[];
isFileExpanded: (fileId: string) => boolean;
setFileExpanded: (fileId: string, expanded: boolean) => void;
filter: Filter;
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
}>> = ({ file, projectNames, isFileExpanded, setFileExpanded }) => {
const searchParams = React.useContext(SearchParamsContext);
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
return <Chip
expanded={isFileExpanded(file.fileId)}
noInsets={true}
@ -39,7 +40,7 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
header={<span>
{file.fileName}
</span>}>
{file.tests.filter(t => filter.matches(t)).map(test =>
{file.tests.map(test =>
<div key={`test-${test.testId}`} className={clsx('test-file-test', 'test-file-test-outcome-' + test.outcome)}>
<div className='hbox' style={{ alignItems: 'flex-start' }}>
<div className='hbox'>
@ -47,11 +48,11 @@ export const TestFileView: React.FC<React.PropsWithChildren<{
{statusIcon(test.outcome)}
</span>
<span>
<Link href={`#?testId=${test.testId}`} title={[...test.path, test.title].join(' ')}>
<Link href={`#?testId=${test.testId}${filterParam}`} title={[...test.path, test.title].join(' ')}>
<span className='test-file-title'>{[...test.path, test.title].join(' ')}</span>
</Link>
{report.projectNames.length > 1 && !!test.projectName &&
<ProjectLink projectNames={report.projectNames} projectName={test.projectName} />}
{projectNames.length > 1 && !!test.projectName &&
<ProjectLink projectNames={projectNames} projectName={test.projectName} />}
<LabelsClickView labels={test.tags} />
</span>
</div>
@ -90,10 +91,10 @@ function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
const LabelsClickView: React.FC<React.PropsWithChildren<{
labels: string[],
}>> = ({ labels }) => {
const searchParams = React.useContext(SearchParamsContext);
const onClickHandle = (e: React.MouseEvent, label: string) => {
e.preventDefault();
const searchParams = new URLSearchParams(window.location.hash.slice(1));
const q = searchParams.get('q')?.toString() || '';
const tokens = q.split(' ');
navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey));

View File

@ -16,7 +16,6 @@
import type { FilteredStats, HTMLReport, TestFileSummary } from './types';
import * as React from 'react';
import type { Filter } from './filter';
import { TestFileView } from './testFileView';
import './testFileView.css';
import { msToString } from './utils';
@ -24,40 +23,26 @@ import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView';
export const TestFilesView: React.FC<{
report?: HTMLReport,
tests: TestFileSummary[],
expandedFiles: Map<string, boolean>,
setExpandedFiles: (value: Map<string, boolean>) => void,
filter: Filter,
filteredStats: FilteredStats,
projectNames: string[],
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => {
}> = ({ tests, expandedFiles, setExpandedFiles, projectNames }) => {
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 });
for (const file of tests) {
visibleTests += file.tests.length;
result.push({ file, defaultExpanded: visibleTests < 200 });
}
return result;
}, [report, filter]);
}, [tests]);
return <>
<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>}
{!filter.empty() && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
<div style={{ flex: 'auto' }}></div>
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report?.duration ?? 0)}</div>
</div>
{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>)}
</AutoChip>}
{report && filteredFiles.map(({ file, defaultExpanded }) => {
{filteredFiles.map(({ file, defaultExpanded }) => {
return <TestFileView
key={`file-${file.fileId}`}
report={report}
file={file}
projectNames={projectNames}
isFileExpanded={fileId => {
const value = expandedFiles.get(fileId);
if (value === undefined)
@ -68,9 +53,28 @@ export const TestFilesView: React.FC<{
const newExpanded = new Map(expandedFiles);
newExpanded.set(fileId, expanded);
setExpandedFiles(newExpanded);
}}
filter={filter}>
}}>
</TestFileView>;
})}
</>;
};
export const TestFilesHeader: React.FC<{
report: HTMLReport | undefined,
filteredStats?: FilteredStats,
}> = ({ report, filteredStats }) => {
if (!report)
return;
return <>
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name' style={{ color: 'var(--color-fg-subtle)' }}>Project: {report.projectNames[0]}</div>}
{filteredStats && <div data-testid='filtered-tests-count' style={{ color: 'var(--color-fg-subtle)', padding: '0 10px' }}>Filtered: {filteredStats.total} {!!filteredStats.total && ('(' + msToString(filteredStats.duration) + ')')}</div>}
<div style={{ flex: 'auto' }}></div>
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div>
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>}
</>;
};