diff --git a/packages/html-reporter/src/icons.tsx b/packages/html-reporter/src/icons.tsx index c3eb471b4d..fb44eb64a7 100644 --- a/packages/html-reporter/src/icons.tsx +++ b/packages/html-reporter/src/icons.tsx @@ -35,6 +35,12 @@ export const rightArrow = () => { ; }; +export const leftArrow = () => { + return ; +}; + export const warning = () => { return
- {report?.json() && } - {report?.json().metadata && } + {reportData && } + {reportData?.metadata && } - {!!report && } + {!!report && ( + + )}
; @@ -72,21 +93,25 @@ export const ReportView: React.FC<{ const TestCaseViewLoader: React.FC<{ report: LoadedReport, -}> = ({ report }) => { - const searchParams = new URLSearchParams(window.location.hash.slice(1)); + filteredFiles: TestFileSummary[], + filter: Filter +}> = ({ report, filteredFiles, filter }) => { + const searchParams = useSearchParams(); const [test, setTest] = React.useState(); - const testId = searchParams.get('testId'); + 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(); - for (const file of report.json().files) { + for (const file of filteredFiles) { for (const test of file.tests) map.set(test.testId, file.fileId); } return map; - }, [report]); + }, [filteredFiles]); + + const { prevTestId, nextTestId } = getAdjacentTestIds(testIdToFileIdMap, testId); React.useEffect(() => { (async () => { @@ -104,19 +129,43 @@ const TestCaseViewLoader: React.FC<{ } })(); }, [test, report, testId, testIdToFileIdMap]); - return ; + + return ( + + ); }; -function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats { + +function computeStats(filteredFiles: TestFileSummary[]): FilteredStats { const stats: FilteredStats = { total: 0, duration: 0, }; - for (const file of files) { - const tests = file.tests.filter(t => filter.matches(t)); - stats.total += tests.length; - for (const test of tests) + for (const file of filteredFiles) { + stats.total += file.tests.length; + for (const test of file.tests) stats.duration += test.duration; } return stats; -} \ No newline at end of file +} + +function getAdjacentTestIds(testIdToFileIdMap: Map, 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], + }; +} diff --git a/packages/html-reporter/src/testCaseView.css b/packages/html-reporter/src/testCaseView.css index d87cf3aacb..950ab0d10b 100644 --- a/packages/html-reporter/src/testCaseView.css +++ b/packages/html-reporter/src/testCaseView.css @@ -34,14 +34,26 @@ color: var(--color-fg-default); } +.test-case-title-wrapper { + display: grid; + grid-template-columns: auto 32px; + gap: 8px; + padding: 8px; +} + .test-case-title { flex: none; - padding: 8px; font-weight: 400; font-size: 32px !important; line-height: 1.25 !important; } +.test-case-navigation { + display: flex; + align-items: center; + gap: 8px; +} + .test-case-location, .test-case-duration { flex: none; @@ -73,4 +85,4 @@ display: flex; flex-direction: row; flex-wrap: wrap; -} \ No newline at end of file +} diff --git a/packages/html-reporter/src/testCaseView.spec.tsx b/packages/html-reporter/src/testCaseView.spec.tsx index 624a93805f..df322d44af 100644 --- a/packages/html-reporter/src/testCaseView.spec.tsx +++ b/packages/html-reporter/src/testCaseView.spec.tsx @@ -64,7 +64,7 @@ const testCase: TestCase = { }; test('should render test case', async ({ mount }) => { - const component = await mount(); + const component = await mount(); await expect(component.getByText('Annotation text', { exact: false }).first()).toBeVisible(); await expect(component.getByText('Hidden annotation')).toBeHidden(); await component.getByText('Annotations').click(); @@ -96,7 +96,7 @@ const annotationLinkRenderingTestCase: TestCase = { }; test('should correctly render links in annotations', async ({ mount }) => { - const component = await mount(); + const component = await mount(); const firstLink = await component.getByText('https://playwright.dev/docs/intro').first(); await expect(firstLink).toBeVisible(); @@ -154,7 +154,7 @@ const attachmentLinkRenderingTestCase: TestCase = { }; test('should correctly render links in attachments', async ({ mount }) => { - const component = await mount(); + const component = await mount(); await component.getByText('first attachment').click(); const body = await component.getByText('The body with https://playwright.dev/docs/intro link'); 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 }) => { - const component = await mount(); + const component = await mount(); 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 navigation links', async ({ mount, page }) => { + const component = await mount(); + + 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/); +}); + diff --git a/packages/html-reporter/src/testCaseView.tsx b/packages/html-reporter/src/testCaseView.tsx index f4d76653cf..5985de6021 100644 --- a/packages/html-reporter/src/testCaseView.tsx +++ b/packages/html-reporter/src/testCaseView.tsx @@ -19,19 +19,26 @@ import * as React from 'react'; import { TabbedPane } from './tabbedPane'; import { AutoChip } from './chip'; import './common.css'; -import { ProjectLink } from './links'; +import { Link, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testCaseView.css'; import { TestResultView } from './testResultView'; import { linkifyText } from './renderUtils'; import { hashStringToInt, msToString } from './utils'; +import * as icons from './icons'; +import { useSearchParams } from './use-search-params'; export const TestCaseView: React.FC<{ projectNames: string[], test: TestCase | undefined, anchor: 'video' | 'diff' | '', 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 labels = React.useMemo(() => { @@ -46,7 +53,15 @@ export const TestCaseView: React.FC<{ return
{test &&
{test.path.join(' › ')}
} - {test &&
{test?.title}
} + {test && ( +
+
{test?.title}
+
+ {icons.leftArrow()} + {icons.rightArrow()} +
+
+ )} {test &&
{test.location.file}:{test.location.line}
diff --git a/packages/html-reporter/src/testFileView.tsx b/packages/html-reporter/src/testFileView.tsx index a5f9a7a358..52c908a697 100644 --- a/packages/html-reporter/src/testFileView.tsx +++ b/packages/html-reporter/src/testFileView.tsx @@ -23,6 +23,7 @@ import { generateTraceUrl, Link, navigate, ProjectLink } from './links'; import { statusIcon } from './statusIcon'; import './testFileView.css'; import { video, image, trace } from './icons'; +import { useSearchParams } from './use-search-params'; export const TestFileView: React.FC void; filter: Filter; }>> = ({ file, report, isFileExpanded, setFileExpanded, filter }) => { + const searchParams = useSearchParams(); + const q = searchParams.get('q') ?? ''; + return {file.fileName} }> - {file.tests.filter(t => filter.matches(t)).map(test => + {file.tests.map(test =>
@@ -46,7 +50,7 @@ export const TestFileView: React.FC - + {[...test.path, test.title].join(' › ')} {report.projectNames.length > 1 && !!test.projectName && @@ -57,11 +61,11 @@ export const TestFileView: React.FC{msToString(test.duration)}
- + {test.location.file}:{test.location.line} - {imageDiffBadge(test)} - {videoBadge(test)} + {imageDiffBadge(test, q)} + {videoBadge(test, q)} {traceBadge(test)}
@@ -69,16 +73,16 @@ export const TestFileView: React.FC; }; -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 => { return attachment.contentType.startsWith('image/') && !!attachment.name.match(/-(expected|actual|diff)/); })); - return resultWithImageDiff ? {image()} : undefined; + return resultWithImageDiff ? {image()} : 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')); - return resultWithVideo ? {video()} : undefined; + return resultWithVideo ? {video()} : undefined; } function traceBadge(test: TestCaseSummary): JSX.Element | undefined { diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index 2a783b5ebe..3e744f8348 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -30,18 +30,18 @@ export const TestFilesView: React.FC<{ filter: Filter, filteredStats: FilteredStats, projectNames: string[], -}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats }) => { - const filteredFiles = React.useMemo(() => { + filteredFiles: TestFileSummary[] +}> = ({ report, filter, expandedFiles, setExpandedFiles, projectNames, filteredStats, filteredFiles }) => { + const filesSummary = 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 filteredFiles) { + visibleTests += file.tests.length; + result.push({ file, defaultExpanded: visibleTests < 200 }); } return result; - }, [report, filter]); + }, [filteredFiles]); + return <>
{projectNames.length === 1 && !!projectNames[0] &&
Project: {projectNames[0]}
} @@ -53,7 +53,7 @@ export const TestFilesView: React.FC<{ {report && !!report.errors.length && {report.errors.map((error, index) => )} } - {report && filteredFiles.map(({ file, defaultExpanded }) => { + {report && filesSummary.map(({ file, defaultExpanded }) => { return { + 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)); +} diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index e416cd05c1..89413fadcd 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2437,6 +2437,111 @@ for (const useIntermediateMergeReport of [false] as const) { await testFilePathLink.click(); 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'); + }); + }); }); }