diff --git a/packages/html-reporter/src/testResultView.css b/packages/html-reporter/src/testResultView.css index 3dbfb1da4a9e3..85d1a7dd73808 100644 --- a/packages/html-reporter/src/testResultView.css +++ b/packages/html-reporter/src/testResultView.css @@ -102,6 +102,10 @@ } } +.step-filter { + margin-bottom: 8px; +} + @media only screen and (max-width: 600px) { .test-result { padding: 0 !important; diff --git a/packages/html-reporter/src/testResultView.tsx b/packages/html-reporter/src/testResultView.tsx index 0a689be253710..279235a530c1b 100644 --- a/packages/html-reporter/src/testResultView.tsx +++ b/packages/html-reporter/src/testResultView.tsx @@ -91,6 +91,9 @@ export const TestResultView: React.FC<{ return { screenshots: [...screenshots], videos, traces, otherAttachments, diffs, errors, otherAttachmentAnchors, screenshotAnchors, errorContext }; }, [result]); + const [stepFilterText, setStepFilterText] = React.useState(''); + React.useEffect(() => setStepFilterText(''), [result]); + const prompt = useAsyncMemo(async () => { if (report.json().options?.noCopyPrompt) return undefined; @@ -130,7 +133,11 @@ export const TestResultView: React.FC<{ })} } {!!result.steps.length && - {result.steps.map((step, i) => )} +
e.preventDefault()}> + {icons.search()} + setStepFilterText(e.target.value)} /> +
+ {result.steps.map((step, i) => )}
} {diffs.map((diff, index) => @@ -197,13 +204,22 @@ function pickDiffForError(error: string, diffs: ImageDiff[]): ImageDiff | undefi return diffs.find(diff => error.includes(diff.name)); } +function stepMatchesFilter(step: TestStep, filterText: string): boolean { + if (step.title.toLowerCase().includes(filterText.toLowerCase())) + return true; + return step.steps.some(s => stepMatchesFilter(s, filterText)); +} + const StepTreeItem: React.FC<{ test: TestCase; result: TestResult; step: TestStep; depth: number, -}> = ({ test, step, result, depth }) => { + filterText?: string, +}> = ({ test, step, result, depth, filterText }) => { const searchParams = useSearchParams(); + if (filterText && !stepMatchesFilter(step, filterText)) + return null; return {statusIcon(step.error || step.duration === -1 ? 'failed' : (step.skipped ? 'skipped' : 'passed'))} @@ -222,9 +238,9 @@ const StepTreeItem: React.FC<{ {msToString(step.duration)} } loadChildren={step.steps.length || step.snippet ? () => { const snippet = step.snippet ? [] : []; - const steps = step.steps.map((s, i) => ); + const steps = step.steps.map((s, i) => ); return snippet.concat(steps); - } : undefined} depth={depth}/>; + } : undefined} depth={depth} expandByDefault={!!filterText}/>; }; type WorkerLists = Map; diff --git a/packages/html-reporter/src/treeItem.tsx b/packages/html-reporter/src/treeItem.tsx index 3e06d7087a0e3..924dfe7053ecd 100644 --- a/packages/html-reporter/src/treeItem.tsx +++ b/packages/html-reporter/src/treeItem.tsx @@ -29,6 +29,9 @@ export const TreeItem: React.FunctionComponent<{ flash?: boolean }> = ({ title, loadChildren, onClick, expandByDefault, depth, style, flash }) => { const [expanded, setExpanded] = React.useState(expandByDefault || false); + React.useEffect(() => { + setExpanded(expandByDefault || false); + }, [expandByDefault]); return
{ onClick?.(); setExpanded(!expanded); }} > {loadChildren && !!expanded && icons.downArrow()} diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index e2dbfadef157b..0814f905c216b 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -822,6 +822,47 @@ for (const useIntermediateMergeReport of [true, false] as const) { ]); }); + test('should filter steps', async ({ runInlineTest, page, showReport }) => { + const result = await runInlineTest({ + 'a.test.js': ` + import { test, expect } from '@playwright/test'; + test('has steps', async ({}) => { + await test.step('click button', async () => {}); + await test.step('fill form', async () => { + await test.step('fill username', async () => {}); + await test.step('fill password', async () => {}); + }); + await test.step('submit form', async () => {}); + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(1); + + await showReport(); + await page.getByRole('link', { name: 'has steps' }).click(); + + const filterInput = page.getByLabel('Filter steps'); + await expect(filterInput).toBeVisible(); + + // filter matching a subset of steps + await filterInput.fill('fill'); + await expect(page.locator('.tree-item-title', { hasText: 'fill form' })).toBeVisible(); + await expect(page.locator('.tree-item-title', { hasText: 'click button' })).toBeHidden(); + await expect(page.locator('.tree-item-title', { hasText: 'submit form' })).toBeHidden(); + // matching parent is auto-expanded to show matching children (like trace viewer) + await expect(page.locator('.tree-item-title', { hasText: 'fill username' })).toBeVisible(); + await expect(page.locator('.tree-item-title', { hasText: 'fill password' })).toBeVisible(); + + // clear filter restores all steps collapsed + await filterInput.clear(); + await expect(page.locator('.tree-item-title', { hasText: 'click button' })).toBeVisible(); + await expect(page.locator('.tree-item-title', { hasText: 'submit form' })).toBeVisible(); + // children of fill form are collapsed again after clearing the filter + await expect(page.locator('.tree-item-title', { hasText: 'fill username' })).toBeHidden(); + await expect(page.locator('.tree-item-title', { hasText: 'fill password' })).toBeHidden(); + }); + test('should show step snippets from non-root', async ({ runInlineTest, page, showReport }) => { const result = await runInlineTest({ 'playwright.config.js': `