Skip to content
Draft
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
- Stop errors and attachments getting too wide ([#427](https://github.com/cucumber/react-components/pull/427))
- Handle base64-encoded attachments with unicode characters ([#486](https://github.com/cucumber/react-components/pull/486))
- Apply search and filters correctly for scenario outlines ([#490](https://github.com/cucumber/react-components/pull/490))

## [24.2.0] - 2026-01-31
### Changed
Expand Down
5 changes: 5 additions & 0 deletions src/SearchContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { TestStepResultStatus as Status } from '@cucumber/messages'
import type { Node } from '@cucumber/tag-expressions'
import { createContext } from 'react'

export interface SearchState {
Expand All @@ -8,12 +9,16 @@ export interface SearchState {

export interface SearchContextValue extends SearchState {
unchanged: boolean
searchTerm?: string
tagExpression?: Node
update: (changes: Partial<SearchState>) => void
}

export default createContext<SearchContextValue>({
query: '',
hideStatuses: [],
unchanged: true,
searchTerm: undefined,
tagExpression: undefined,
update: () => {},
})
21 changes: 21 additions & 0 deletions src/components/app/ControlledSearchProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { parse } from '@cucumber/tag-expressions'
import { type FC, type PropsWithChildren, useMemo } from 'react'

import isTagExpression from '../../isTagExpression.js'
import SearchQueryContext, {
type SearchContextValue,
type SearchState,
Expand All @@ -21,10 +23,29 @@ export const ControlledSearchProvider: FC<PropsWithChildren<Props>> = ({
query: value.query,
hideStatuses: value.hideStatuses,
unchanged,
...makeDerivedState(value.query),
update: (newValues: Partial<SearchState>) => {
onChange({ ...value, ...newValues })
},
}
}, [value, onChange])
return <SearchQueryContext.Provider value={contextValue}>{children}</SearchQueryContext.Provider>
}

function makeDerivedState(query: string): Pick<SearchContextValue, 'searchTerm' | 'tagExpression'> {
if (!query) {
return {}
}
if (isTagExpression(query)) {
try {
return {
tagExpression: parse(query),
}
} catch (error) {
console.error(`Failed to parse tag expression "${query}":`, error)
}
}
return {
searchTerm: query,
}
}
215 changes: 211 additions & 4 deletions src/components/app/FilteredDocuments.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import attachments from '../../../acceptance/attachments/attachments.js'
import examplesTables from '../../../acceptance/examples-tables/examples-tables.js'
import hooksConditional from '../../../acceptance/hooks-conditional/hooks-conditional.js'
import retry from '../../../acceptance/retry/retry.js'
import rules from '../../../acceptance/rules/rules.js'
import randomOrderRun from '../../../samples/random-order-run.js'
import targetedRun from '../../../samples/targeted-run.js'
import { EnvelopesProvider } from './EnvelopesProvider.js'
Expand Down Expand Up @@ -94,8 +95,130 @@ describe('FilteredDocuments', () => {
})
})

describe('filtering by tag expression', () => {
it('shows no results based on a tag expression that doesnt match anything', async () => {
const { getByText } = render(
<EnvelopesProvider envelopes={hooksConditional}>
<InMemorySearchProvider defaultQuery="@nonexistent">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => {
expect(getByText('No scenarios match your query and/or filters.')).to.be.visible
})
})

it('matches based on a single tag', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={hooksConditional}>
<InMemorySearchProvider defaultQuery="@fail-before">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() =>
getByRole('heading', {
name: 'Scenario: A failure in the before hook and a skipped step',
})
)

expect(
queryByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
).not.to.exist
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
.to.exist
})

it('matches based on an or expression', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={hooksConditional}>
<InMemorySearchProvider defaultQuery="@fail-before or @fail-after">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() =>
getByRole('heading', {
name: 'Scenario: A failure in the before hook and a skipped step',
})
)

expect(
getByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
).to.be.visible
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
.to.exist
})

it('matches based on a negated expression', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={hooksConditional}>
<InMemorySearchProvider defaultQuery="not @passing-hook">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() =>
getByRole('heading', {
name: 'Scenario: A failure in the before hook and a skipped step',
})
)

expect(
getByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
).to.be.visible
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
.to.exist
})

it('matches based on tags inherited from rule', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={rules}>
<InMemorySearchProvider defaultQuery="@some-tag">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => getByRole('button', { name: 'samples/rules/rules.feature' }))
await userEvent.click(getByRole('button', { name: 'samples/rules/rules.feature' }))

await waitFor(() => getByRole('heading', { name: 'Example: No chocolates left' }))

// scenario inside rule with @some-tag is shown
expect(getByRole('heading', { name: 'Example: No chocolates left' })).to.be.visible
// scenarios inside rule without @some-tag are excluded
expect(queryByRole('heading', { name: 'Example: Not enough money' })).not.to.exist
expect(queryByRole('heading', { name: 'Example: Enough money' })).not.to.exist
})

