diff --git a/packages/html-reporter/src/filter.ts b/packages/html-reporter/src/filter.ts index 280c9a566b..135336e869 100644 --- a/packages/html-reporter/src/filter.ts +++ b/packages/html-reporter/src/filter.ts @@ -15,12 +15,18 @@ */ import type { TestCaseSummary } from './types'; + +type FilterToken = { + name: string; + not: boolean; +}; + export class Filter { - project: string[] = []; - status: string[] = []; - text: string[] = []; - labels: string[] = []; - annotations: string[] = []; + project: FilterToken[] = []; + status: FilterToken[] = []; + text: FilterToken[] = []; + labels: FilterToken[] = []; + annotations: FilterToken[] = []; empty(): boolean { return this.project.length + this.status.length + this.text.length === 0; @@ -28,29 +34,33 @@ export class Filter { static parse(expression: string): Filter { const tokens = Filter.tokenize(expression); - const project = new Set(); - const status = new Set(); - const text: string[] = []; - const labels = new Set(); - const annotations = new Set(); - for (const token of tokens) { + const project = new Set(); + const status = new Set(); + const text: FilterToken[] = []; + const labels = new Set(); + const annotations = new Set(); + for (let token of tokens) { + const not = token.startsWith('!'); + if (not) + token = token.slice(1); + if (token.startsWith('p:')) { - project.add(token.slice(2)); + project.add({ name: token.slice(2), not }); continue; } if (token.startsWith('s:')) { - status.add(token.slice(2)); + status.add({ name: token.slice(2), not }); continue; } if (token.startsWith('@')) { - labels.add(token); + labels.add({ name: token, not }); continue; } if (token.startsWith('annot:')) { - annotations.add(token.slice('annot:'.length)); + annotations.add({ name: token.slice('annot:'.length), not }); continue; } - text.push(token.toLowerCase()); + text.push({ name: token.toLowerCase(), not }); } const filter = new Filter(); @@ -106,12 +116,18 @@ export class Filter { matches(test: TestCaseSummary): boolean { const searchValues = cacheSearchValues(test); if (this.project.length) { - const matches = !!this.project.find(p => searchValues.project.includes(p)); + const matches = !!this.project.find(p => { + const match = searchValues.project.includes(p.name); + return p.not ? !match : match; + }); if (!matches) return false; } if (this.status.length) { - const matches = !!this.status.find(s => searchValues.status.includes(s)); + const matches = !!this.status.find(s => { + const match = searchValues.status.includes(s.name); + return s.not ? !match : match; + }); if (!matches) return false; } else { @@ -119,23 +135,32 @@ export class Filter { return false; } if (this.text.length) { - for (const text of this.text) { - if (searchValues.text.includes(text)) - continue; - const [fileName, line, column] = text.split(':'); + const matches = this.text.every(text => { + if (searchValues.text.includes(text.name)) + return text.not ? false : true; + + const [fileName, line, column] = text.name.split(':'); if (searchValues.file.includes(fileName) && searchValues.line === line && (column === undefined || searchValues.column === column)) - continue; + return text.not ? false : true; + + return text.not ? true : false; + }); + if (!matches) return false; - } } if (this.labels.length) { - const matches = this.labels.every(l => searchValues.labels.includes(l)); + const matches = this.labels.every(l => { + const match = searchValues.labels.includes(l.name); + return l.not ? !match : match; + }); if (!matches) return false; } if (this.annotations.length) { - const matches = this.annotations.every(annotation => - searchValues.annotations.some(a => a.includes(annotation))); + const matches = this.annotations.every(annotation => { + const match = searchValues.annotations.some(a => a.includes(annotation.name)); + return annotation.not ? !match : match; + }); if (!matches) return false; } diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index 18d23de7c8..fc353a233e 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2010,7 +2010,7 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.test-file-test')).toHaveCount(2); await expect(page.locator('.chip', { hasText: 'a.test.js' })).toHaveCount(1); await expect(page.locator('.chip', { hasText: 'b.test.js' })).toHaveCount(1); - await expect(page.locator('.test-file-test .test-file-title')).toHaveCount(2); + await expect(page.locator('.test-file-test .test-file-title')).toHaveText(['@smoke fails', '@smoke passes']); await expect(searchInput).toHaveValue('@smoke '); await expect(page).toHaveURL(/%40smoke/); @@ -2019,9 +2019,18 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.test-file-test')).toHaveCount(2); await expect(page.locator('.chip', { hasText: 'a.test.js' })).toHaveCount(1); await expect(page.locator('.chip', { hasText: 'b.test.js' })).toHaveCount(1); - await expect(page.locator('.test-file-test .test-file-title')).toHaveCount(2); + await expect(page.locator('.test-file-test .test-file-title')).toHaveText(['@regression fails', '@regression passes']); await expect(searchInput).toHaveValue('@regression '); await expect(page).toHaveURL(/%40regression/); + + await searchInput.fill('!@regression'); + await searchInput.press('Enter'); + await expect(page.locator('.test-file-test')).toHaveCount(2); + await expect(page.locator('.chip', { hasText: 'a.test.js' })).toHaveCount(1); + await expect(page.locator('.chip', { hasText: 'b.test.js' })).toHaveCount(1); + await expect(page.locator('.test-file-test .test-file-title')).toHaveText(['@smoke fails', '@smoke passes']); + await expect(searchInput).toHaveValue('!@regression '); + await expect(page).toHaveURL(/%21%40regression/); }); test('if label contains similar words only one label should be selected', async ({ runInlineTest, showReport, page }) => { @@ -2421,13 +2430,33 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.getByText('file-a.test.js', { exact: true })).toBeVisible(); await expect(page.getByText('a test 1')).toBeVisible(); await expect(page.getByText('a test 2')).toBeVisible(); - await expect(page.getByText('file-b.test.js', { exact: true })).not.toBeVisible(); - await expect(page.getByText('b test 1')).not.toBeVisible(); - await expect(page.getByText('b test 2')).not.toBeVisible(); + await expect(page.getByText('file-b.test.js', { exact: true })).toBeHidden(); + await expect(page.getByText('b test 1')).toBeHidden(); + await expect(page.getByText('b test 2')).toBeHidden(); + + await searchInput.fill('!file-a'); + await expect(page.getByText('file-a.test.js', { exact: true })).toBeHidden(); + await expect(page.getByText('a test 1')).toBeHidden(); + await expect(page.getByText('a test 2')).toBeHidden(); + await expect(page.getByText('file-b.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('b test 1')).toBeVisible(); + await expect(page.getByText('b test 2')).toBeVisible(); await searchInput.fill('file-a:3'); + await expect(page.getByText('file-a.test.js', { exact: true })).toBeVisible(); await expect(page.getByText('a test 1')).toBeVisible(); - await expect(page.getByText('a test 2')).not.toBeVisible(); + await expect(page.getByText('a test 2')).toBeHidden(); + await expect(page.getByText('file-b.test.js', { exact: true })).toBeHidden(); + await expect(page.getByText('b test 1')).toBeHidden(); + await expect(page.getByText('b test 2')).toBeHidden(); + + await searchInput.fill('!file-a:3'); + await expect(page.getByText('file-a.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('a test 1')).toBeHidden(); + await expect(page.getByText('a test 2')).toBeVisible(); + await expect(page.getByText('file-b.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('b test 1')).toBeVisible(); + await expect(page.getByText('b test 2')).toBeVisible(); }); test('tests should filter by status', async ({ runInlineTest, showReport, page }) => { @@ -2449,17 +2478,22 @@ for (const useIntermediateMergeReport of [true, false] as const) { await searchInput.fill('s:failed'); await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); - await expect(page.getByText('failed title')).not.toBeVisible(); + await expect(page.getByText('failed title')).toBeHidden(); await expect(page.getByText('passes title')).toBeVisible(); + + await searchInput.fill('!s:failed'); + await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('failed title')).toBeVisible(); + await expect(page.getByText('passes title')).toBeHidden(); }); test('tests should filter by annotation texts', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'a.test.js': ` const { test, expect } = require('@playwright/test'); - test('annotated test',{ annotation :[{type:'key',description:'value'}]}, async ({}) => {expect(1).toBe(1);}); + test('with annotation',{ annotation :[{type:'key',description:'value'}]}, async ({}) => {expect(1).toBe(1);}); test('slow test', () => { test.slow(); }); - test('non-annotated test', async ({}) => {expect(1).toBe(2);}); + test('without annotation', async ({}) => {expect(1).toBe(2);}); `, }, { reporter: 'dot,html' }, { PW_TEST_HTML_REPORT_OPEN: 'never' }); @@ -2474,15 +2508,29 @@ for (const useIntermediateMergeReport of [true, false] as const) { await test.step('filter by type and value', async () => { await searchInput.fill('annot:key=value'); await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); - await expect(page.getByText('non-annotated test')).not.toBeVisible(); - await expect(page.getByText('annotated test')).toBeVisible(); + await expect(page.getByText('without annotation')).toBeHidden(); + await expect(page.getByText('with annotation')).toBeVisible(); + }); + + await test.step('NOT filter by type and value', async () => { + await searchInput.fill('!annot:key=value'); + await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('without annotation')).toBeVisible(); + await expect(page.getByText('with annotation')).toBeHidden(); }); await test.step('filter by type', async () => { await searchInput.fill('annot:key'); await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); - await expect(page.getByText('non-annotated test')).not.toBeVisible(); - await expect(page.getByText('annotated test')).toBeVisible(); + await expect(page.getByText('without annotation')).toBeHidden(); + await expect(page.getByText('with annotation')).toBeVisible(); + }); + + await test.step('NOT filter by type', async () => { + await searchInput.fill('!annot:key'); + await expect(page.getByText('a.test.js', { exact: true })).toBeVisible(); + await expect(page.getByText('without annotation')).toBeVisible(); + await expect(page.getByText('with annotation')).toBeHidden(); }); await test.step('filter by result annotation', async () => { @@ -2516,9 +2564,17 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.getByText('a.test.js:3', { exact: true })).toBeVisible(); await expect(page.getByText('a.test.js:4', { exact: true })).toBeHidden(); + await searchInput.fill('!a.test.js:3'); + await expect(page.getByText('a.test.js:3', { exact: true })).toBeHidden(); + await expect(page.getByText('a.test.js:4', { exact: true })).toBeVisible(); + await searchInput.fill('a.test.js:4:15'); await expect(page.getByText('a.test.js:3', { exact: true })).toBeHidden(); await expect(page.getByText('a.test.js:4', { exact: true })).toBeVisible(); + + await searchInput.fill('!a.test.js:4:15'); + await expect(page.getByText('a.test.js:3', { exact: true })).toBeVisible(); + await expect(page.getByText('a.test.js:4', { exact: true })).toBeHidden(); }); test('should properly display beforeEach with and without title', async ({ runInlineTest, showReport, page }) => {