From f8d776b89b9518f71b17582aa0924eb08f0df788 Mon Sep 17 00:00:00 2001 From: Emanuele Minotto Date: Wed, 11 Mar 2026 20:36:19 +0100 Subject: [PATCH] feat(html-reporter): add step filter in test steps section Adds a search input to the Test Steps in the HTML reporter, allowing users to filter the steps tree by title text. Steps that don't match (and have no matching descendants) are hidden. --- packages/html-reporter/src/testResultView.css | 4 ++ packages/html-reporter/src/testResultView.tsx | 24 +++++++++-- packages/html-reporter/src/treeItem.tsx | 3 ++ tests/playwright-test/reporter-html.spec.ts | 41 +++++++++++++++++++ 4 files changed, 68 insertions(+), 4 deletions(-) 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': `