diff --git a/CHANGELOG.md b/CHANGELOG.md index 8df208d7..9a7a55f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/SearchContext.ts b/src/SearchContext.ts index cf42f779..4b0faa32 100644 --- a/src/SearchContext.ts +++ b/src/SearchContext.ts @@ -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 { @@ -8,6 +9,8 @@ export interface SearchState { export interface SearchContextValue extends SearchState { unchanged: boolean + searchTerm?: string + tagExpression?: Node update: (changes: Partial) => void } @@ -15,5 +18,7 @@ export default createContext({ query: '', hideStatuses: [], unchanged: true, + searchTerm: undefined, + tagExpression: undefined, update: () => {}, }) diff --git a/src/components/app/ControlledSearchProvider.tsx b/src/components/app/ControlledSearchProvider.tsx index d9dc9fe9..12e32a42 100644 --- a/src/components/app/ControlledSearchProvider.tsx +++ b/src/components/app/ControlledSearchProvider.tsx @@ -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, @@ -21,6 +23,7 @@ export const ControlledSearchProvider: FC> = ({ query: value.query, hideStatuses: value.hideStatuses, unchanged, + ...makeDerivedState(value.query), update: (newValues: Partial) => { onChange({ ...value, ...newValues }) }, @@ -28,3 +31,21 @@ export const ControlledSearchProvider: FC> = ({ }, [value, onChange]) return {children} } + +function makeDerivedState(query: string): Pick { + 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, + } +} diff --git a/src/components/app/FilteredDocuments.spec.tsx b/src/components/app/FilteredDocuments.spec.tsx index 06cb7679..5cd2f7b9 100644 --- a/src/components/app/FilteredDocuments.spec.tsx +++ b/src/components/app/FilteredDocuments.spec.tsx @@ -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' @@ -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( + + + + + + ) + + 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( + + + + + + ) + + 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( + + + + + + ) + + 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( + + + + + + ) + + 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( + + + + + + ) + + 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( + + + + + + ) + + 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( { }) }) - it('shows only passed scenarios when other statuses are hidden', async () => { + it('shows only passed scenarios', async () => { const { getByRole, getByText, queryByText } = render( { .exist }) - it('shows only failed scenarios when other statuses are hidden', async () => { + it('shows only failed scenarios', async () => { const { getByRole, queryByRole, getByText } = render( { ).not.to.exist }) - it('shows scenarios matching any of multiple statuses', async () => { + it('shows scenarios matching multiple statuses', async () => { const { getByRole, getByText } = render( { .visible }) + it('shows only matching examples within a scenario outline', async () => { + const { getByRole, queryByRole } = render( + + + + + + ) + + 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( @@ -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( + + + + + + ) + + 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( + + + + + + ) + + 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( + + + + + + ) + + await waitFor(() => { + expect(getByText('No scenarios match your query and/or filters.')).to.be.visible + }) + }) + }) }) diff --git a/src/hooks/useFilteredDocuments.ts b/src/hooks/useFilteredDocuments.ts index ecc2d28c..77b84515 100644 --- a/src/hooks/useFilteredDocuments.ts +++ b/src/hooks/useFilteredDocuments.ts @@ -1,53 +1,70 @@ import { GherkinDocumentWalker, rejectAllFilters } from '@cucumber/gherkin-utils' -import type { GherkinDocument, TestStepResultStatus } from '@cucumber/messages' -import type { Query } from '@cucumber/query' +import type { GherkinDocument } from '@cucumber/messages' import { useEffect, useState } from 'react' -import { createSearch, type Searchable } from '../search/index.js' -import { useQueries } from './useQueries.js' +import { + type FilterableTestCase, + useFilteredTestCasesStarted, +} from './useFilteredTestCasesStarted.js' +import { useGherkinDocuments } from './useGherkinDocuments.js' import { useSearch } from './useSearch.js' +import { useSearchIndex } from './useSearchIndex.js' export function useFilteredDocuments(): { results: GherkinDocument[] | undefined filtered: boolean } { - const { query, hideStatuses, unchanged } = useSearch() - const { gherkinQuery, cucumberQuery } = useQueries() - const [searchable, setSearchable] = useState() + const { searchTerm, unchanged } = useSearch() + const gherkinDocuments = useGherkinDocuments() + const searchIndex = useSearchIndex() + const filteredTestCases = useFilteredTestCasesStarted() const [results, setResults] = useState() useEffect(() => { - createSearch(gherkinQuery).then((created) => setSearchable(created)) - }, [gherkinQuery]) + if (!searchTerm) { + setResults(filterAndSort(gherkinDocuments, filteredTestCases)) + } + }, [searchTerm, gherkinDocuments, filteredTestCases]) useEffect(() => { - if (!searchable) { - return + if (searchTerm && searchIndex) { + const allowedUris = new Set(filteredTestCases.map(({ pickle }) => pickle.uri)) + searchIndex + .search(searchTerm, allowedUris) + .then((searched) => { + setResults(filterAndSort(searched, filteredTestCases)) + }) + .catch((error) => console.error('Search failed:', error)) } - searchable.search(query).then((searched) => { - const filtered = filterByStatus(searched, hideStatuses, cucumberQuery) - const sorted = sortByUri(filtered) - setResults(sorted) - }) - }, [query, hideStatuses, cucumberQuery, searchable]) + }, [searchTerm, searchIndex, filteredTestCases]) return { results, filtered: !unchanged, } } -function filterByStatus( +function filterAndSort( searched: ReadonlyArray, - hideStatuses: ReadonlyArray, - query: Query + filteredTestCases: ReadonlyArray +) { + return sortByUri(applyFilters(searched, filteredTestCases)) +} + +/** + * Filters Gherkin documents to only include content that is present in executed test cases + * after filters for tag expression and status have been applied. + * + * The GherkinDocumentWalker traverses each document and produces an abridged copy + * containing only the scenarios whose IDs appear in our filtered set. Rules, Features and + * ultimately GherkinDocuments with no matching scenarios are excluded entirely. + */ +function applyFilters( + searched: ReadonlyArray, + filteredTestCases: ReadonlyArray ): ReadonlyArray { + const scenarioIds = new Set(filteredTestCases.flatMap(({ pickle }) => pickle.astNodeIds)) + const walker = new GherkinDocumentWalker({ ...rejectAllFilters, - acceptScenario: (scenario) => { - return query - .findAllTestCaseStarted() - .filter((started) => query.findLineageBy(started)?.scenario?.id === scenario.id) - .map((started) => query.findMostSevereTestStepResultBy(started)?.status) - .some((status) => !hideStatuses.includes(status as TestStepResultStatus)) - }, + acceptScenario: (scenario) => scenarioIds.has(scenario.id), }) return searched diff --git a/src/hooks/useFilteredTestCasesStarted.ts b/src/hooks/useFilteredTestCasesStarted.ts new file mode 100644 index 00000000..940bf22e --- /dev/null +++ b/src/hooks/useFilteredTestCasesStarted.ts @@ -0,0 +1,45 @@ +import { type Pickle, type TestCaseStarted, TestStepResultStatus } from '@cucumber/messages' +import { useMemo } from 'react' + +import { ensure } from './helpers.js' +import { useQueries } from './useQueries.js' +import { useSearch } from './useSearch.js' + +export interface FilterableTestCase { + testCaseStarted: TestCaseStarted + pickle: Pickle +} + +/** + * Pairs each TestCaseStarted with its originating Pickle, and filters based on tag expression + * and status from the search state + */ +export function useFilteredTestCasesStarted(): ReadonlyArray { + const { tagExpression, hideStatuses } = useSearch() + const { cucumberQuery } = useQueries() + + return useMemo(() => { + return cucumberQuery + .findAllTestCaseStarted() + .map((testCaseStarted) => { + const pickle = ensure( + cucumberQuery.findPickleBy(testCaseStarted), + `No Pickle found for TestCaseStarted ${testCaseStarted.id}` + ) + return { testCaseStarted, pickle } + }) + .filter(({ testCaseStarted, pickle }) => { + const status = + cucumberQuery.findMostSevereTestStepResultBy(testCaseStarted)?.status ?? + TestStepResultStatus.UNKNOWN + if (hideStatuses.includes(status)) { + return false + } + if (tagExpression) { + const tags = pickle.tags.map((tag) => tag.name) + return tagExpression.evaluate(tags) + } + return true + }) + }, [cucumberQuery, hideStatuses, tagExpression]) +} diff --git a/src/hooks/useGherkinDocuments.ts b/src/hooks/useGherkinDocuments.ts new file mode 100644 index 00000000..877eb9f3 --- /dev/null +++ b/src/hooks/useGherkinDocuments.ts @@ -0,0 +1,12 @@ +import type { GherkinDocument } from '@cucumber/messages' +import { useMemo } from 'react' + +import { useQueries } from './useQueries.js' + +export function useGherkinDocuments(): ReadonlyArray { + const { gherkinQuery } = useQueries() + return useMemo(() => { + // this is a stable reference at time of writing, but that's a bug + return gherkinQuery.getGherkinDocuments() + }, [gherkinQuery]) +} diff --git a/src/hooks/useSearchIndex.ts b/src/hooks/useSearchIndex.ts new file mode 100644 index 00000000..4f6f2eab --- /dev/null +++ b/src/hooks/useSearchIndex.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from 'react' + +import { createSearchIndex, type SearchIndex } from '../search/index.js' +import { useGherkinDocuments } from './useGherkinDocuments.js' + +export function useSearchIndex(): SearchIndex | undefined { + const gherkinDocuments = useGherkinDocuments() + const [searchIndex, setSearchIndex] = useState() + + useEffect(() => { + createSearchIndex(gherkinDocuments) + .then((created) => setSearchIndex(created)) + .catch((error) => console.error('Failed to create search index:', error)) + }, [gherkinDocuments]) + + return searchIndex +} diff --git a/src/hooks/useTestCaseStarted.ts b/src/hooks/useTestCaseStarted.ts index 8acb7fad..fed992c3 100644 --- a/src/hooks/useTestCaseStarted.ts +++ b/src/hooks/useTestCaseStarted.ts @@ -1,12 +1,19 @@ import type { TestCaseStarted } from '@cucumber/messages' import { useMemo } from 'react' +import { useFilteredTestCasesStarted } from './useFilteredTestCasesStarted.js' import { useQueries } from './useQueries.js' +/** + * Finds the corresponding TestCaseStarted for an Example or Scenario node from a Gherkin + * document, unless filtered out based on tag expression or status + * @param nodeId the AST node id + */ export function useTestCaseStarted(nodeId: string): TestCaseStarted | undefined { const { cucumberQuery } = useQueries() + const filteredTestCases = useFilteredTestCasesStarted() const mapped = useMemo(() => { - return cucumberQuery.findAllTestCaseStarted().reduce((prev, testCaseStarted) => { + return filteredTestCases.reduce((prev, { testCaseStarted }) => { const lineage = cucumberQuery.findLineageBy(testCaseStarted) const closestNodeId = lineage?.example?.id ?? lineage?.scenario?.id if (closestNodeId) { @@ -14,6 +21,6 @@ export function useTestCaseStarted(nodeId: string): TestCaseStarted | undefined } return prev }, new Map()) - }, [cucumberQuery]) + }, [cucumberQuery, filteredTestCases]) return mapped.get(nodeId) } diff --git a/src/search/FeatureSearch.spec.ts b/src/search/FeatureSearch.spec.ts index d246b476..3715452c 100644 --- a/src/search/FeatureSearch.spec.ts +++ b/src/search/FeatureSearch.spec.ts @@ -2,7 +2,7 @@ import type { Feature, GherkinDocument } from '@cucumber/messages' import { expect } from 'chai' import { makeFeature } from '../../test/search.js' -import { createFeatureSearch } from './FeatureSearch.js' +import { FeatureSearch } from './FeatureSearch.js' import type { TypedIndex } from './types.js' describe('FeatureSearch', () => { @@ -10,7 +10,7 @@ describe('FeatureSearch', () => { let gherkinDocument: GherkinDocument beforeEach(async () => { - featureSearch = await createFeatureSearch() + featureSearch = new FeatureSearch() gherkinDocument = { uri: 'some/feature.file', comments: [], diff --git a/src/search/FeatureSearch.ts b/src/search/FeatureSearch.ts index e68df54b..c82288b2 100644 --- a/src/search/FeatureSearch.ts +++ b/src/search/FeatureSearch.ts @@ -12,12 +12,15 @@ const schema = { * A little different than the other indexes - a Feature doesn't have its own id, * so we use the uri of the GherkinDocument as a pointer */ -class FeatureSearch implements TypedIndex { +export class FeatureSearch implements TypedIndex { private readonly featuresByUri = new Map() private readonly index: Orama - constructor(index: Orama) { - this.index = index + constructor() { + this.index = create({ + schema, + sort: { enabled: false }, + }) } async search(term: string): Promise> { @@ -39,13 +42,3 @@ class FeatureSearch implements TypedIndex { return this } } - -export async function createFeatureSearch(): Promise> { - const index: Orama = await create({ - schema, - sort: { - enabled: false, - }, - }) - return new FeatureSearch(index) -} diff --git a/src/search/ScenarioLikeSearch.spec.ts b/src/search/ScenarioLikeSearch.spec.ts index 5d820430..748af874 100644 --- a/src/search/ScenarioLikeSearch.spec.ts +++ b/src/search/ScenarioLikeSearch.spec.ts @@ -2,7 +2,7 @@ import type { Scenario } from '@cucumber/messages' import { expect } from 'chai' import { makeScenario } from '../../test/search.js' -import { createScenarioLikeSearch } from './ScenarioLikeSearch.js' +import { ScenarioLikeSearch } from './ScenarioLikeSearch.js' import type { TypedIndex } from './types.js' describe('ScenarioLikeSearch', () => { @@ -10,7 +10,7 @@ describe('ScenarioLikeSearch', () => { let scenarios: Scenario[] beforeEach(async () => { - scenarioSearch = await createScenarioLikeSearch() + scenarioSearch = new ScenarioLikeSearch() scenarios = [ makeScenario('a passed scenario', 'a little description', []), diff --git a/src/search/ScenarioLikeSearch.ts b/src/search/ScenarioLikeSearch.ts index f451928e..09be919c 100644 --- a/src/search/ScenarioLikeSearch.ts +++ b/src/search/ScenarioLikeSearch.ts @@ -17,12 +17,15 @@ export interface ScenarioLike { * Can be used for Backgrounds, Scenarios and Rules, searching against the * name and description */ -class ScenarioLikeSearch implements TypedIndex { +export class ScenarioLikeSearch implements TypedIndex { private itemById = new Map() private readonly index: Orama - constructor(index: Orama) { - this.index = index + constructor() { + this.index = create({ + schema, + sort: { enabled: false }, + }) } async search(term: string): Promise> { @@ -42,15 +45,3 @@ class ScenarioLikeSearch implements TypedIndex { return this } } - -export async function createScenarioLikeSearch(): Promise< - ScenarioLikeSearch -> { - const index: Orama = await create({ - schema, - sort: { - enabled: false, - }, - }) - return new ScenarioLikeSearch(index) -} diff --git a/src/search/Search.spec.ts b/src/search/Search.spec.ts deleted file mode 100644 index bf58ac3a..00000000 --- a/src/search/Search.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { generateMessages } from '@cucumber/gherkin' -import { Query as GherkinQuery, pretty } from '@cucumber/gherkin-utils' -import { IdGenerator, SourceMediaType } from '@cucumber/messages' -import { expect } from 'chai' - -import { createSearch } from './Search.js' - -describe('Search', () => { - let gherkinQuery: GherkinQuery - - const feature = `Feature: Solar System - - @planet - Scenario: Earth - Given is the sixth planet from the Sun - - @dwarf - Scenario: Pluto - Given it is not really a planet -` - - beforeEach(() => { - gherkinQuery = new GherkinQuery() - }) - - async function prettyResults(feature: string, query: string): Promise { - const envelopes = generateMessages( - feature, - 'test.feature', - SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, - { - includeGherkinDocument: true, - includePickles: true, - includeSource: true, - newId: IdGenerator.incrementing(), - } - ) - for (const envelope of envelopes) { - gherkinQuery.update(envelope) - } - const search = await createSearch(gherkinQuery) - return pretty((await search.search(query))[0]) - } - - describe('search', () => { - describe('when using a tag expression query', () => { - it('uses TagSearch to filter the results', async () => { - const results = await prettyResults(feature, '@planet') - expect(results).to.eq( - `Feature: Solar System - - @planet - Scenario: Earth - Given is the sixth planet from the Sun -` - ) - }) - - it('does not raises error when tag expression is incorrect', async () => { - const results = await prettyResults(feature, '(@planet or @dwarf))') - expect(results).to.eq( - `Feature: Solar System - - @planet - Scenario: Earth - Given is the sixth planet from the Sun - - @dwarf - Scenario: Pluto - Given it is not really a planet -` - ) - }) - }) - - describe('when using a query which is not a tag expression', () => { - it('uses TextSearch to filter the results', async () => { - const results = await prettyResults(feature, 'not really') - expect(results).to.eq( - `Feature: Solar System - - @dwarf - Scenario: Pluto - Given it is not really a planet -` - ) - }) - }) - }) -}) diff --git a/src/search/Search.ts b/src/search/Search.ts deleted file mode 100644 index d9cb4715..00000000 --- a/src/search/Search.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { Query as GherkinQuery } from '@cucumber/gherkin-utils' -import type { GherkinDocument } from '@cucumber/messages' - -import isTagExpression from '../isTagExpression.js' -import { createTagSearch } from './TagSearch.js' -import { createTextSearch } from './TextSearch.js' -import type { Searchable } from './types.js' - -class Search { - private readonly documents: GherkinDocument[] = [] - - constructor( - private readonly tagSearch: Searchable, - private readonly textSearch: Searchable - ) {} - - public async search(query: string): Promise { - if (!query) { - return [...this.documents] - } - - if (isTagExpression(query)) { - try { - return await this.tagSearch.search(query) - } catch { - // No-op, we fall back to text search. - } - } - - return this.textSearch.search(query) - } - - public async add(gherkinDocument: GherkinDocument) { - this.documents.push(gherkinDocument) - await this.tagSearch.add(gherkinDocument) - await this.textSearch.add(gherkinDocument) - return this - } -} - -/** - * Creates a search index that supports querying by term or tag expression, and - * returns an array of abridged Gherkin documents matching the query. - * - * @param gherkinQuery - query instance used internally for searching, with any - * documents already present being pre-populated in the search index - */ -export async function createSearch(gherkinQuery: GherkinQuery): Promise { - const [tagSearch, textSearch] = await Promise.all([ - createTagSearch(gherkinQuery), - createTextSearch(), - ]) - const searchImpl = new Search(tagSearch, textSearch) - for (const doc of gherkinQuery.getGherkinDocuments()) { - await searchImpl.add(doc) - } - return searchImpl -} diff --git a/src/search/StepSearch.spec.ts b/src/search/StepSearch.spec.ts index 4e0029a1..319dde6a 100644 --- a/src/search/StepSearch.spec.ts +++ b/src/search/StepSearch.spec.ts @@ -2,7 +2,7 @@ import type { Step } from '@cucumber/messages' import { expect } from 'chai' import { makeStep } from '../../test/search.js' -import { createStepSearch } from './StepSearch.js' +import { StepSearch } from './StepSearch.js' import type { TypedIndex } from './types.js' describe('StepSearch', () => { @@ -10,7 +10,7 @@ describe('StepSearch', () => { let steps: Step[] beforeEach(async () => { - stepSearch = await createStepSearch() + stepSearch = new StepSearch() steps = [ makeStep('Given', 'a passed step', 'There is a docstring here'), diff --git a/src/search/StepSearch.ts b/src/search/StepSearch.ts index 8966bdfc..cc98d93f 100644 --- a/src/search/StepSearch.ts +++ b/src/search/StepSearch.ts @@ -10,12 +10,15 @@ const schema = { dataTable: 'string[]', } as const -class StepSearch implements TypedIndex { +export class StepSearch implements TypedIndex { private readonly stepById = new Map() private readonly index: Orama - constructor(index: Orama) { - this.index = index + constructor() { + this.index = create({ + schema, + sort: { enabled: false }, + }) } async search(term: string): Promise> { @@ -40,13 +43,3 @@ class StepSearch implements TypedIndex { return this } } - -export async function createStepSearch() { - const index: Orama = await create({ - schema, - sort: { - enabled: false, - }, - }) - return new StepSearch(index) -} diff --git a/src/search/TagSearch.spec.ts b/src/search/TagSearch.spec.ts deleted file mode 100644 index 68ec2db1..00000000 --- a/src/search/TagSearch.spec.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { generateMessages } from '@cucumber/gherkin' -import { Query as GherkinQuery, pretty } from '@cucumber/gherkin-utils' -import { IdGenerator, SourceMediaType } from '@cucumber/messages' -import { expect } from 'chai' - -import { createTagSearch } from './TagSearch.js' - -describe('TagSearch', () => { - let gherkinQuery: GherkinQuery - - const feature = `@system -Feature: Solar System - - @planet - Scenario: Earth - Given is the sixth planet from the Sun - - @dwarf - Scenario: Pluto - Given it is not really a planet - ` - - beforeEach(() => { - gherkinQuery = new GherkinQuery() - }) - - async function prettyResults(feature: string, query: string): Promise { - const envelopes = generateMessages( - feature, - 'test.feature', - SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, - { - includePickles: true, - includeGherkinDocument: true, - newId: IdGenerator.incrementing(), - } - ) - const tagSearch = await createTagSearch(gherkinQuery) - for (const envelope of envelopes) { - gherkinQuery.update(envelope) - } - for (const document of gherkinQuery.getGherkinDocuments()) { - await tagSearch.add(document) - } - return pretty((await tagSearch.search(query))[0]) - } - - describe('search', () => { - it('returns an empty list when no documents have been added', async () => { - const tagSearch = await createTagSearch(gherkinQuery) - expect(await tagSearch.search('@any')).to.deep.eq([]) - }) - - it('finds matching scenarios', async () => { - expect(await prettyResults(feature, '@planet')).to.contain('Scenario: Earth') - }) - - it('takes into account feature tags', async () => { - const results = await prettyResults(feature, '@system') - - expect(results).to.contain('Scenario: Earth') - expect(results).to.contain('Scenario: Pluto') - }) - - it('supports complex search', async () => { - const results = await prettyResults(feature, '@system and not @dwarf') - - expect(results).to.contain('Scenario: Earth') - expect(results).not.to.contain('Scenario: Pluto') - }) - }) - - describe('with examples and tags', () => { - // Currently, we are filtering at the scenario level, - // so as long as one example match, we keep them all. - - const exampleFeature = `@system -Feature: Solar system - - Scenario: a planet may have sattelites - Then should arround - - @solid - Examples: solid planets - | planet | satellites | - | earth | moon | - | mars | phobos, demios | - - @gas - Examples: giant gas planets - | jupiter | Io, Europe, Ganymède, Callisto | -` - it('does not filter non-matching examples', async () => { - const results = await prettyResults(exampleFeature, '@solid') - - expect(results).to.contain('Scenario: a planet may have sattelites') - expect(results).to.contain('Examples: solid planets') - expect(results).to.contain('Examples: giant gas planets') - }) - - it('does not filter examples which should be excluded', async () => { - const results = await prettyResults(exampleFeature, '@solid and not @gas') - - expect(results).to.contain('Scenario: a planet may have sattelites') - expect(results).to.contain('Examples: solid planets') - expect(results).to.contain('Examples: giant gas planets') - }) - }) -}) diff --git a/src/search/TagSearch.ts b/src/search/TagSearch.ts deleted file mode 100644 index 146dfa10..00000000 --- a/src/search/TagSearch.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - GherkinDocumentWalker, - type Query as GherkinQuery, - rejectAllFilters, -} from '@cucumber/gherkin-utils' -import type { GherkinDocument, Pickle, Scenario } from '@cucumber/messages' -import { parse } from '@cucumber/tag-expressions' -import { ArrayMultimap } from '@teppeis/multimaps' - -import type { Searchable } from './types.js' - -class TagSearch { - private readonly pickleById = new Map() - private readonly picklesByScenarioId = new ArrayMultimap() - private gherkinDocuments: GherkinDocument[] = [] - - constructor(private readonly gherkinQuery: GherkinQuery) { - this.gherkinQuery = gherkinQuery - } - - public async search(query: string): Promise { - const expressionNode = parse(query) - const tagFilters = { - acceptScenario: (scenario: Scenario) => { - const pickles = this.picklesByScenarioId.get(scenario.id) - - for (const pickle of pickles) { - const tags = pickle.tags.map((tag) => tag.name) - if (expressionNode.evaluate(tags)) { - return true - } - } - - return false - }, - } - const filters = { ...rejectAllFilters, ...tagFilters } - const astWalker = new GherkinDocumentWalker(filters) - - return this.gherkinDocuments - .map((gherkinDocument) => astWalker.walkGherkinDocument(gherkinDocument)) - .filter((gherkinDocument) => gherkinDocument !== null) as GherkinDocument[] - } - - public async add(gherkinDocument: GherkinDocument) { - this.gherkinDocuments.push(gherkinDocument) - const pickles = this.gherkinQuery.getPickles() - for (const pickle of pickles) { - this.pickleById.set(pickle.id, pickle) - } - - const astWalker = new GherkinDocumentWalker( - {}, - { - handleScenario: (scenario) => { - if (!gherkinDocument.uri) throw new Error('No uri for gherkinDocument') - const pickleIds = this.gherkinQuery.getPickleIds(gherkinDocument.uri, scenario.id) - - for (const pickleId of pickleIds) { - const pickle = this.pickleById.get(pickleId) - if (!pickle) { - throw new Error(`No pickle for id=${pickleId}`) - } - this.picklesByScenarioId.put(scenario.id, pickle) - } - }, - } - ) - astWalker.walkGherkinDocument(gherkinDocument) - - return this - } -} - -export async function createTagSearch(gherkinQuery: GherkinQuery): Promise { - return new TagSearch(gherkinQuery) -} diff --git a/src/search/TextSearch.spec.ts b/src/search/TextSearch.spec.ts index 04f87e71..f8f78056 100644 --- a/src/search/TextSearch.spec.ts +++ b/src/search/TextSearch.spec.ts @@ -1,130 +1,58 @@ -import assert from 'node:assert' -import { AstBuilder, GherkinClassicTokenMatcher, Parser } from '@cucumber/gherkin' -import { pretty } from '@cucumber/gherkin-utils' -import { type GherkinDocument, IdGenerator } from '@cucumber/messages' +import { generateMessages } from '@cucumber/gherkin' +import { Query as GherkinQuery, pretty } from '@cucumber/gherkin-utils' +import { IdGenerator, SourceMediaType } from '@cucumber/messages' +import { expect } from 'chai' -import { createTextSearch } from './TextSearch.js' -import type { Searchable } from './types.js' +import { createSearchIndex } from './createSearchIndex.js' describe('TextSearch', () => { - let search: Searchable - const source = `Feature: Continents + let gherkinQuery: GherkinQuery - Background: World - Given the world exists + const feature = `Feature: Solar System - Scenario: Europe - Given France - When Spain - Then The Netherlands + @planet + Scenario: Earth + Given is the sixth planet from the Sun - Scenario: America - Given Mexico - Then Brazil - - Scenario: Africa - Given Ethiopia - - Rule: uninhabited continents - - Scenario: Antartica - Given some scientific bases -` - - beforeEach(async () => { - const gherkinDocument = parse(source) - - search = await createTextSearch() - await search.add(gherkinDocument) - }) - - describe('Hit found in step', () => { - it('displays just one scenario', async () => { - const searchResults = await search.search('Spain') - - assert.deepStrictEqual( - pretty(searchResults[0]), - `Feature: Continents - - Background: World - Given the world exists - - Scenario: Europe - Given France - When Spain - Then The Netherlands -` - ) - }) - }) - - describe('Hit found in scenario', () => { - it('displays just one scenario', async () => { - const searchResults = await search.search('europe') - - assert.deepStrictEqual( - pretty(searchResults[0]), - `Feature: Continents - - Background: World - Given the world exists - - Scenario: Europe - Given France - When Spain - Then The Netherlands + @dwarf + Scenario: Pluto + Given it is not really a planet ` - ) - }) - }) - - describe('Hit found in background', () => { - it('displays all scenarios', async () => { - const searchResults = await search.search('world') - assert.deepStrictEqual(pretty(searchResults[0]), source) - }) - - it('finds hits in background steps', async () => { - const searchResults = await search.search('exists') - - assert.deepStrictEqual(pretty(searchResults[0]), source) - }) + beforeEach(() => { + gherkinQuery = new GherkinQuery() }) - describe('Hit found in rule', () => { - it('displays a rule', async () => { - const searchResults = await search.search('uninhabited') - - assert.deepStrictEqual( - pretty(searchResults[0]), - `Feature: Continents - - Background: World - Given the world exists - - Rule: uninhabited continents - - Scenario: Antartica - Given some scientific bases + async function prettyResults(feature: string, query: string): Promise { + const envelopes = generateMessages( + feature, + 'test.feature', + SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN, + { + includeGherkinDocument: true, + includePickles: true, + includeSource: true, + newId: IdGenerator.incrementing(), + } + ) + for (const envelope of envelopes) { + gherkinQuery.update(envelope) + } + const search = await createSearchIndex(gherkinQuery.getGherkinDocuments()) + return pretty((await search.search(query))[0]) + } + + describe('search', () => { + it('uses TextSearch to filter the results', async () => { + const results = await prettyResults(feature, 'not really') + expect(results).to.eq( + `Feature: Solar System + + @dwarf + Scenario: Pluto + Given it is not really a planet ` ) }) }) - - describe('No hit found', () => { - it('returns no hits', async () => { - const searchResults = await search.search('saturn') - - assert.deepStrictEqual(searchResults, []) - }) - }) }) - -function parse(source: string): GherkinDocument { - const newId = IdGenerator.uuid() - const parser = new Parser(new AstBuilder(newId), new GherkinClassicTokenMatcher()) - const gherkinDocument = parser.parse(source) - gherkinDocument.uri = 'features/acme.feature' - return gherkinDocument -} diff --git a/src/search/TextSearch.ts b/src/search/TextSearch.ts index 0e038ac4..afb946be 100644 --- a/src/search/TextSearch.ts +++ b/src/search/TextSearch.ts @@ -1,12 +1,9 @@ import { GherkinDocumentWalker } from '@cucumber/gherkin-utils' import type { Background, Feature, GherkinDocument, Rule, Scenario, Step } from '@cucumber/messages' -import { createFeatureSearch } from './FeatureSearch.js' -import { createScenarioLikeSearch } from './ScenarioLikeSearch.js' -import { createStepSearch } from './StepSearch.js' -import type { Searchable, TypedIndex } from './types.js' +import type { SearchIndex, TypedIndex } from './types.js' -class TextSearch { +export class TextSearch implements SearchIndex { private readonly gherkinDocuments: GherkinDocument[] = [] private readonly stepSearch: TypedIndex @@ -29,7 +26,10 @@ class TextSearch { this.featureSearch = featureSearch } - public async search(query: string): Promise { + public async search( + query: string, + allowedUris?: ReadonlySet + ): Promise { const [matchingSteps, matchingBackgrounds, matchingScenarios, matchingRules, matchingFeatures] = await Promise.all([ this.stepSearch.search(query), @@ -47,7 +47,11 @@ class TextSearch { acceptFeature: (feature) => matchingFeatures.includes(feature), }) - return this.gherkinDocuments + const documentsToWalk = allowedUris + ? this.gherkinDocuments.filter((doc) => doc.uri && allowedUris.has(doc.uri)) + : this.gherkinDocuments + + return documentsToWalk .map((gherkinDocument) => walker.walkGherkinDocument(gherkinDocument)) .filter((gherkinDocument) => !!gherkinDocument) as readonly GherkinDocument[] } @@ -70,15 +74,3 @@ class TextSearch { return this } } - -export async function createTextSearch(): Promise { - const [stepSearch, backgroundSearch, scenarioSearch, ruleSearch, featureSearch] = - await Promise.all([ - createStepSearch(), - createScenarioLikeSearch(), - createScenarioLikeSearch(), - createScenarioLikeSearch(), - createFeatureSearch(), - ]) - return new TextSearch(stepSearch, backgroundSearch, scenarioSearch, ruleSearch, featureSearch) -} diff --git a/src/search/createSearchIndex.ts b/src/search/createSearchIndex.ts new file mode 100644 index 00000000..1a185c4b --- /dev/null +++ b/src/search/createSearchIndex.ts @@ -0,0 +1,27 @@ +import type { Background, GherkinDocument, Rule, Scenario } from '@cucumber/messages' + +import { FeatureSearch } from './FeatureSearch.js' +import { ScenarioLikeSearch } from './ScenarioLikeSearch.js' +import { StepSearch } from './StepSearch.js' +import { TextSearch } from './TextSearch.js' +import type { SearchIndex } from './types.js' + +/** + * Creates a search index that supports querying by term, and returns an array + * of abridged Gherkin documents matching the query. + */ +export async function createSearchIndex( + gherkinDocuments: ReadonlyArray +): Promise { + const textSearch = new TextSearch( + new StepSearch(), + new ScenarioLikeSearch(), + new ScenarioLikeSearch(), + new ScenarioLikeSearch(), + new FeatureSearch() + ) + for (const doc of gherkinDocuments) { + await textSearch.add(doc) + } + return textSearch +} diff --git a/src/search/index.ts b/src/search/index.ts index 290a1505..3949c408 100644 --- a/src/search/index.ts +++ b/src/search/index.ts @@ -1,2 +1,2 @@ -export { createSearch } from './Search.js' +export { createSearchIndex } from './createSearchIndex.js' export * from './types.js' diff --git a/src/search/types.ts b/src/search/types.ts index 48c9d055..7f0ef5e3 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -2,7 +2,7 @@ import type { GherkinDocument } from '@cucumber/messages' /** * Facade for an index that supports searching for and adding items of a given - * type. Also supports a different type being added if needed. + * type. Also supports a different type being added vs returned if needed. */ export interface TypedIndex { search: (query: string) => Promise @@ -10,6 +10,9 @@ export interface TypedIndex { } /** - * Shorthand type for an index of Gherkin documents. + * Interface for the top-level document search index. Accepts an optional set of + * document URIs to constrain the scope. */ -export type Searchable = TypedIndex +export interface SearchIndex { + search: (query: string, allowedUris?: ReadonlySet) => Promise +}