mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
feat(html): render prev/next test buttons (#33356)
This commit is contained in:
parent
9ce401d44a
commit
6f5c7b4358
@ -20,7 +20,7 @@ import './colors.css';
|
|||||||
import './common.css';
|
import './common.css';
|
||||||
import './headerView.css';
|
import './headerView.css';
|
||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import { Link, navigate } from './links';
|
import { Link, navigate, SearchParamsContext } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import { filterWithToken } from './filter';
|
import { filterWithToken } from './filter';
|
||||||
|
|
||||||
@ -65,7 +65,7 @@ export const HeaderView: React.FC<React.PropsWithChildren<{
|
|||||||
const StatsNavView: React.FC<{
|
const StatsNavView: React.FC<{
|
||||||
stats: Stats
|
stats: Stats
|
||||||
}> = ({ stats }) => {
|
}> = ({ stats }) => {
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
const q = searchParams.get('q')?.toString() || '';
|
const q = searchParams.get('q')?.toString() || '';
|
||||||
const tokens = q.split(' ');
|
const tokens = q.split(' ');
|
||||||
return <nav>
|
return <nav>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import { ReportView } from './reportView';
|
|||||||
const zipjs = zipImport as typeof zip;
|
const zipjs = zipImport as typeof zip;
|
||||||
|
|
||||||
import logo from '@web/assets/playwright-logo.svg';
|
import logo from '@web/assets/playwright-logo.svg';
|
||||||
|
import { SearchParamsProvider } from './links';
|
||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.rel = 'shortcut icon';
|
link.rel = 'shortcut icon';
|
||||||
link.href = logo;
|
link.href = logo;
|
||||||
@ -40,7 +41,9 @@ const ReportLoader: React.FC = () => {
|
|||||||
const zipReport = new ZipReport();
|
const zipReport = new ZipReport();
|
||||||
zipReport.load().then(() => setReport(zipReport));
|
zipReport.load().then(() => setReport(zipReport));
|
||||||
}, [report]);
|
}, [report]);
|
||||||
return <ReportView report={report}></ReportView>;
|
return <SearchParamsProvider>
|
||||||
|
<ReportView report={report} />
|
||||||
|
</SearchParamsProvider>;
|
||||||
};
|
};
|
||||||
|
|
||||||
window.onload = () => {
|
window.onload = () => {
|
||||||
|
|||||||
@ -33,13 +33,8 @@ export const Route: React.FunctionComponent<{
|
|||||||
predicate: (params: URLSearchParams) => boolean,
|
predicate: (params: URLSearchParams) => boolean,
|
||||||
children: any
|
children: any
|
||||||
}> = ({ predicate, children }) => {
|
}> = ({ predicate, children }) => {
|
||||||
const [matches, setMatches] = React.useState(predicate(new URLSearchParams(window.location.hash.slice(1))));
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
React.useEffect(() => {
|
return predicate(searchParams) ? children : null;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Link: React.FunctionComponent<{
|
export const Link: React.FunctionComponent<{
|
||||||
@ -90,6 +85,20 @@ export const AttachmentLink: React.FunctionComponent<{
|
|||||||
} : undefined} depth={0} style={{ lineHeight: '32px' }}></TreeItem>;
|
} : 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 {
|
function downloadFileNameForAttachment(attachment: TestAttachment): string {
|
||||||
if (attachment.name.includes('.') || !attachment.path)
|
if (attachment.name.includes('.') || !attachment.path)
|
||||||
return attachment.name;
|
return attachment.name;
|
||||||
|
|||||||
@ -14,19 +14,19 @@
|
|||||||
limitations under the License.
|
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 * as React from 'react';
|
||||||
import './colors.css';
|
import './colors.css';
|
||||||
import './common.css';
|
import './common.css';
|
||||||
import { Filter } from './filter';
|
import { Filter } from './filter';
|
||||||
import { HeaderView } from './headerView';
|
import { HeaderView } from './headerView';
|
||||||
import { Route } from './links';
|
import { Route, SearchParamsContext } from './links';
|
||||||
import type { LoadedReport } from './loadedReport';
|
import type { LoadedReport } from './loadedReport';
|
||||||
import './reportView.css';
|
import './reportView.css';
|
||||||
import type { Metainfo } from './metadataView';
|
import type { Metainfo } from './metadataView';
|
||||||
import { MetadataView } from './metadataView';
|
import { MetadataView } from './metadataView';
|
||||||
import { TestCaseView } from './testCaseView';
|
import { TestCaseView } from './testCaseView';
|
||||||
import { TestFilesView } from './testFilesView';
|
import { TestFilesHeader, TestFilesView } from './testFilesView';
|
||||||
import './theme.css';
|
import './theme.css';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@ -39,32 +39,55 @@ declare global {
|
|||||||
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
|
const testFilesRoutePredicate = (params: URLSearchParams) => !params.has('testId');
|
||||||
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
|
const testCaseRoutePredicate = (params: URLSearchParams) => params.has('testId');
|
||||||
|
|
||||||
|
type TestModelSummary = {
|
||||||
|
files: TestFileSummary[];
|
||||||
|
tests: TestCaseSummary[];
|
||||||
|
};
|
||||||
|
|
||||||
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 = React.useContext(SearchParamsContext);
|
||||||
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 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 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'>
|
return <div className='htmlreport vbox px-4 pb-4'>
|
||||||
<main>
|
<main>
|
||||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||||
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
{report?.json().metadata && <MetadataView {...report?.json().metadata as Metainfo} />}
|
||||||
<Route predicate={testFilesRoutePredicate}>
|
<Route predicate={testFilesRoutePredicate}>
|
||||||
|
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} />
|
||||||
<TestFilesView
|
<TestFilesView
|
||||||
report={report?.json()}
|
tests={filteredTests.files}
|
||||||
filter={filter}
|
|
||||||
expandedFiles={expandedFiles}
|
expandedFiles={expandedFiles}
|
||||||
setExpandedFiles={setExpandedFiles}
|
setExpandedFiles={setExpandedFiles}
|
||||||
projectNames={report?.json().projectNames || []}
|
projectNames={report?.json().projectNames || []}
|
||||||
filteredStats={filteredStats}
|
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
<Route predicate={testCaseRoutePredicate}>
|
<Route predicate={testCaseRoutePredicate}>
|
||||||
{!!report && <TestCaseViewLoader report={report}></TestCaseViewLoader>}
|
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
|
||||||
</Route>
|
</Route>
|
||||||
</main>
|
</main>
|
||||||
</div>;
|
</div>;
|
||||||
@ -72,21 +95,21 @@ export const ReportView: React.FC<{
|
|||||||
|
|
||||||
const TestCaseViewLoader: React.FC<{
|
const TestCaseViewLoader: React.FC<{
|
||||||
report: LoadedReport,
|
report: LoadedReport,
|
||||||
}> = ({ report }) => {
|
tests: TestCaseSummary[],
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
testIdToFileIdMap: Map<string, string>,
|
||||||
|
}> = ({ report, testIdToFileIdMap, tests }) => {
|
||||||
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
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 { prev, next } = React.useMemo(() => {
|
||||||
const map = new Map<string, string>();
|
const index = tests.findIndex(t => t.testId === testId);
|
||||||
for (const file of report.json().files) {
|
const prev = index > 0 ? tests[index - 1] : undefined;
|
||||||
for (const test of file.tests)
|
const next = index < tests.length - 1 ? tests[index + 1] : undefined;
|
||||||
map.set(test.testId, file.fileId);
|
return { prev, next };
|
||||||
}
|
}, [testId, tests]);
|
||||||
return map;
|
|
||||||
}, [report]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -104,7 +127,15 @@ 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}
|
||||||
|
next={next}
|
||||||
|
prev={prev}
|
||||||
|
test={test}
|
||||||
|
anchor={anchor}
|
||||||
|
run={run}
|
||||||
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
|
function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
|
||||||
@ -119,4 +150,4 @@ function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
|
|||||||
stats.duration += test.duration;
|
stats.duration += test.duration;
|
||||||
}
|
}
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
.test-case-column {
|
.test-case-column {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
margin: 24px 0;
|
margin: 12px 0 24px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.test-case-column .tab-element.selected {
|
.test-case-column .tab-element.selected {
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
import { test, expect } from '@playwright/experimental-ct-react';
|
import { test, expect } from '@playwright/experimental-ct-react';
|
||||||
import { TestCaseView } from './testCaseView';
|
import { TestCaseView } from './testCaseView';
|
||||||
import type { TestCase, TestResult } from './types';
|
import type { TestCase, TestCaseSummary, TestResult } from './types';
|
||||||
|
|
||||||
test.use({ viewport: { width: 800, height: 600 } });
|
test.use({ viewport: { width: 800, height: 600 } });
|
||||||
|
|
||||||
@ -63,7 +63,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} prev={undefined} next={undefined} run={0} anchor=''></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();
|
||||||
@ -79,7 +79,7 @@ test('should render test case', async ({ mount }) => {
|
|||||||
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
|
test('should render copy buttons for annotations', async ({ mount, page, context }) => {
|
||||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
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 expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible();
|
||||||
await component.getByText('Annotation text', { exact: false }).first().hover();
|
await component.getByText('Annotation text', { exact: false }).first().hover();
|
||||||
await expect(component.locator('.test-case-annotation').getByLabel('Copy to clipboard').first()).toBeVisible();
|
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 }) => {
|
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();
|
const firstLink = await component.getByText('https://playwright.dev/docs/intro').first();
|
||||||
await expect(firstLink).toBeVisible();
|
await expect(firstLink).toBeVisible();
|
||||||
@ -165,8 +165,23 @@ const attachmentLinkRenderingTestCase: TestCase = {
|
|||||||
results: [resultWithAttachment]
|
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 }) => {
|
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();
|
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();
|
||||||
@ -175,8 +190,17 @@ 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} prev={undefined} next={undefined} run={0} anchor=''></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 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"
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|||||||
@ -14,12 +14,12 @@
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { TestCase, TestCaseAnnotation } from './types';
|
import type { TestCase, TestCaseAnnotation, TestCaseSummary } from './types';
|
||||||
import * as React from 'react';
|
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, SearchParamsContext } from './links';
|
||||||
import { statusIcon } from './statusIcon';
|
import { statusIcon } from './statusIcon';
|
||||||
import './testCaseView.css';
|
import './testCaseView.css';
|
||||||
import { TestResultView } from './testResultView';
|
import { TestResultView } from './testResultView';
|
||||||
@ -31,10 +31,14 @@ import { CopyToClipboardContainer } from './copyToClipboard';
|
|||||||
export const TestCaseView: React.FC<{
|
export const TestCaseView: React.FC<{
|
||||||
projectNames: string[],
|
projectNames: string[],
|
||||||
test: TestCase | undefined,
|
test: TestCase | undefined,
|
||||||
|
next: TestCaseSummary | undefined,
|
||||||
|
prev: TestCaseSummary | undefined,
|
||||||
anchor: 'video' | 'diff' | '',
|
anchor: 'video' | 'diff' | '',
|
||||||
run: number,
|
run: number,
|
||||||
}> = ({ projectNames, test, run, anchor }) => {
|
}> = ({ projectNames, test, run, anchor, next, prev }) => {
|
||||||
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
const [selectedResultIndex, setSelectedResultIndex] = React.useState(run);
|
||||||
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
|
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
|
||||||
|
|
||||||
const labels = React.useMemo(() => {
|
const labels = React.useMemo(() => {
|
||||||
if (!test)
|
if (!test)
|
||||||
@ -47,6 +51,11 @@ export const TestCaseView: React.FC<{
|
|||||||
}, [test?.annotations]);
|
}, [test?.annotations]);
|
||||||
|
|
||||||
return <div className='test-case-column vbox'>
|
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-path'>{test.path.join(' › ')}</div>}
|
||||||
{test && <div className='test-case-title'>{test?.title}</div>}
|
{test && <div className='test-case-title'>{test?.title}</div>}
|
||||||
{test && <div className='hbox'>
|
{test && <div className='hbox'>
|
||||||
|
|||||||
@ -14,24 +14,25 @@
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { HTMLReport, TestCaseSummary, TestFileSummary } from './types';
|
import type { TestCaseSummary, TestFileSummary } from './types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { hashStringToInt, msToString } from './utils';
|
import { hashStringToInt, msToString } from './utils';
|
||||||
import { Chip } from './chip';
|
import { Chip } from './chip';
|
||||||
import { filterWithToken, type Filter } from './filter';
|
import { filterWithToken } from './filter';
|
||||||
import { generateTraceUrl, Link, navigate, ProjectLink } from './links';
|
import { generateTraceUrl, Link, navigate, ProjectLink, SearchParamsContext } 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 { clsx } from '@web/uiUtils';
|
import { clsx } from '@web/uiUtils';
|
||||||
|
|
||||||
export const TestFileView: React.FC<React.PropsWithChildren<{
|
export const TestFileView: React.FC<React.PropsWithChildren<{
|
||||||
report: HTMLReport;
|
|
||||||
file: TestFileSummary;
|
file: TestFileSummary;
|
||||||
|
projectNames: string[];
|
||||||
isFileExpanded: (fileId: string) => boolean;
|
isFileExpanded: (fileId: string) => boolean;
|
||||||
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
setFileExpanded: (fileId: string, expanded: boolean) => void;
|
||||||
filter: Filter;
|
}>> = ({ file, projectNames, isFileExpanded, setFileExpanded }) => {
|
||||||
}>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => {
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
|
const filterParam = searchParams.has('q') ? '&q=' + searchParams.get('q') : '';
|
||||||
return <Chip
|
return <Chip
|
||||||
expanded={isFileExpanded(file.fileId)}
|
expanded={isFileExpanded(file.fileId)}
|
||||||
noInsets={true}
|
noInsets={true}
|
||||||
@ -39,7 +40,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={clsx('test-file-test', 'test-file-test-outcome-' + test.outcome)}>
|
<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' style={{ alignItems: 'flex-start' }}>
|
||||||
<div className='hbox'>
|
<div className='hbox'>
|
||||||
@ -47,11 +48,11 @@ 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={`#?testId=${test.testId}${filterParam}`} 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 &&
|
{projectNames.length > 1 && !!test.projectName &&
|
||||||
<ProjectLink projectNames={report.projectNames} projectName={test.projectName} />}
|
<ProjectLink projectNames={projectNames} projectName={test.projectName} />}
|
||||||
<LabelsClickView labels={test.tags} />
|
<LabelsClickView labels={test.tags} />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -90,10 +91,10 @@ function traceBadge(test: TestCaseSummary): JSX.Element | undefined {
|
|||||||
const LabelsClickView: React.FC<React.PropsWithChildren<{
|
const LabelsClickView: React.FC<React.PropsWithChildren<{
|
||||||
labels: string[],
|
labels: string[],
|
||||||
}>> = ({ labels }) => {
|
}>> = ({ labels }) => {
|
||||||
|
const searchParams = React.useContext(SearchParamsContext);
|
||||||
|
|
||||||
const onClickHandle = (e: React.MouseEvent, label: string) => {
|
const onClickHandle = (e: React.MouseEvent, label: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const searchParams = new URLSearchParams(window.location.hash.slice(1));
|
|
||||||
const q = searchParams.get('q')?.toString() || '';
|
const q = searchParams.get('q')?.toString() || '';
|
||||||
const tokens = q.split(' ');
|
const tokens = q.split(' ');
|
||||||
navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey));
|
navigate(filterWithToken(tokens, label, e.metaKey || e.ctrlKey));
|
||||||
|
|||||||
@ -16,7 +16,6 @@
|
|||||||
|
|
||||||
import type { FilteredStats, HTMLReport, TestFileSummary } from './types';
|
import type { FilteredStats, HTMLReport, TestFileSummary } from './types';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import type { Filter } from './filter';
|
|
||||||
import { TestFileView } from './testFileView';
|
import { TestFileView } from './testFileView';
|
||||||
import './testFileView.css';
|
import './testFileView.css';
|
||||||
import { msToString } from './utils';
|
import { msToString } from './utils';
|
||||||
@ -24,40 +23,26 @@ import { AutoChip } from './chip';
|
|||||||
import { TestErrorView } from './testErrorView';
|
import { TestErrorView } from './testErrorView';
|
||||||
|
|
||||||
export const TestFilesView: React.FC<{
|
export const TestFilesView: React.FC<{
|
||||||
report?: HTMLReport,
|
tests: TestFileSummary[],
|
||||||
expandedFiles: Map<string, boolean>,
|
expandedFiles: Map<string, boolean>,
|
||||||
setExpandedFiles: (value: Map<string, boolean>) => void,
|
setExpandedFiles: (value: Map<string, boolean>) => void,
|
||||||
filter: Filter,
|
|
||||||
filteredStats: FilteredStats,
|
|
||||||
projectNames: string[],
|
projectNames: string[],
|
||||||
}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => {
|
}> = ({ tests, expandedFiles, setExpandedFiles, projectNames }) => {
|
||||||
const filteredFiles = React.useMemo(() => {
|
const filteredFiles = 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 tests) {
|
||||||
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]);
|
}, [tests]);
|
||||||
return <>
|
return <>
|
||||||
<div className='mt-2 mx-1' style={{ display: 'flex' }}>
|
{filteredFiles.map(({ file, defaultExpanded }) => {
|
||||||
{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 }) => {
|
|
||||||
return <TestFileView
|
return <TestFileView
|
||||||
key={`file-${file.fileId}`}
|
key={`file-${file.fileId}`}
|
||||||
report={report}
|
|
||||||
file={file}
|
file={file}
|
||||||
|
projectNames={projectNames}
|
||||||
isFileExpanded={fileId => {
|
isFileExpanded={fileId => {
|
||||||
const value = expandedFiles.get(fileId);
|
const value = expandedFiles.get(fileId);
|
||||||
if (value === undefined)
|
if (value === undefined)
|
||||||
@ -68,9 +53,28 @@ export const TestFilesView: React.FC<{
|
|||||||
const newExpanded = new Map(expandedFiles);
|
const newExpanded = new Map(expandedFiles);
|
||||||
newExpanded.set(fileId, expanded);
|
newExpanded.set(fileId, expanded);
|
||||||
setExpandedFiles(newExpanded);
|
setExpandedFiles(newExpanded);
|
||||||
}}
|
}}>
|
||||||
filter={filter}>
|
|
||||||
</TestFileView>;
|
</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>}
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user