Skip to content

Commit 380298e

Browse files
committed
Rewrite search and filtering
1 parent f7e4496 commit 380298e

23 files changed

Lines changed: 417 additions & 552 deletions

src/SearchContext.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { TestStepResultStatus as Status } from '@cucumber/messages'
2+
import type { Node } from '@cucumber/tag-expressions'
23
import { createContext } from 'react'
34

45
export interface SearchState {
@@ -8,12 +9,16 @@ export interface SearchState {
89

910
export interface SearchContextValue extends SearchState {
1011
unchanged: boolean
12+
searchTerm: string | undefined
13+
tagExpression: Node | undefined
1114
update: (changes: Partial<SearchState>) => void
1215
}
1316

1417
export default createContext<SearchContextValue>({
1518
query: '',
1619
hideStatuses: [],
1720
unchanged: true,
21+
searchTerm: undefined,
22+
tagExpression: undefined,
1823
update: () => {},
1924
})

src/components/app/ControlledSearchProvider.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { parse } from '@cucumber/tag-expressions'
12
import { type FC, type PropsWithChildren, useMemo } from 'react'
23

4+
import isTagExpression from '../../isTagExpression.js'
35
import SearchQueryContext, {
46
type SearchContextValue,
57
type SearchState,
@@ -17,10 +19,13 @@ export const ControlledSearchProvider: FC<PropsWithChildren<Props>> = ({
1719
}) => {
1820
const contextValue: SearchContextValue = useMemo(() => {
1921
const unchanged = !value.query && !value.hideStatuses.length
22+
const isTag = !!value.query && isTagExpression(value.query)
2023
return {
2124
query: value.query,
2225
hideStatuses: value.hideStatuses,
2326
unchanged,
27+
searchTerm: value.query && !isTag ? value.query : undefined,
28+
tagExpression: isTag ? parse(value.query) : undefined,
2429
update: (newValues: Partial<SearchState>) => {
2530
onChange({ ...value, ...newValues })
2631
},

src/components/app/FilteredDocuments.spec.tsx

Lines changed: 177 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import attachments from '../../../acceptance/attachments/attachments.js'
77
import examplesTables from '../../../acceptance/examples-tables/examples-tables.js'
88
import hooksConditional from '../../../acceptance/hooks-conditional/hooks-conditional.js'
99
import retry from '../../../acceptance/retry/retry.js'
10+
import rules from '../../../acceptance/rules/rules.js'
1011
import randomOrderRun from '../../../samples/random-order-run.js'
1112
import targetedRun from '../../../samples/targeted-run.js'
1213
import { EnvelopesProvider } from './EnvelopesProvider.js'
@@ -94,8 +95,130 @@ describe('FilteredDocuments', () => {
9495
})
9596
})
9697

98+
describe('filtering by tag expression', () => {
99+
it('shows no results based on a tag expression that doesnt match anything', async () => {
100+
const { getByText } = render(
101+
<EnvelopesProvider envelopes={hooksConditional}>
102+
<InMemorySearchProvider defaultQuery="@nonexistent">
103+
<FilteredDocuments />
104+
</InMemorySearchProvider>
105+
</EnvelopesProvider>
106+
)
107+
108+
await waitFor(() => {
109+
expect(getByText('No scenarios match your query and/or filters.')).to.be.visible
110+
})
111+
})
112+
113+
it('matches based on a single tag', async () => {
114+
const { getByRole, queryByRole } = render(
115+
<EnvelopesProvider envelopes={hooksConditional}>
116+
<InMemorySearchProvider defaultQuery="@fail-before">
117+
<FilteredDocuments />
118+
</InMemorySearchProvider>
119+
</EnvelopesProvider>
120+
)
121+
122+
await waitFor(() =>
123+
getByRole('heading', {
124+
name: 'Scenario: A failure in the before hook and a skipped step',
125+
})
126+
)
127+
128+
expect(
129+
queryByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
130+
).not.to.exist
131+
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
132+
.to.exist
133+
})
134+
135+
it('matches based on an or expression', async () => {
136+
const { getByRole, queryByRole } = render(
137+
<EnvelopesProvider envelopes={hooksConditional}>
138+
<InMemorySearchProvider defaultQuery="@fail-before or @fail-after">
139+
<FilteredDocuments />
140+
</InMemorySearchProvider>
141+
</EnvelopesProvider>
142+
)
143+
144+
await waitFor(() =>
145+
getByRole('heading', {
146+
name: 'Scenario: A failure in the before hook and a skipped step',
147+
})
148+
)
149+
150+
expect(
151+
getByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
152+
).to.be.visible
153+
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
154+
.to.exist
155+
})
156+
157+
it('matches based on a negated expression', async () => {
158+
const { getByRole, queryByRole } = render(
159+
<EnvelopesProvider envelopes={hooksConditional}>
160+
<InMemorySearchProvider defaultQuery="not @passing-hook">
161+
<FilteredDocuments />
162+
</InMemorySearchProvider>
163+
</EnvelopesProvider>
164+
)
165+
166+
await waitFor(() =>
167+
getByRole('heading', {
168+
name: 'Scenario: A failure in the before hook and a skipped step',
169+
})
170+
)
171+
172+
expect(
173+
getByRole('heading', { name: 'Scenario: A failure in the after hook and a passed step' })
174+
).to.be.visible
175+
expect(queryByRole('heading', { name: 'Scenario: With an tag, a passed step and hook' })).not
176+
.to.exist
177+
})
178+
179+
it('matches based on tags inherited from rule', async () => {
180+
const { getByRole, queryByRole } = render(
181+
<EnvelopesProvider envelopes={rules}>
182+
<InMemorySearchProvider defaultQuery="@some-tag">
183+
<FilteredDocuments />
184+
</InMemorySearchProvider>
185+
</EnvelopesProvider>
186+
)
187+
188+
await waitFor(() => getByRole('button', { name: 'samples/rules/rules.feature' }))
189+
await userEvent.click(getByRole('button', { name: 'samples/rules/rules.feature' }))
190+
191+
await waitFor(() => getByRole('heading', { name: 'Example: No chocolates left' }))
192+
193+
// scenario inside rule with @some-tag is shown
194+
expect(getByRole('heading', { name: 'Example: No chocolates left' })).to.be.visible
195+
// scenarios inside rule without @some-tag are excluded
196+
expect(queryByRole('heading', { name: 'Example: Not enough money' })).not.to.exist
197+
expect(queryByRole('heading', { name: 'Example: Enough money' })).not.to.exist
198+
})
199+
200+
it('matches based on tags inherited from examples table', async () => {
201+
const { getByRole, queryByRole } = render(
202+
<EnvelopesProvider envelopes={examplesTables}>
203+
<InMemorySearchProvider defaultQuery="@passing">
204+
<FilteredDocuments />
205+
</InMemorySearchProvider>
206+
</EnvelopesProvider>
207+
)
208+
209+
await waitFor(() => getByRole('heading', { name: 'Examples: These are passing' }))
210+
211+
// examples with @passing tag are shown
212+
expect(getByRole('heading', { name: 'Then I should have 7 cucumbers' })).to.be.visible
213+
expect(getByRole('heading', { name: 'Then I should have 15 cucumbers' })).to.be.visible
214+
// examples with @failing tag (not matching) should not be visible
215+
expect(queryByRole('heading', { name: 'When I eat 20 cucumbers' })).not.to.exist
216+
expect(queryByRole('heading', { name: 'When I eat 1 cucumbers' })).not.to.exist
217+
})
218+
})
219+
97220
describe('filtering by status', () => {
98-
it('should show a message if we filter all statuses out', async () => {
221+
it('shows no results when all statuses are hidden', async () => {
99222
const { queryByRole, getByText } = render(
100223
<EnvelopesProvider envelopes={examplesTables}>
101224
<InMemorySearchProvider
@@ -120,7 +243,7 @@ describe('FilteredDocuments', () => {
120243
})
121244
})
122245

123-
it('shows only passed scenarios when other statuses are hidden', async () => {
246+
it('shows only passed scenarios', async () => {
124247
const { getByRole, getByText, queryByText } = render(
125248
<EnvelopesProvider envelopes={retry}>
126249
<InMemorySearchProvider
@@ -150,7 +273,7 @@ describe('FilteredDocuments', () => {
150273
.exist
151274
})
152275

153-
it('shows only failed scenarios when other statuses are hidden', async () => {
276+
it('shows only failed scenarios', async () => {
154277
const { getByRole, queryByRole, getByText } = render(
155278
<EnvelopesProvider envelopes={retry}>
156279
<InMemorySearchProvider
@@ -188,7 +311,7 @@ describe('FilteredDocuments', () => {
188311
).not.to.exist
189312
})
190313

191-
it('shows scenarios matching any of multiple statuses', async () => {
314+
it('shows scenarios matching multiple statuses', async () => {
192315
const { getByRole, getByText } = render(
193316
<EnvelopesProvider envelopes={retry}>
194317
<InMemorySearchProvider
@@ -217,6 +340,25 @@ describe('FilteredDocuments', () => {
217340
.visible
218341
})
219342

343+
it('shows only matching examples within a scenario outline', async () => {
344+
const { getByRole, queryByRole } = render(
345+
<EnvelopesProvider envelopes={examplesTables}>
346+
<InMemorySearchProvider defaultHideStatuses={[TestStepResultStatus.FAILED]}>
347+
<FilteredDocuments />
348+
</InMemorySearchProvider>
349+
</EnvelopesProvider>
350+
)
351+
352+
await waitFor(() => getByRole('heading', { name: 'Scenario Outline: Eating cucumbers' }))
353+
354+
// passing examples should be visible
355+
expect(getByRole('heading', { name: 'Then I should have 7 cucumbers' })).to.be.visible
356+
expect(getByRole('heading', { name: 'Then I should have 15 cucumbers' })).to.be.visible
357+
// failing examples should not be visible
358+
expect(queryByRole('heading', { name: 'When I eat 20 cucumbers' })).not.to.exist
359+
expect(queryByRole('heading', { name: 'When I eat 1 cucumbers' })).not.to.exist
360+
})
361+
220362
it('treats scenarios with failed before hooks as failed', async () => {
221363
const { getByRole, getByText, queryByText } = render(
222364
<EnvelopesProvider envelopes={hooksConditional}>
@@ -277,4 +419,35 @@ describe('FilteredDocuments', () => {
277419
expect(queryByText('With an tag, a passed step and hook')).not.to.exist
278420
})
279421
})
422+
423+
describe('searching and filtering together', () => {
424+
it('narrows results with both text search and status filter', async () => {
425+
const { getByRole, getByText, queryByText } = render(
426+
<EnvelopesProvider envelopes={retry}>
427+
<InMemorySearchProvider
428+
defaultQuery="always"
429+
defaultHideStatuses={[TestStepResultStatus.FAILED]}
430+
>
431+
<FilteredDocuments />
432+
</InMemorySearchProvider>
433+
</EnvelopesProvider>
434+
)
435+
436+
await waitFor(() => getByRole('button', { name: 'samples/retry/retry.feature' }))
437+
await userEvent.click(getByRole('button', { name: 'samples/retry/retry.feature' }))
438+
439+
await waitFor(() => getByText("Test cases that pass aren't retried"))
440+
441+
// scenario 1 matches both filters (step has "always", status is PASSED)
442+
expect(getByText("Test cases that pass aren't retried")).to.be.visible
443+
// scenarios 2 and 3 don't match text search (no "always" in steps)
444+
expect(queryByText('Test cases that fail are retried if within the --retry limit')).not.to
445+
.exist
446+
expect(queryByText('Test cases that fail will continue to retry up to the --retry limit')).not
447+
.to.exist
448+
// scenario 4 matches text search but not status filter (it's FAILED)
449+
expect(queryByText("Test cases won't retry after failing more than the --retry limit")).not.to
450+
.exist
451+
})
452+
})
280453
})

src/hooks/useFilteredDocuments.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,67 @@
11
import { GherkinDocumentWalker, rejectAllFilters } from '@cucumber/gherkin-utils'
2-
import type { GherkinDocument, TestStepResultStatus } from '@cucumber/messages'
3-
import type { Query } from '@cucumber/query'
2+
import type { GherkinDocument } from '@cucumber/messages'
43
import { useEffect, useState } from 'react'
54

6-
import { createSearch, type Searchable } from '../search/index.js'
7-
import { useQueries } from './useQueries.js'
5+
import {
6+
type FilterableTestCase,
7+
useFilteredTestCasesStarted,
8+
} from './useFilteredTestCasesStarted.js'
9+
import { useGherkinDocuments } from './useGherkinDocuments.js'
810
import { useSearch } from './useSearch.js'
11+
import { useSearchIndex } from './useSearchIndex.js'
912

1013
export function useFilteredDocuments(): {
1114
results: GherkinDocument[] | undefined
1215
filtered: boolean
1316
} {
14-
const { query, hideStatuses, unchanged } = useSearch()
15-
const { gherkinQuery, cucumberQuery } = useQueries()
16-
const [searchable, setSearchable] = useState<Searchable>()
17+
const { searchTerm, unchanged } = useSearch()
18+
const gherkinDocuments = useGherkinDocuments()
19+
const searchIndex = useSearchIndex()
20+
const filteredTestCases = useFilteredTestCasesStarted()
1721
const [results, setResults] = useState<GherkinDocument[]>()
1822
useEffect(() => {
19-
createSearch(gherkinQuery).then((created) => setSearchable(created))
20-
}, [gherkinQuery])
23+
if (!searchTerm) {
24+
setResults(filterAndSort(gherkinDocuments, filteredTestCases))
25+
}
26+
}, [searchTerm, gherkinDocuments, filteredTestCases])
2127
useEffect(() => {
22-
if (!searchable) {
23-
return
28+
if (searchTerm && searchIndex) {
29+
const allowedUris = new Set(filteredTestCases.map(({ pickle }) => pickle.uri))
30+
searchIndex.search(searchTerm, allowedUris).then((searched) => {
31+
setResults(filterAndSort(searched, filteredTestCases))
32+
})
2433
}
25-
searchable.search(query).then((searched) => {
26-
const filtered = filterByStatus(searched, hideStatuses, cucumberQuery)
27-
const sorted = sortByUri(filtered)
28-
setResults(sorted)
29-
})
30-
}, [query, hideStatuses, cucumberQuery, searchable])
34+
}, [searchTerm, searchIndex, filteredTestCases])
3135
return {
3236
results,
3337
filtered: !unchanged,
3438
}
3539
}
3640

37-
function filterByStatus(
41+
function filterAndSort(
3842
searched: ReadonlyArray<GherkinDocument>,
39-
hideStatuses: ReadonlyArray<TestStepResultStatus>,
40-
query: Query
43+
filteredTestCases: ReadonlyArray<FilterableTestCase>
44+
) {
45+
return sortByUri(applyFilters(searched, filteredTestCases))
46+
}
47+
48+
/**
49+
* Filters Gherkin documents to only include content that is present in executed test cases
50+
* after filters for tag expression and status have been applied.
51+
*
52+
* The GherkinDocumentWalker traverses each document and produces an abridged copy
53+
* containing only the scenarios whose IDs appear in our filtered set. Rules, Features and
54+
* ultimately GherkinDocuments with no matching scenarios are excluded entirely.
55+
*/
56+
function applyFilters(
57+
searched: ReadonlyArray<GherkinDocument>,
58+
filteredTestCases: ReadonlyArray<FilterableTestCase>
4159
): ReadonlyArray<GherkinDocument> {
60+
const scenarioIds = new Set(filteredTestCases.flatMap(({ pickle }) => pickle.astNodeIds))
61+
4262
const walker = new GherkinDocumentWalker({
4363
...rejectAllFilters,
44-
acceptScenario: (scenario) => {
45-
return query
46-
.findAllTestCaseStarted()
47-
.filter((started) => query.findLineageBy(started)?.scenario?.id === scenario.id)
48-
.map((started) => query.findMostSevereTestStepResultBy(started)?.status)
49-
.some((status) => !hideStatuses.includes(status as TestStepResultStatus))
50-
},
64+
acceptScenario: (scenario) => scenarioIds.has(scenario.id),
5165
})
5266

5367
return searched

0 commit comments

Comments
 (0)