From 9e4919c1785c4884feb2047d796743c392e347b6 Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 22 Jul 2026 13:51:16 +0100 Subject: [PATCH 1/2] Add useFilteredTestCases hook --- src/components/results/UndefinedResult.tsx | 2 +- src/ensure.ts | 6 + src/hooks/helpers.ts | 37 ----- src/hooks/useFilteredDocuments.ts | 134 +++++++++--------- src/hooks/useFilteredTestCases.spec.tsx | 151 +++++++++++++++++++++ src/hooks/useFilteredTestCases.ts | 47 +++++++ src/hooks/useSearchResult.ts | 89 ++++++++++++ src/hooks/useTestRunHooks.ts | 2 +- src/search/TextSearch.ts | 39 +++--- src/search/filterAndExpandTestCases.ts | 62 +++++++++ src/search/index.ts | 2 + src/search/pruneGherkinDocuments.ts | 17 ++- src/search/pruneTestCaseEvents.ts | 47 +++++++ src/search/types.ts | 10 +- tsconfig.json | 2 +- 15 files changed, 502 insertions(+), 145 deletions(-) create mode 100644 src/ensure.ts delete mode 100644 src/hooks/helpers.ts create mode 100644 src/hooks/useFilteredTestCases.spec.tsx create mode 100644 src/hooks/useFilteredTestCases.ts create mode 100644 src/hooks/useSearchResult.ts create mode 100644 src/search/filterAndExpandTestCases.ts create mode 100644 src/search/pruneTestCaseEvents.ts diff --git a/src/components/results/UndefinedResult.tsx b/src/components/results/UndefinedResult.tsx index bff53a3e..f9d8475e 100644 --- a/src/components/results/UndefinedResult.tsx +++ b/src/components/results/UndefinedResult.tsx @@ -1,7 +1,7 @@ import type { TestStep } from '@cucumber/messages' import type { FC } from 'react' -import { ensure } from '../../hooks/helpers.js' +import { ensure } from '../../ensure.js' import { useQueries } from '../../hooks/index.js' import { ResultNote } from './ResultNote.js' import styles from './UndefinedResult.module.scss' diff --git a/src/ensure.ts b/src/ensure.ts new file mode 100644 index 00000000..721b9149 --- /dev/null +++ b/src/ensure.ts @@ -0,0 +1,6 @@ +export function ensure(value: T | undefined, message: string): T { + if (!value) { + throw new Error(message) + } + return value +} diff --git a/src/hooks/helpers.ts b/src/hooks/helpers.ts deleted file mode 100644 index 81cea466..00000000 --- a/src/hooks/helpers.ts +++ /dev/null @@ -1,37 +0,0 @@ -export function ensure(value: T | undefined, message: string): T { - if (!value) { - throw new Error(message) - } - return value -} - -// Helper function for sorting directories -export function comparePaths(uriA: string, uriB: string): number { - // Assumes that last part of every uri is a file - const partsA = uriA.split('/') - const partsB = uriB.split('/') - const minLength = Math.min(partsA.length, partsB.length) - - for (let i = 0; i < minLength; i++) { - const partA = partsA[i] - const partB = partsB[i] - - if (partA !== partB) { - const isALast = i === partsA.length - 1 - const isBLast = i === partsB.length - 1 - - if (isALast && !isBLast) { - return 1 // A is file and B is directory -> B comes first - } - if (!isALast && isBLast) { - return -1 // A is directory and B is file -> A comes first - } - - // Both are files or both are directories -> Alphabetical sorting - return partA.localeCompare(partB) - } - } - - // If one path is prefix of other then shorter path comes first - return partsA.length - partsB.length -} diff --git a/src/hooks/useFilteredDocuments.ts b/src/hooks/useFilteredDocuments.ts index 9ee7cb68..a91ad4aa 100644 --- a/src/hooks/useFilteredDocuments.ts +++ b/src/hooks/useFilteredDocuments.ts @@ -1,15 +1,13 @@ -import { type GherkinDocument, type Pickle, TestStepResultStatus } from '@cucumber/messages' -import type { Lineage } from '@cucumber/query' +import type { GherkinDocument } from '@cucumber/messages' import { type Dispatch, useCallback, useEffect, useMemo, useState } from 'react' import { - createSearchIndex, deriveLineageConstraints, + filterAndExpandTestCases, pruneGherkinDocuments, - type SearchIndex, } from '../search/index.js' -import { comparePaths, ensure } from './helpers.js' import { useQueries } from './useQueries.js' import { useSearch } from './useSearch.js' +import { useSearchResult } from './useSearchResult.js' export function useFilteredDocuments(): { results?: ReadonlyArray @@ -18,37 +16,18 @@ export function useFilteredDocuments(): { const { cucumberQuery, gherkinQuery } = useQueries() const allTestCasesStarted = useMemo(() => cucumberQuery.findAllTestCaseStarted(), [cucumberQuery]) const gherkinDocuments = useMemo(() => gherkinQuery.getGherkinDocuments(), [gherkinQuery]) - const { hideStatuses, tagExpression, searchTerm, unchanged } = useSearch() - const lineageConstraints = useMemo(() => { - const candidates: Array<{ pickle: Pickle; lineage: Lineage }> = [] - for (const testCaseStarted of allTestCasesStarted) { - if (hideStatuses.length) { - const status = - cucumberQuery.findMostSevereTestStepResultBy(testCaseStarted)?.status ?? - TestStepResultStatus.UNKNOWN - if (hideStatuses.includes(status)) { - continue - } - } - const pickle = ensure( - cucumberQuery.findPickleBy(testCaseStarted), - `No Pickle found for TestCaseStarted ${testCaseStarted.id}` - ) - if (tagExpression) { - const tags = pickle.tags.map((tag) => tag.name) - if (!tagExpression.evaluate(tags)) { - continue - } - } - const lineage = ensure( - cucumberQuery.findLineageBy(pickle), - `No Lineage found for Pickle ${pickle.uri}` - ) - candidates.push({ pickle, lineage }) - } - return deriveLineageConstraints(candidates) - }, [allTestCasesStarted, cucumberQuery, hideStatuses, tagExpression]) - const [searchIndex, setSearchIndex] = useState() + const { hideStatuses, tagExpression, unchanged } = useSearch() + const lineageConstraints = useMemo( + () => + deriveLineageConstraints( + filterAndExpandTestCases(cucumberQuery, allTestCasesStarted, { + hideStatuses, + tagExpression, + }) + ), + [allTestCasesStarted, cucumberQuery, hideStatuses, tagExpression] + ) + const searchResult = useSearchResult() const [results, setResults] = useState>() const setResultsSorting: Dispatch> = useCallback((unsorted) => { const sorted = [...unsorted] @@ -57,47 +36,56 @@ export function useFilteredDocuments(): { }, []) useEffect(() => { - createSearchIndex(gherkinDocuments) - .then((created) => setSearchIndex(created)) - .catch((error) => { - console.error('Failed to create search index:', error) - setSearchIndex(false) - }) - }, [gherkinDocuments]) - useEffect(() => { - if (searchTerm) { - // we only deal with the non-search path in this effect - return - } - setResultsSorting(pruneGherkinDocuments(gherkinDocuments, lineageConstraints)) - }, [gherkinDocuments, lineageConstraints, searchTerm, setResultsSorting]) - useEffect(() => { - if (!searchTerm) { - // we only deal with the search path in this effect - return - } - if (searchIndex === undefined) { - // search index is not ready yet - return - } - if (searchIndex === false) { - // search index failed to create - fallback - setResultsSorting(pruneGherkinDocuments(gherkinDocuments, lineageConstraints)) - return - } - searchIndex - .search(searchTerm) - .then((searchHits) => { - setResultsSorting(pruneGherkinDocuments(gherkinDocuments, lineageConstraints, searchHits)) - }) - .catch((error) => { - console.error('Search failed:', error) + switch (searchResult.status) { + case 'WAITING': + // results are not ready yet - leave them as they are + return + case 'NOOP': + case 'ERROR': + // no narrowing to apply setResultsSorting(pruneGherkinDocuments(gherkinDocuments, lineageConstraints)) - }) - }, [gherkinDocuments, lineageConstraints, searchIndex, searchTerm, setResultsSorting]) + return + case 'SUCCESS': + setResultsSorting( + pruneGherkinDocuments(gherkinDocuments, lineageConstraints, searchResult.hits) + ) + return + } + }, [gherkinDocuments, lineageConstraints, searchResult, setResultsSorting]) return { results, filtered: !unchanged, } } + +// Helper function for sorting directories +function comparePaths(uriA: string, uriB: string): number { + // Assumes that last part of every uri is a file + const partsA = uriA.split('/') + const partsB = uriB.split('/') + const minLength = Math.min(partsA.length, partsB.length) + + for (let i = 0; i < minLength; i++) { + const partA = partsA[i] + const partB = partsB[i] + + if (partA !== partB) { + const isALast = i === partsA.length - 1 + const isBLast = i === partsB.length - 1 + + if (isALast && !isBLast) { + return 1 // A is file and B is directory -> B comes first + } + if (!isALast && isBLast) { + return -1 // A is directory and B is file -> A comes first + } + + // Both are files or both are directories -> Alphabetical sorting + return partA.localeCompare(partB) + } + } + + // If one path is prefix of other then shorter path comes first + return partsA.length - partsB.length +} diff --git a/src/hooks/useFilteredTestCases.spec.tsx b/src/hooks/useFilteredTestCases.spec.tsx new file mode 100644 index 00000000..5ea17fc8 --- /dev/null +++ b/src/hooks/useFilteredTestCases.spec.tsx @@ -0,0 +1,151 @@ +import { TestStepResultStatus } from '@cucumber/messages' +import { renderHook, waitFor } from '@testing-library/react' +import { expect } from 'chai' + +import attachments from '../../acceptance/attachments/attachments.js' +import backgrounds from '../../acceptance/backgrounds/backgrounds.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 { EnvelopesProvider } from '../components/app/EnvelopesProvider.js' +import { InMemorySearchProvider } from '../components/app/InMemorySearchProvider.js' +import { useFilteredTestCases } from './useFilteredTestCases.js' + +interface ProviderProps { + envelopes: Parameters[0]['envelopes'] + defaultQuery?: string + defaultHideStatuses?: readonly TestStepResultStatus[] +} + +function renderAndExtractPickleNames({ + envelopes, + defaultQuery, + defaultHideStatuses, +}: ProviderProps) { + return renderHook(() => useFilteredTestCases().map(({ pickle }) => pickle.name), { + wrapper: ({ children }) => ( + + + {children} + + + ), + }) +} + +describe('useFilteredTestCases', () => { + describe('with no filters', () => { + it('returns a test case for every finished scenario', async () => { + const { result } = renderAndExtractPickleNames({ envelopes: hooksConditional }) + + await waitFor(() => + expect(result.current).to.have.members([ + 'A failure in the before hook and a skipped step', + 'A failure in the after hook and a passed step', + 'With an tag, a passed step and hook', + ]) + ) + }) + }) + + describe('filtering by tag expression', () => { + it('keeps only test cases matching the tag expression', async () => { + const { result } = renderAndExtractPickleNames({ + envelopes: hooksConditional, + defaultQuery: '@fail-before', + }) + + await waitFor(() => + expect(result.current).to.have.members(['A failure in the before hook and a skipped step']) + ) + }) + }) + + describe('filtering by status', () => { + it('keeps only test cases whose result is not hidden', async () => { + const { result } = renderAndExtractPickleNames({ + envelopes: retry, + defaultHideStatuses: [ + TestStepResultStatus.FAILED, + TestStepResultStatus.SKIPPED, + TestStepResultStatus.PENDING, + TestStepResultStatus.UNDEFINED, + TestStepResultStatus.AMBIGUOUS, + ], + }) + + await waitFor(() => expect(result.current).to.include("Test cases that pass aren't retried")) + expect(result.current).to.include( + 'Test cases that fail are retried if within the --retry limit' + ) + expect(result.current).to.include( + 'Test cases that fail will continue to retry up to the --retry limit' + ) + expect(result.current).not.to.include( + "Test cases won't retry after failing more than the --retry limit" + ) + }) + }) + + describe('searching by text', () => { + it('keeps test cases whose step matches', async () => { + const { result } = renderAndExtractPickleNames({ envelopes: retry, defaultQuery: 'third' }) + + await waitFor(() => + expect(result.current).to.include( + 'Test cases that fail will continue to retry up to the --retry limit' + ) + ) + expect(result.current).not.to.include("Test cases that pass aren't retried") + expect(result.current).not.to.include( + 'Test cases that fail are retried if within the --retry limit' + ) + expect(result.current).not.to.include( + "Test cases won't retry after failing more than the --retry limit" + ) + }) + + it('keeps test cases whose scenario name matches', async () => { + const { result } = renderAndExtractPickleNames({ + envelopes: attachments, + defaultQuery: 'JSON', + }) + + await waitFor(() => expect(result.current).to.include('Log JSON')) + expect(result.current).not.to.include('Log text') + expect(result.current).not.to.include('Log ANSI coloured text') + }) + + it('keeps test cases whose rule matches', async () => { + const { result } = renderAndExtractPickleNames({ + envelopes: rules, + defaultQuery: 'enough money', + }) + + await waitFor(() => expect(result.current).to.include('Enough money')) + expect(result.current).to.include('Not enough money') + expect(result.current).not.to.include('No chocolates left') + }) + + it('keeps all test cases under a matching feature', async () => { + const { result } = renderAndExtractPickleNames({ envelopes: rules, defaultQuery: 'synonym' }) + + await waitFor(() => expect(result.current).to.include('No chocolates left')) + expect(result.current).to.include('Enough money') + expect(result.current).to.include('Not enough money') + }) + + it('keeps all test cases under a matching background', async () => { + const { result } = renderAndExtractPickleNames({ + envelopes: backgrounds, + defaultQuery: 'eggs', + }) + + await waitFor(() => expect(result.current).to.include('one scenario')) + expect(result.current).to.include('another scenario') + }) + }) +}) diff --git a/src/hooks/useFilteredTestCases.ts b/src/hooks/useFilteredTestCases.ts new file mode 100644 index 00000000..fc8bded4 --- /dev/null +++ b/src/hooks/useFilteredTestCases.ts @@ -0,0 +1,47 @@ +import type { TestCaseFinished } from '@cucumber/messages' +import { useEffect, useMemo, useState } from 'react' +import { + type ExpandedTestCase, + filterAndExpandTestCases, + pruneTestCaseEvents, +} from '../search/index.js' +import { useQueries } from './useQueries.js' +import { useSearch } from './useSearch.js' +import { useSearchResult } from './useSearchResult.js' + +export function useFilteredTestCases(): ReadonlyArray> { + const { cucumberQuery } = useQueries() + const allTestCasesFinished = useMemo( + () => cucumberQuery.findAllTestCaseFinished(), + [cucumberQuery] + ) + const { hideStatuses, tagExpression } = useSearch() + const candidates = useMemo( + () => + filterAndExpandTestCases(cucumberQuery, allTestCasesFinished, { + hideStatuses, + tagExpression, + }), + [allTestCasesFinished, cucumberQuery, hideStatuses, tagExpression] + ) + const searchResult = useSearchResult() + const [results, setResults] = useState>>([]) + + useEffect(() => { + switch (searchResult.status) { + case 'WAITING': + // results are not ready yet - leave them as they are + return + case 'NOOP': + case 'ERROR': + // no narrowing to apply + setResults(candidates) + return + case 'SUCCESS': + setResults(pruneTestCaseEvents(candidates, searchResult.hits)) + return + } + }, [candidates, searchResult]) + + return results +} diff --git a/src/hooks/useSearchResult.ts b/src/hooks/useSearchResult.ts new file mode 100644 index 00000000..fb983d63 --- /dev/null +++ b/src/hooks/useSearchResult.ts @@ -0,0 +1,89 @@ +import { useEffect, useMemo, useState } from 'react' +import { createSearchIndex, type SearchHits, type SearchIndex } from '../search/index.js' +import { useQueries } from './useQueries.js' +import { useSearch } from './useSearch.js' + +/** the search ran - `hits` is an empty map when nothing matched */ +export interface SuccessSearchResult { + status: 'SUCCESS' + hits: SearchHits +} + +/** there was no search term, so no search was done */ +export interface NoopSearchResult { + status: 'NOOP' +} + +/** waiting for the index to build or the search to resolve */ +export interface WaitingSearchResult { + status: 'WAITING' +} + +/** the index failed to build or the search itself failed */ +export interface ErrorSearchResult { + status: 'ERROR' + error: Error +} + +export type SearchResult = + | SuccessSearchResult + | NoopSearchResult + | WaitingSearchResult + | ErrorSearchResult + +/** + * Creates and populates a search index from the loaded Gherkin documents, runs the current search + * term against it, and returns a discriminated result describing the outcome. Consumers narrow on + * `result.status` and don't need to know whether a search term is present. + */ +export function useSearchResult(): SearchResult { + const { gherkinQuery } = useQueries() + const gherkinDocuments = useMemo(() => gherkinQuery.getGherkinDocuments(), [gherkinQuery]) + const { searchTerm } = useSearch() + + const [searchIndex, setSearchIndex] = useState() + const [result, setResult] = useState({ status: 'WAITING' }) + + useEffect(() => { + createSearchIndex(gherkinDocuments) + .then((created) => setSearchIndex(created)) + .catch((error) => { + console.error('Failed to create search index:', error) + setSearchIndex(new Error('Failed to create search index', { cause: error })) + }) + }, [gherkinDocuments]) + useEffect(() => { + if (!searchTerm) { + setResult({ status: 'NOOP' }) + return + } + if (searchIndex === undefined) { + // search index is not ready yet + setResult({ status: 'WAITING' }) + return + } + if (searchIndex instanceof Error) { + setResult({ status: 'ERROR', error: searchIndex }) + return + } + let active = true + searchIndex + .search(searchTerm) + .then((hits) => { + if (active) { + setResult({ status: 'SUCCESS', hits }) + } + }) + .catch((error) => { + console.error('Search failed:', error) + if (active) { + setResult({ status: 'ERROR', error: new Error('Search failed', { cause: error }) }) + } + }) + return () => { + active = false + } + }, [searchIndex, searchTerm]) + + return result +} diff --git a/src/hooks/useTestRunHooks.ts b/src/hooks/useTestRunHooks.ts index 39eaaf71..4af12dc6 100644 --- a/src/hooks/useTestRunHooks.ts +++ b/src/hooks/useTestRunHooks.ts @@ -1,6 +1,6 @@ import type { Hook, TestRunHookFinished } from '@cucumber/messages' -import { ensure } from './helpers.js' +import { ensure } from '../ensure.js' import { useQueries } from './useQueries.js' type RunHooksList = { testRunHookFinished: TestRunHookFinished; hook: Hook }[] diff --git a/src/search/TextSearch.ts b/src/search/TextSearch.ts index b0b2747f..b8840ff2 100644 --- a/src/search/TextSearch.ts +++ b/src/search/TextSearch.ts @@ -3,7 +3,13 @@ import type { Background, GherkinDocument, Rule, Scenario } from '@cucumber/mess import { FeatureSearch } from './FeatureSearch.js' import { ScenarioLikeSearch } from './ScenarioLikeSearch.js' import { StepSearch } from './StepSearch.js' -import type { DocumentSearchHits, IndexHit, SearchHits, SearchIndex } from './types.js' +import type { DocumentSearchHits, SearchHits, SearchIndex } from './types.js' + +type MutableDocumentSearchHits = { + [Key in keyof DocumentSearchHits]: DocumentSearchHits[Key] extends ReadonlySet + ? Set + : DocumentSearchHits[Key] +} export class TextSearch implements SearchIndex { private readonly featureSearch = new FeatureSearch() @@ -12,7 +18,7 @@ export class TextSearch implements SearchIndex { private readonly scenarioSearch = new ScenarioLikeSearch() private readonly stepSearch = new StepSearch() - public async search(query: string): Promise { + public async search(query: string): Promise { const [featureHits, backgroundHits, ruleHits, scenarioHits, stepHits] = await Promise.all([ this.featureSearch.search(query), this.backgroundSearch.search(query), @@ -20,21 +26,18 @@ export class TextSearch implements SearchIndex { this.scenarioSearch.search(query), this.stepSearch.search(query), ]) - const allHits: ReadonlyArray = [ - ...featureHits, - ...backgroundHits, - ...ruleHits, - ...scenarioHits, - ...stepHits, - ] - if (allHits.length === 0) { - return false - } - const map = new Map() + // an empty map is a valid result meaning nothing matched + const map = new Map() const getOrDefault = (uri: string) => { let entry = map.get(uri) if (!entry) { - entry = { feature: false, background: [], rule: [], scenario: [], step: [] } + entry = { + feature: false, + background: new Set(), + rule: new Set(), + scenario: new Set(), + step: new Set(), + } map.set(uri, entry) } return entry @@ -43,16 +46,16 @@ export class TextSearch implements SearchIndex { getOrDefault(hit.uri).feature = true } for (const hit of backgroundHits) { - getOrDefault(hit.uri).background.push(hit.id) + getOrDefault(hit.uri).background.add(hit.id) } for (const hit of ruleHits) { - getOrDefault(hit.uri).rule.push(hit.id) + getOrDefault(hit.uri).rule.add(hit.id) } for (const hit of scenarioHits) { - getOrDefault(hit.uri).scenario.push(hit.id) + getOrDefault(hit.uri).scenario.add(hit.id) } for (const hit of stepHits) { - getOrDefault(hit.uri).step.push(hit.id) + getOrDefault(hit.uri).step.add(hit.id) } return map } diff --git a/src/search/filterAndExpandTestCases.ts b/src/search/filterAndExpandTestCases.ts new file mode 100644 index 00000000..8ed4b9e8 --- /dev/null +++ b/src/search/filterAndExpandTestCases.ts @@ -0,0 +1,62 @@ +import { + type Pickle, + TestStepResultStatus as Status, + type TestCaseFinished, + type TestCaseStarted, + type TestStepResultStatus, +} from '@cucumber/messages' +import type { Query as CucumberQuery, Lineage } from '@cucumber/query' +import type { Node as TagExpression } from '@cucumber/tag-expressions' +import { ensure } from '../ensure.js' + +export interface ExpandedTestCase { + testCaseEvent: T + pickle: Pickle + lineage: Lineage +} + +export interface FilterCriteria { + hideStatuses: ReadonlyArray + tagExpression?: TagExpression +} + +/** + * Filters test cases by status and/or tag expression, expanding each survivor with its pickle and + * lineage. + * + * Works for either `TestCaseStarted` or `TestCaseFinished` - the element type flows through to the + * `testCaseEvent` of each returned item. + */ +export function filterAndExpandTestCases( + cucumberQuery: CucumberQuery, + testCases: ReadonlyArray, + { hideStatuses, tagExpression }: FilterCriteria +): Array> { + const expanded: Array> = [] + for (const testCase of testCases) { + if (hideStatuses.length) { + const status = + cucumberQuery.findMostSevereTestStepResultBy(testCase)?.status ?? Status.UNKNOWN + if (hideStatuses.includes(status)) { + continue + } + } + const id = 'id' in testCase ? testCase.id : testCase.testCaseStartedId + const pickle = ensure( + cucumberQuery.findPickleBy(testCase), + `No Pickle found for TestCase ${id}` + ) + if (tagExpression) { + const tags = pickle.tags.map((tag) => tag.name) + if (!tagExpression.evaluate(tags)) { + continue + } + } + const lineage = ensure( + cucumberQuery.findLineageBy(pickle), + `No Lineage found for Pickle ${pickle.uri}` + ) + expanded.push({ testCaseEvent: testCase, pickle, lineage }) + } + return expanded +} diff --git a/src/search/index.ts b/src/search/index.ts index bdadb29b..c298d49a 100644 --- a/src/search/index.ts +++ b/src/search/index.ts @@ -1,4 +1,6 @@ export * from './createSearchIndex.js' export * from './deriveLineageConstraints.js' +export * from './filterAndExpandTestCases.js' export * from './pruneGherkinDocuments.js' +export * from './pruneTestCaseEvents.js' export * from './types.js' diff --git a/src/search/pruneGherkinDocuments.ts b/src/search/pruneGherkinDocuments.ts index 2b902578..1d03847e 100644 --- a/src/search/pruneGherkinDocuments.ts +++ b/src/search/pruneGherkinDocuments.ts @@ -29,15 +29,15 @@ import type { DocumentSearchHits, LineageConstraints, SearchHits } from './types * * `searchHits` has cascading behaviour - a match at a higher level (Feature, Rule, or Background) * ensures all of its descendants will be retained, and a match at a lower level (Scenario, Step) - * ensures all of its ascendants (but not siblings) will be retained. A `false` value means there - * were no hits at all, so no documents will be returned. + * ensures all of its ascendants (but not siblings) will be retained. An empty map means there were + * no hits at all, so no documents will be returned. */ export function pruneGherkinDocuments( gherkinDocuments: ReadonlyArray, constraints: LineageConstraints, - searchHits?: SearchHits | false + searchHits?: SearchHits ): ReadonlyArray { - if (searchHits === false) { + if (searchHits?.size === 0) { return [] } const results: GherkinDocument[] = [] @@ -150,7 +150,7 @@ function filterRule( if ( searchHits && - (searchHits.rule.includes(rule.id) || matchesOnBackground(rule.children, searchHits)) + (searchHits.rule.has(rule.id) || matchesOnBackground(rule.children, searchHits)) ) { searchHits = undefined } @@ -201,7 +201,7 @@ function filterScenario( } if ( searchHits && - !searchHits.scenario.includes(scenario.id) && + !searchHits.scenario.has(scenario.id) && !matchesOnSteps(scenario.steps, searchHits) ) { return undefined @@ -242,11 +242,10 @@ function matchesOnBackground( .filter((background) => !!background) .some( (background) => - searchHits.background.includes(background.id) || - matchesOnSteps(background.steps, searchHits) + searchHits.background.has(background.id) || matchesOnSteps(background.steps, searchHits) ) } function matchesOnSteps(steps: ReadonlyArray, searchHits: DocumentSearchHits): boolean { - return steps.some((step) => searchHits.step.includes(step.id)) + return steps.some((step) => searchHits.step.has(step.id)) } diff --git a/src/search/pruneTestCaseEvents.ts b/src/search/pruneTestCaseEvents.ts new file mode 100644 index 00000000..9af1d2ed --- /dev/null +++ b/src/search/pruneTestCaseEvents.ts @@ -0,0 +1,47 @@ +import type { TestCaseFinished, TestCaseStarted } from '@cucumber/messages' +import type { ExpandedTestCase } from './filterAndExpandTestCases.js' +import type { SearchHits } from './types.js' + +/** + * Narrows expanded test cases down to those that overlap with the given search hits. + * + * @param expandedTestCases - the test cases to narrow, each paired with its pickle and lineage + * @param searchHits - the hits to narrow against; an empty map means the search matched nothing + * + * @remarks + * A test case is retained when its lineage overlaps a hit at any level: a matched Feature retains + * every test case beneath it; otherwise the test case's own Rule, Background, or Scenario must be + * hit, or one of its steps must be hit. An empty map means there were no hits at all, so nothing + * is retained. + */ +export function pruneTestCaseEvents( + expandedTestCases: ReadonlyArray>, + searchHits: SearchHits +): ReadonlyArray> { + if (searchHits.size === 0) { + return [] + } + return expandedTestCases.filter(({ lineage, pickle }) => { + const documentHits = searchHits.get(pickle.uri) + if (!documentHits) { + return false + } + if (documentHits.feature) { + return true + } + if (lineage.background && documentHits.background.has(lineage.background.id)) { + return true + } + if (lineage.rule && documentHits.rule.has(lineage.rule.id)) { + return true + } + if (lineage.ruleBackground && documentHits.background.has(lineage.ruleBackground.id)) { + return true + } + if (lineage.scenario && documentHits.scenario.has(lineage.scenario.id)) { + return true + } + const stepNodeIds = new Set(pickle.steps.flatMap((step) => step.astNodeIds)) + return !stepNodeIds.isDisjointFrom(documentHits.step) + }) +} diff --git a/src/search/types.ts b/src/search/types.ts index e85cd913..5a0f1a1f 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -18,7 +18,7 @@ export interface TypedIndex { * Interface for the top-level document search index */ export interface SearchIndex { - search: (query: string) => Promise + search: (query: string) => Promise } /** @@ -27,10 +27,10 @@ export interface SearchIndex { */ export interface DocumentSearchHits { feature: boolean - background: Array - rule: Array - scenario: Array - step: Array + background: ReadonlySet + rule: ReadonlySet + scenario: ReadonlySet + step: ReadonlySet } export type SearchHits = ReadonlyMap diff --git a/tsconfig.json b/tsconfig.json index 89b81897..366cc9d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "./node_modules/@tsconfig/recommended/tsconfig.json", "include": ["src", "test"], "compilerOptions": { - "lib": ["es2024", "dom"], + "lib": ["es2024", "ESNext.Collection", "dom"], "target": "es2024", "module": "NodeNext", "moduleResolution": "NodeNext", From 5e712e21a99ef75abb1f27c5f618fe0bb3db84bb Mon Sep 17 00:00:00 2001 From: David Goss Date: Wed, 22 Jul 2026 14:05:05 +0100 Subject: [PATCH 2/2] Naming things --- src/hooks/useFilteredDocuments.ts | 4 ++-- src/hooks/useFilteredTestCases.ts | 4 ++-- ...stCases.ts => filterAndExpandTestCaseEvents.ts} | 14 +++++++------- src/search/index.ts | 2 +- src/search/pruneTestCaseEvents.ts | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) rename src/search/{filterAndExpandTestCases.ts => filterAndExpandTestCaseEvents.ts} (76%) diff --git a/src/hooks/useFilteredDocuments.ts b/src/hooks/useFilteredDocuments.ts index a91ad4aa..298633e9 100644 --- a/src/hooks/useFilteredDocuments.ts +++ b/src/hooks/useFilteredDocuments.ts @@ -2,7 +2,7 @@ import type { GherkinDocument } from '@cucumber/messages' import { type Dispatch, useCallback, useEffect, useMemo, useState } from 'react' import { deriveLineageConstraints, - filterAndExpandTestCases, + filterAndExpandTestCaseEvents, pruneGherkinDocuments, } from '../search/index.js' import { useQueries } from './useQueries.js' @@ -20,7 +20,7 @@ export function useFilteredDocuments(): { const lineageConstraints = useMemo( () => deriveLineageConstraints( - filterAndExpandTestCases(cucumberQuery, allTestCasesStarted, { + filterAndExpandTestCaseEvents(cucumberQuery, allTestCasesStarted, { hideStatuses, tagExpression, }) diff --git a/src/hooks/useFilteredTestCases.ts b/src/hooks/useFilteredTestCases.ts index fc8bded4..d80add38 100644 --- a/src/hooks/useFilteredTestCases.ts +++ b/src/hooks/useFilteredTestCases.ts @@ -2,7 +2,7 @@ import type { TestCaseFinished } from '@cucumber/messages' import { useEffect, useMemo, useState } from 'react' import { type ExpandedTestCase, - filterAndExpandTestCases, + filterAndExpandTestCaseEvents, pruneTestCaseEvents, } from '../search/index.js' import { useQueries } from './useQueries.js' @@ -18,7 +18,7 @@ export function useFilteredTestCases(): ReadonlyArray - filterAndExpandTestCases(cucumberQuery, allTestCasesFinished, { + filterAndExpandTestCaseEvents(cucumberQuery, allTestCasesFinished, { hideStatuses, tagExpression, }), diff --git a/src/search/filterAndExpandTestCases.ts b/src/search/filterAndExpandTestCaseEvents.ts similarity index 76% rename from src/search/filterAndExpandTestCases.ts rename to src/search/filterAndExpandTestCaseEvents.ts index 8ed4b9e8..a9f8dff8 100644 --- a/src/search/filterAndExpandTestCases.ts +++ b/src/search/filterAndExpandTestCaseEvents.ts @@ -27,23 +27,23 @@ export interface FilterCriteria { * Works for either `TestCaseStarted` or `TestCaseFinished` - the element type flows through to the * `testCaseEvent` of each returned item. */ -export function filterAndExpandTestCases( +export function filterAndExpandTestCaseEvents( cucumberQuery: CucumberQuery, - testCases: ReadonlyArray, + testCaseEvents: ReadonlyArray, { hideStatuses, tagExpression }: FilterCriteria ): Array> { const expanded: Array> = [] - for (const testCase of testCases) { + for (const testCaseEvent of testCaseEvents) { if (hideStatuses.length) { const status = - cucumberQuery.findMostSevereTestStepResultBy(testCase)?.status ?? Status.UNKNOWN + cucumberQuery.findMostSevereTestStepResultBy(testCaseEvent)?.status ?? Status.UNKNOWN if (hideStatuses.includes(status)) { continue } } - const id = 'id' in testCase ? testCase.id : testCase.testCaseStartedId + const id = 'id' in testCaseEvent ? testCaseEvent.id : testCaseEvent.testCaseStartedId const pickle = ensure( - cucumberQuery.findPickleBy(testCase), + cucumberQuery.findPickleBy(testCaseEvent), `No Pickle found for TestCase ${id}` ) if (tagExpression) { @@ -56,7 +56,7 @@ export function filterAndExpandTestCases