Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions packages/html-reporter/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,13 +34,17 @@ link.href = logo;
document.head.appendChild(link);

const ReportLoader: React.FC = () => {
const [report, setReport] = React.useState<LoadedReport | undefined>();
const [report, setReport] = React.useState<AsyncResult<ParsedReport>>({ 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 <SearchParamsProvider>
<ReportView report={report} />
</SearchParamsProvider>;
Expand All @@ -52,7 +56,7 @@ window.onload = () => {

const kPlaywrightReportStorageForHMR = 'playwrightReportStorageForHMR';

class ZipReport implements LoadedReport {
class ZipReport implements ParsedReport {
private _entries = new Map<string, zip.Entry>();
private _json!: HTMLReport;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import type { HTMLReport } from './types';

export interface LoadedReport {
export interface ParsedReport {
json(): HTMLReport;
entry(name: string): Promise<Object | undefined>;
}
6 changes: 6 additions & 0 deletions packages/html-reporter/src/reportView.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
98 changes: 69 additions & 29 deletions packages/html-reporter/src/reportView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -43,7 +43,17 @@ type TestModelSummary = {
};

export const ReportView: React.FC<{
report: LoadedReport | undefined,
report: AsyncResult<ParsedReport>,
}> = ({ report }) => {
return <div className='htmlreport vbox px-4 pb-4'>
<main>
<RenderedReportResult report={report} />
</main>
</div>;
};

const LoadedReportView: React.FC<{
report: ParsedReport,
}> = ({ report }) => {
const searchParams = React.useContext(SearchParamsContext);
const [expandedFiles, setExpandedFiles] = React.useState<Map<string, boolean>>(new Map());
Expand All @@ -52,18 +62,18 @@ export const ReportView: React.FC<{

const testIdToFileIdMap = React.useMemo(() => {
const map = new Map<string, string>();
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);
}
return map;
}, [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 });
Expand All @@ -74,30 +84,43 @@ export const ReportView: React.FC<{

return <div className='htmlreport vbox px-4 pb-4'>
<main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText} />
<Route predicate={testFilesRoutePredicate}>
<TestFilesHeader report={report?.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
<TestFilesHeader report={report.json()} filteredStats={filteredStats} metadataVisible={metadataVisible} toggleMetadataVisible={() => setMetadataVisible(visible => !visible)}/>
<TestFilesView
tests={filteredTests.files}
expandedFiles={expandedFiles}
setExpandedFiles={setExpandedFiles}
projectNames={report?.json().projectNames || []}
projectNames={report.json().projectNames}
/>
</Route>
<Route predicate={testCaseRoutePredicate}>
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
<TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />
</Route>
</main>
</div>;
};

const RenderedReportResult: React.FC<{
report: AsyncResult<ParsedReport>
}> = ({ report }) => {
switch (report.type) {
case 'loading':
return <div />;
case 'data':
return <LoadedReportView report={report.data} />;
case 'error':
return <div className='fatal-report-error'>Report data could not be found. Please reload the page or regenerate the HTML report</div>;
}
};

const TestCaseViewLoader: React.FC<{
report: LoadedReport,
report: ParsedReport,
tests: TestCaseSummary[],
testIdToFileIdMap: Map<string, string>,
}> = ({ report, testIdToFileIdMap, tests }) => {
const searchParams = React.useContext(SearchParamsContext);
const [test, setTest] = React.useState<TestCase | undefined>();
const [test, setTest] = React.useState<AsyncResult<TestCase>>({ type: 'loading' });
const testId = searchParams.get('testId');
const run = +(searchParams.get('run') || '0');

Expand All @@ -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 <TestCaseView
projectNames={report.json().projectNames}
next={next}
prev={prev}
test={test}
run={run}
/>;
}, [report, testId, currentTestId, testIdToFileIdMap]);

switch (test.type) {
case 'loading':
case 'data':
return <TestCaseView
projectNames={report.json().projectNames}
next={next}
prev={prev}
test={test.type === 'data' ? test.data : undefined}
run={run}
/>;
case 'error':
return <div className='test-case-column vbox'>
<div className='test-case-title'>Test not found</div>
<div className='test-case-location'>Test ID: {testId}</div>
</div>;
}
};

function computeStats(files: TestFileSummary[], filter: Filter): FilteredStats {
Expand Down
4 changes: 1 addition & 3 deletions packages/html-reporter/src/testFilesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <>
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
<div className='test-file-header-info'>
Expand Down
9 changes: 9 additions & 0 deletions packages/html-reporter/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,12 @@ export type TestStep = {
count: number;
skipped?: boolean;
};

export type AsyncResult<T> = {
type: 'loading'
} | {
type: 'data',
data: T,
} | {
type: 'error',
};
19 changes: 19 additions & 0 deletions tests/playwright-test/reporter-html.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<button>Click me</button>');
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();
});
});
}

Expand Down
Loading