Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/components/results/UndefinedResult.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
6 changes: 6 additions & 0 deletions src/ensure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function ensure<T>(value: T | undefined, message: string): T {
if (!value) {
throw new Error(message)
}
return value
}
37 changes: 0 additions & 37 deletions src/hooks/helpers.ts

This file was deleted.

134 changes: 61 additions & 73 deletions src/hooks/useFilteredDocuments.ts
Original file line number Diff line number Diff line change
@@ -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,
filterAndExpandTestCaseEvents,
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<GherkinDocument>
Expand All @@ -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<SearchIndex | false>()
const { hideStatuses, tagExpression, unchanged } = useSearch()
const lineageConstraints = useMemo(
() =>
deriveLineageConstraints(
filterAndExpandTestCaseEvents(cucumberQuery, allTestCasesStarted, {
hideStatuses,
tagExpression,
})
),
[allTestCasesStarted, cucumberQuery, hideStatuses, tagExpression]
)
const searchResult = useSearchResult()
const [results, setResults] = useState<ReadonlyArray<GherkinDocument>>()
const setResultsSorting: Dispatch<ReadonlyArray<GherkinDocument>> = useCallback((unsorted) => {
const sorted = [...unsorted]
Expand All @@ -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
}
151 changes: 151 additions & 0 deletions src/hooks/useFilteredTestCases.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof EnvelopesProvider>[0]['envelopes']
defaultQuery?: string
defaultHideStatuses?: readonly TestStepResultStatus[]
}

function renderAndExtractPickleNames({
envelopes,
defaultQuery,
defaultHideStatuses,
}: ProviderProps) {
return renderHook(() => useFilteredTestCases().map(({ pickle }) => pickle.name), {
wrapper: ({ children }) => (
<EnvelopesProvider envelopes={envelopes}>
<InMemorySearchProvider
defaultQuery={defaultQuery}
defaultHideStatuses={defaultHideStatuses}
>
{children}
</InMemorySearchProvider>
</EnvelopesProvider>
),
})
}

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')
})
})
})
Loading