diff --git a/packages/html-reporter/src/index.tsx b/packages/html-reporter/src/index.tsx index 84763267c49f0..c143a6bf5d073 100644 --- a/packages/html-reporter/src/index.tsx +++ b/packages/html-reporter/src/index.tsx @@ -14,14 +14,14 @@ * limitations under the License. */ -import type { HTMLReport } from './types'; +import type { AsyncResult, HTMLReport } from './types'; import type * as zip from '@zip.js/zip.js'; // @ts-ignore import * as zipImport from '@zip.js/zip.js/lib/zip-no-worker-inflate.js'; import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; import './colors.css'; -import type { LoadedReport } from './loadedReport'; +import type { ParsedReport } from './parsedReport'; import { ReportView } from './reportView'; // @ts-ignore const zipjs = zipImport as typeof zip; @@ -34,13 +34,17 @@ link.href = logo; document.head.appendChild(link); const ReportLoader: React.FC = () => { - const [report, setReport] = React.useState(); + const [report, setReport] = React.useState>({ type: 'loading' }); React.useEffect(() => { - if (report) - return; const zipReport = new ZipReport(); - zipReport.load().then(() => setReport(zipReport)); - }, [report]); + zipReport.load() + .then(() => setReport({ type: 'data', data: zipReport })) + .catch(error => { + // eslint-disable-next-line no-console + console.error('Failed to load report', error); + setReport({ type: 'error' }); + }); + }, []); return ; @@ -52,7 +56,7 @@ window.onload = () => { const kPlaywrightReportStorageForHMR = 'playwrightReportStorageForHMR'; -class ZipReport implements LoadedReport { +class ZipReport implements ParsedReport { private _entries = new Map(); private _json!: HTMLReport; diff --git a/packages/html-reporter/src/loadedReport.ts b/packages/html-reporter/src/parsedReport.ts similarity index 95% rename from packages/html-reporter/src/loadedReport.ts rename to packages/html-reporter/src/parsedReport.ts index c1ff2d753088d..cb93088566855 100644 --- a/packages/html-reporter/src/loadedReport.ts +++ b/packages/html-reporter/src/parsedReport.ts @@ -16,7 +16,7 @@ import type { HTMLReport } from './types'; -export interface LoadedReport { +export interface ParsedReport { json(): HTMLReport; entry(name: string): Promise; } diff --git a/packages/html-reporter/src/reportView.css b/packages/html-reporter/src/reportView.css index ae5a633c2466c..09ea0ff2bed5e 100644 --- a/packages/html-reporter/src/reportView.css +++ b/packages/html-reporter/src/reportView.css @@ -29,6 +29,12 @@ body { width: 100%; } +.fatal-report-error { + display: flex; + justify-content: center; + margin-top: 10px; +} + .test-file-test:not(:first-child) { border-top: 1px solid var(--color-border-default); } diff --git a/packages/html-reporter/src/reportView.tsx b/packages/html-reporter/src/reportView.tsx index e48064201c26f..167d822aed30c 100644 --- a/packages/html-reporter/src/reportView.tsx +++ b/packages/html-reporter/src/reportView.tsx @@ -14,14 +14,14 @@ limitations under the License. */ -import type { FilteredStats, TestCase, TestCaseSummary, TestFile, TestFileSummary } from './types'; +import type { AsyncResult, 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, SearchParamsContext } from './links'; -import type { LoadedReport } from './loadedReport'; +import type { ParsedReport } from './parsedReport'; import './reportView.css'; import { TestCaseView } from './testCaseView'; import { TestFilesHeader, TestFilesView } from './testFilesView'; @@ -43,7 +43,17 @@ type TestModelSummary = { }; export const ReportView: React.FC<{ - report: LoadedReport | undefined, + report: AsyncResult, +}> = ({ report }) => { + return
+
+ +
+
; +}; + +const LoadedReportView: React.FC<{ + report: ParsedReport, }> = ({ report }) => { const searchParams = React.useContext(SearchParamsContext); const [expandedFiles, setExpandedFiles] = React.useState>(new Map()); @@ -52,7 +62,7 @@ export const ReportView: React.FC<{ const testIdToFileIdMap = React.useMemo(() => { const map = new Map(); - for (const file of report?.json().files || []) { + for (const file of report.json().files) { for (const test of file.tests) map.set(test.testId, file.fileId); } @@ -60,10 +70,10 @@ export const ReportView: React.FC<{ }, [report]); const filter = React.useMemo(() => Filter.parse(filterText), [filterText]); - const filteredStats = React.useMemo(() => filter.empty() ? undefined : 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 || []) { + for (const file of report.json().files) { const tests = file.tests.filter(t => filter.matches(t)); if (tests.length) result.files.push({ ...file, tests }); @@ -74,30 +84,43 @@ export const ReportView: React.FC<{ return
- {report?.json() && } + - setMetadataVisible(visible => !visible)}/> + setMetadataVisible(visible => !visible)}/> - {!!report && } +
; }; +const RenderedReportResult: React.FC<{ + report: AsyncResult +}> = ({ report }) => { + switch (report.type) { + case 'loading': + return
; + case 'data': + return ; + case 'error': + return
Report data could not be found. Please reload the page or regenerate the HTML report
; + } +}; + const TestCaseViewLoader: React.FC<{ - report: LoadedReport, + report: ParsedReport, tests: TestCaseSummary[], testIdToFileIdMap: Map, }> = ({ report, testIdToFileIdMap, tests }) => { const searchParams = React.useContext(SearchParamsContext); - const [test, setTest] = React.useState(); + const [test, setTest] = React.useState>({ type: 'loading' }); const testId = searchParams.get('testId'); const run = +(searchParams.get('run') || '0'); @@ -108,30 +131,47 @@ const TestCaseViewLoader: React.FC<{ return { prev, next }; }, [testId, tests]); + const currentTestId = test.type === 'data' ? test.data.testId : undefined; + React.useEffect(() => { (async () => { - if (!testId || testId === test?.testId) + if (testId === currentTestId) return; + if (!testId) { + setTest({ type: 'error' }); + return; + } const fileId = testIdToFileIdMap.get(testId); - if (!fileId) + if (!fileId) { + setTest({ type: 'error' }); return; - const file = await report.entry(`${fileId}.json`) as TestFile; - for (const t of file.tests) { - if (t.testId === testId) { - setTest(t); - break; - } + } + try { + const file = await report.entry(`${fileId}.json`) as TestFile; + const testCase = file.tests.find(t => t.testId === testId); + setTest(!!testCase ? { type: 'data', data: testCase } : { type: 'error' }); + } catch (e) { + setTest({ type: 'error' }); } })(); - }, [test, report, testId, testIdToFileIdMap]); - - return ; + }, [report, testId, currentTestId, testIdToFileIdMap]); + + switch (test.type) { + case 'loading': + case 'data': + return ; + case 'error': + return
+
Test not found
+
Test ID: {testId}
+
; + } }; function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats { diff --git a/packages/html-reporter/src/testFilesView.tsx b/packages/html-reporter/src/testFilesView.tsx index 4b2c48ae1d84e..74a02d508b279 100644 --- a/packages/html-reporter/src/testFilesView.tsx +++ b/packages/html-reporter/src/testFilesView.tsx @@ -62,13 +62,11 @@ export const TestFilesView: React.FC<{ }; export const TestFilesHeader: React.FC<{ - report: HTMLReport | undefined, + report: HTMLReport, filteredStats?: FilteredStats, metadataVisible: boolean, toggleMetadataVisible: () => void, }> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => { - if (!report) - return null; return <>
diff --git a/packages/html-reporter/src/types.d.ts b/packages/html-reporter/src/types.d.ts index 17c5a3b3730a4..51ae99704d2d1 100644 --- a/packages/html-reporter/src/types.d.ts +++ b/packages/html-reporter/src/types.d.ts @@ -113,3 +113,12 @@ export type TestStep = { count: number; skipped?: boolean; }; + +export type AsyncResult = { + type: 'loading' +} | { + type: 'data', + data: T, +} | { + type: 'error', +}; diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index aa827cda08d0e..3a7bd47028fa6 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2933,6 +2933,25 @@ for (const useIntermediateMergeReport of [true, false] as const) { const prompt = await page.evaluate(() => navigator.clipboard.readText()); expect(prompt, 'contains snapshot').toContain('- button "Click me"'); }); + + test('should show error if test ID not found', async ({ runInlineTest, showReport, page }) => { + const result = await runInlineTest({ + 'example.spec.ts': ` + import { test, expect } from '@playwright/test'; + test('sample', async ({ browser }) => { + const page = await browser.newPage(); + await page.setContent(''); + expect(2).toBe(2); + }); + `, + }, { reporter: 'html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + await showReport(); + + await page.goto(`${page.url()}#?testId=non-existing-test-id`); + + await expect(page.getByText('Test not found')).toBeVisible(); + }); }); }