it('matches based on tags inherited from examples table', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={examplesTables}>
<InMemorySearchProvider defaultQuery="@passing">
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => getByRole('heading', { name: 'Examples: These are passing' }))

// examples with @passing tag are shown
expect(getByRole('heading', { name: 'Then I should have 7 cucumbers' })).to.be.visible
expect(getByRole('heading', { name: 'Then I should have 15 cucumbers' })).to.be.visible
// examples with @failing tag (not matching) should not be visible
expect(queryByRole('heading', { name: 'When I eat 20 cucumbers' })).not.to.exist
expect(queryByRole('heading', { name: 'When I eat 1 cucumbers' })).not.to.exist
})
})

describe('filtering by status', () => {
it('should show a message if we filter all statuses out', async () => {
it('shows no results when all statuses are hidden', async () => {
const { queryByRole, getByText } = render(
<EnvelopesProvider envelopes={examplesTables}>
<InMemorySearchProvider
Expand All @@ -120,7 +243,7 @@ describe('FilteredDocuments', () => {
})
})

it('shows only passed scenarios when other statuses are hidden', async () => {
it('shows only passed scenarios', async () => {
const { getByRole, getByText, queryByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
Expand Down Expand Up @@ -150,7 +273,7 @@ describe('FilteredDocuments', () => {
.exist
})

it('shows only failed scenarios when other statuses are hidden', async () => {
it('shows only failed scenarios', async () => {
const { getByRole, queryByRole, getByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
Expand Down Expand Up @@ -188,7 +311,7 @@ describe('FilteredDocuments', () => {
).not.to.exist
})

it('shows scenarios matching any of multiple statuses', async () => {
it('shows scenarios matching multiple statuses', async () => {
const { getByRole, getByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
Expand Down Expand Up @@ -217,6 +340,25 @@ describe('FilteredDocuments', () => {
.visible
})

it('shows only matching examples within a scenario outline', async () => {
const { getByRole, queryByRole } = render(
<EnvelopesProvider envelopes={examplesTables}>
<InMemorySearchProvider defaultHideStatuses={[TestStepResultStatus.FAILED]}>
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => getByRole('heading', { name: 'Scenario Outline: Eating cucumbers' }))

// passing examples should be visible
expect(getByRole('heading', { name: 'Then I should have 7 cucumbers' })).to.be.visible
expect(getByRole('heading', { name: 'Then I should have 15 cucumbers' })).to.be.visible
// failing examples should not be visible
expect(queryByRole('heading', { name: 'When I eat 20 cucumbers' })).not.to.exist
expect(queryByRole('heading', { name: 'When I eat 1 cucumbers' })).not.to.exist
})

it('treats scenarios with failed before hooks as failed', async () => {
const { getByRole, getByText, queryByText } = render(
<EnvelopesProvider envelopes={hooksConditional}>
Expand Down Expand Up @@ -277,4 +419,69 @@ describe('FilteredDocuments', () => {
expect(queryByText('With an tag, a passed step and hook')).not.to.exist
})
})

describe('searching and filtering together', () => {
it('narrows results with both text search and status filter', async () => {
const { getByRole, getByText, queryByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
defaultQuery="always"
defaultHideStatuses={[TestStepResultStatus.FAILED]}
>
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => getByRole('button', { name: 'samples/retry/retry.feature' }))
await userEvent.click(getByRole('button', { name: 'samples/retry/retry.feature' }))

await waitFor(() => getByText("Test cases that pass aren't retried"))

// scenario 1 matches both filters (step has "always", status is PASSED)
expect(getByText("Test cases that pass aren't retried")).to.be.visible
// scenarios 2 and 3 don't match text search (no "always" in steps)
expect(queryByText('Test cases that fail are retried if within the --retry limit')).not.to
.exist
expect(queryByText('Test cases that fail will continue to retry up to the --retry limit')).not
.to.exist
// scenario 4 matches text search but not status filter (it's FAILED)
expect(queryByText("Test cases won't retry after failing more than the --retry limit")).not.to
.exist
})

it('shows no results when text search matches but status filter excludes all', async () => {
const { getByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
defaultQuery="always"
defaultHideStatuses={[TestStepResultStatus.PASSED, TestStepResultStatus.FAILED]}
>
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => {
expect(getByText('No scenarios match your query and/or filters.')).to.be.visible
})
})

it('shows no results when status filter matches but text search excludes all', async () => {
const { getByText } = render(
<EnvelopesProvider envelopes={retry}>
<InMemorySearchProvider
defaultQuery="nonexistent"
defaultHideStatuses={[TestStepResultStatus.FAILED]}
>
<FilteredDocuments />
</InMemorySearchProvider>
</EnvelopesProvider>
)

await waitFor(() => {
expect(getByText('No scenarios match your query and/or filters.')).to.be.visible
})
})
})
})
Loading