= ({ className }) => {
- const { cucumberQuery, gherkinQuery, envelopesQuery } = useQueries()
+ const { envelopesQuery } = useQueries()
+ const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } = useResultStatistics()
const { query, hideStatuses, update } = useSearch()
- const allDocuments = gherkinQuery.getGherkinDocuments()
-
- const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } =
- countScenariosByStatuses(gherkinQuery, cucumberQuery, envelopesQuery)
-
- const search = new Search(gherkinQuery)
- for (const gherkinDocument of allDocuments) {
- search.add(gherkinDocument)
- }
-
- const onlyShowStatuses = statusesWithScenarios.filter((s) => !hideStatuses.includes(s))
-
- const matches = query ? search.search(query) : allDocuments
- const filtered = matches
- .map((document) => filterByStatus(document, gherkinQuery, cucumberQuery, onlyShowStatuses))
- .filter((document) => document !== null) as GherkinDocument[]
+ const filtered = useFilteredDocuments(query, hideStatuses)
return (
@@ -52,15 +36,22 @@ export const FilteredResults: React.FunctionComponent = ({ className })
/>
update({ query })}
+ onSearch={(newValue) => update({ query: newValue })}
statusesWithScenarios={statusesWithScenarios}
hideStatuses={hideStatuses}
- onFilter={(hideStatuses) => update({ hideStatuses })}
+ onFilter={(newValue) => update({ hideStatuses: newValue })}
/>
- {filtered.length > 0 && }
- {filtered.length < 1 && }
+ {filtered !== undefined && (
+ <>
+ {filtered.length > 0 ? (
+
+ ) : (
+
+ )}
+ >
+ )}
)
}
diff --git a/src/components/app/HighLight.tsx b/src/components/app/HighLight.tsx
index 3208f2cf..dbb10887 100644
--- a/src/components/app/HighLight.tsx
+++ b/src/components/app/HighLight.tsx
@@ -1,4 +1,4 @@
-import elasticlunr from 'elasticlunr'
+import { stemmer } from '@orama/stemmers/english'
import highlightWords from 'highlight-words'
import React from 'react'
import ReactMarkdown from 'react-markdown'
@@ -15,7 +15,7 @@ interface IProps {
const allQueryWords = (queryWords: string[]): string[] => {
return queryWords.reduce((allWords, word) => {
- const stem = elasticlunr.stemmer(word)
+ const stem = stemmer(word)
allWords.push(word)
if (stem !== word) {
diff --git a/src/components/app/SearchBar.spec.tsx b/src/components/app/SearchBar.spec.tsx
index 022111e9..6376b566 100644
--- a/src/components/app/SearchBar.spec.tsx
+++ b/src/components/app/SearchBar.spec.tsx
@@ -23,6 +23,27 @@ describe('SearchBar', () => {
expect(getByRole('textbox', { name: 'Search' })).to.have.value('keyword')
})
+ it('fires an event after half a second when the user types a query', async () => {
+ const onChange = sinon.fake()
+ const { getByRole } = render(
+
+ )
+
+ await userEvent.type(getByRole('textbox', { name: 'Search' }), 'search text')
+
+ expect(onChange).not.to.have.been.called
+
+ await new Promise((resolve) => setTimeout(resolve, 500))
+
+ expect(onChange).to.have.been.called
+ })
+
it('fires an event with the query when the form is submitted', async () => {
const onChange = sinon.fake()
const { getByRole } = render(
diff --git a/src/components/app/SearchBar.tsx b/src/components/app/SearchBar.tsx
index e6ef814a..2a672b9d 100644
--- a/src/components/app/SearchBar.tsx
+++ b/src/components/app/SearchBar.tsx
@@ -2,6 +2,7 @@ import { TestStepResultStatus as Status } from '@cucumber/messages'
import { faFilter, faSearch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { FunctionComponent } from 'react'
+import { useDebouncedCallback } from 'use-debounce'
import statusName from '../gherkin/statusName.js'
import styles from './SearchBar.module.scss'
@@ -22,11 +23,12 @@ export const SearchBar: FunctionComponent = ({
onSearch,
onFilter,
}) => {
+ const debouncedSearchChange = useDebouncedCallback((newValue) => {
+ onSearch(newValue)
+ }, 500)
const searchSubmitted = (event: React.FormEvent) => {
event.preventDefault()
- const formData = new window.FormData(event.currentTarget)
- const query = formData.get('query')
- onSearch((query || '').toString())
+ debouncedSearchChange.flush()
}
const filterChanged = (name: Status, show: boolean) => {
onFilter(show ? hideStatuses.filter((s) => s !== name) : hideStatuses.concat(name))
@@ -42,6 +44,7 @@ export const SearchBar: FunctionComponent = ({
name="query"
placeholder="Search with text or @tags"
defaultValue={query}
+ onChange={(e) => debouncedSearchChange(e.target.value)}
/>
You can search with plain text or{' '}
diff --git a/src/countScenariosByStatuses.ts b/src/countScenariosByStatuses.ts
index ba8050eb..d5910803 100644
--- a/src/countScenariosByStatuses.ts
+++ b/src/countScenariosByStatuses.ts
@@ -1,9 +1,19 @@
-import { GherkinDocumentWalker, Query as GherkinQuery } from '@cucumber/gherkin-utils'
+import { Query as GherkinQuery } from '@cucumber/gherkin-utils'
import { getWorstTestStepResult, TestStepResultStatus } from '@cucumber/messages'
import { Query as CucumberQuery } from '@cucumber/query'
import { EnvelopesQuery } from './EnvelopesQueryContext.js'
+export const allStatuses = [
+ TestStepResultStatus.UNKNOWN,
+ TestStepResultStatus.SKIPPED,
+ TestStepResultStatus.FAILED,
+ TestStepResultStatus.PASSED,
+ TestStepResultStatus.AMBIGUOUS,
+ TestStepResultStatus.PENDING,
+ TestStepResultStatus.UNDEFINED,
+] as const
+
export function makeEmptyScenarioCountsByStatus(): Record {
return {
[TestStepResultStatus.UNKNOWN]: 0,
@@ -27,28 +37,15 @@ export default function countScenariosByStatuses(
} {
const scenarioCountByStatus = makeEmptyScenarioCountsByStatus()
- for (const gherkinDocument of gherkinQuery.getGherkinDocuments()) {
- const counter = new GherkinDocumentWalker(
- {},
- {
- handleScenario: (scenario) => {
- if (!gherkinDocument.uri) throw new Error('Missing uri on gherkinDocument')
- const pickleIds = gherkinQuery.getPickleIds(gherkinDocument.uri, scenario.id)
-
- pickleIds.forEach((pickleId) => {
- // if no test case then this pickle was omitted by filtering e.g. tags
- if (envelopesQuery.hasTestCase(pickleId)) {
- const status = getWorstTestStepResult(
- cucumberQuery.getPickleTestStepResults([pickleId])
- ).status
- scenarioCountByStatus[status] = scenarioCountByStatus[status] + 1
- }
- })
- },
- }
- )
- counter.walkGherkinDocument(gherkinDocument)
- }
+ gherkinQuery
+ .getPickles()
+ .filter((pickle) => envelopesQuery.hasTestCase(pickle.id))
+ .forEach((pickle) => {
+ const status = getWorstTestStepResult(
+ cucumberQuery.getPickleTestStepResults([pickle.id])
+ ).status
+ scenarioCountByStatus[status] = scenarioCountByStatus[status] + 1
+ })
const statusesWithScenarios = Object.entries(scenarioCountByStatus)
.filter(([, value]) => {
diff --git a/src/filter/filterByStatus.spec.ts b/src/filter/filterByStatus.spec.ts
index a5d71285..6bf33afc 100644
--- a/src/filter/filterByStatus.spec.ts
+++ b/src/filter/filterByStatus.spec.ts
@@ -7,6 +7,7 @@ import { Query as CucumberQuery } from '@cucumber/query'
import { expect } from 'chai'
import { FailingHook, runFeature } from '../../test-utils/index.js'
+import { EnvelopesQuery } from '../EnvelopesQueryContext.js'
import filterByStatus from './filterByStatus.js'
const sourceReference: SourceReference = {}
@@ -24,6 +25,7 @@ function scenarioNames(gherkinDocument: messages.GherkinDocument): string[] {
describe('filterByStatus', () => {
let gherkinQuery: GherkinQuery
let cucumberQuery: CucumberQuery
+ let envelopesQuery: EnvelopesQuery
let supportCode: SupportCode
const feature = `
@@ -42,6 +44,7 @@ Feature: statuses
beforeEach(() => {
gherkinQuery = new GherkinQuery()
cucumberQuery = new CucumberQuery()
+ envelopesQuery = new EnvelopesQuery()
supportCode = new SupportCode()
supportCode.defineStepDefinition(sourceReference, 'a passed step', () => null)
supportCode.defineStepDefinition(sourceReference, 'a failed step', () => {
@@ -52,34 +55,55 @@ Feature: statuses
it('only accepts scenarios having one of the expected results', async () => {
const emitted = await runFeature(feature, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
- const passedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.PASSED,
- ])
+ const passedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.PASSED]
+ )
expect(scenarioNames(passedScenarios!)).to.deep.eq(['passed'])
- const failedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.FAILED,
- ])
+ const failedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.FAILED]
+ )
expect(scenarioNames(failedScenarios!)).to.deep.eq(['failed'])
- const undefinedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.UNDEFINED,
- ])
+ const undefinedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.UNDEFINED]
+ )
expect(scenarioNames(undefinedScenarios!)).to.deep.eq(['undefined'])
})
it('can filter with multiple statuses', async () => {
const emitted = await runFeature(feature, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
- const passedAndFailedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.PASSED,
- messages.TestStepResultStatus.FAILED,
- ])
+ const passedAndFailedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.PASSED, messages.TestStepResultStatus.FAILED]
+ )
expect(scenarioNames(passedAndFailedScenarios!)).to.deep.eq(['passed', 'failed'])
})
@@ -97,11 +121,18 @@ Feature: statuses
it('does not keep scenarios when no result matches', async () => {
const emitted = await runFeature(featureWithExamples, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
-
- const pendingScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.PENDING,
- ])
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
+
+ const pendingScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.PENDING]
+ )
expect(scenarioNames(pendingScenarios!)).to.deep.eq([])
})
@@ -110,11 +141,18 @@ Feature: statuses
const emitted = await runFeature(featureWithExamples, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
-
- const onlyPassedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.PASSED,
- ])
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
+
+ const onlyPassedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.PASSED]
+ )
expect(pretty(onlyPassedScenarios!)).to.deep.eq(featureWithExamples)
})
@@ -126,11 +164,18 @@ Feature: statuses
const emitted = await runFeature(feature, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
-
- const onlyFailedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.FAILED,
- ])
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
+
+ const onlyFailedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.FAILED]
+ )
expect(scenarioNames(onlyFailedScenarios!)).to.deep.eq(['passed', 'failed', 'undefined'])
})
@@ -142,11 +187,18 @@ Feature: statuses
const emitted = await runFeature(feature, gherkinQuery, supportCode)
const gherkinDocument = emitted.find((envelope) => envelope.gherkinDocument)!.gherkinDocument
- emitted.map((message) => cucumberQuery.update(message))
-
- const onlyFailedScenarios = filterByStatus(gherkinDocument!, gherkinQuery, cucumberQuery, [
- messages.TestStepResultStatus.FAILED,
- ])
+ emitted.forEach((message) => {
+ cucumberQuery.update(message)
+ envelopesQuery.update(message)
+ })
+
+ const onlyFailedScenarios = filterByStatus(
+ gherkinDocument!,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ [messages.TestStepResultStatus.FAILED]
+ )
expect(scenarioNames(onlyFailedScenarios!)).to.deep.eq(['passed', 'failed', 'undefined'])
})
diff --git a/src/filter/filterByStatus.ts b/src/filter/filterByStatus.ts
index 91d035f6..ffd1654d 100644
--- a/src/filter/filterByStatus.ts
+++ b/src/filter/filterByStatus.ts
@@ -4,10 +4,13 @@ import * as messages from '@cucumber/messages'
import { getWorstTestStepResult } from '@cucumber/messages'
import { Query as CucumberQuery } from '@cucumber/query'
+import { EnvelopesQuery } from '../EnvelopesQueryContext.js'
+
export default function filterByStatus(
gherkinDocument: messages.GherkinDocument,
gherkinQuery: GherkinQuery,
cucumberQuery: CucumberQuery,
+ envelopesQuery: EnvelopesQuery,
statuses: readonly messages.TestStepResultStatus[]
): messages.GherkinDocument | null {
const filters = {
@@ -16,12 +19,12 @@ export default function filterByStatus(
const pickleIds = gherkinQuery.getPickleIds(gherkinDocument.uri, scenario.id)
return pickleIds
- .map((pickleId) =>
+ .filter((pickleId) => envelopesQuery.hasTestCase(pickleId))
+ .some((pickleId) =>
statuses.includes(
getWorstTestStepResult(cucumberQuery.getPickleTestStepResults([pickleId])).status
)
)
- .includes(true)
},
}
diff --git a/src/hooks/useFilteredDocuments.ts b/src/hooks/useFilteredDocuments.ts
new file mode 100644
index 00000000..4680de8d
--- /dev/null
+++ b/src/hooks/useFilteredDocuments.ts
@@ -0,0 +1,41 @@
+import { GherkinDocument, TestStepResultStatus } from '@cucumber/messages'
+import { useEffect, useState } from 'react'
+
+import { allStatuses } from '../countScenariosByStatuses.js'
+import filterByStatus from '../filter/filterByStatus.js'
+import { createSearch, Searchable } from '../search/index.js'
+import { useQueries } from './useQueries.js'
+
+export function useFilteredDocuments(
+ query: string,
+ hideStatuses: readonly TestStepResultStatus[]
+): GherkinDocument[] | undefined {
+ const { gherkinQuery, cucumberQuery, envelopesQuery } = useQueries()
+ const [searchable, setSearchable] = useState()
+ const [results, setResults] = useState()
+ useEffect(() => {
+ createSearch(gherkinQuery).then((created) => setSearchable(created))
+ }, [gherkinQuery])
+ useEffect(() => {
+ if (!searchable) {
+ return
+ }
+ ;(query ? searchable.search(query) : Promise.resolve(gherkinQuery.getGherkinDocuments())).then(
+ (searched) => {
+ const filtered = searched
+ .map((document) =>
+ filterByStatus(
+ document,
+ gherkinQuery,
+ cucumberQuery,
+ envelopesQuery,
+ allStatuses.filter((s) => !hideStatuses.includes(s))
+ )
+ )
+ .filter((document) => document !== null) as GherkinDocument[]
+ setResults(filtered)
+ }
+ )
+ }, [query, hideStatuses, gherkinQuery, cucumberQuery, envelopesQuery, searchable])
+ return results
+}
diff --git a/src/hooks/useResultStatistics.ts b/src/hooks/useResultStatistics.ts
new file mode 100644
index 00000000..e062e605
--- /dev/null
+++ b/src/hooks/useResultStatistics.ts
@@ -0,0 +1,7 @@
+import countScenariosByStatuses from '../countScenariosByStatuses.js'
+import { useQueries } from './useQueries.js'
+
+export function useResultStatistics() {
+ const { gherkinQuery, cucumberQuery, envelopesQuery } = useQueries()
+ return countScenariosByStatuses(gherkinQuery, cucumberQuery, envelopesQuery)
+}
diff --git a/src/search/FeatureSearch.spec.ts b/src/search/FeatureSearch.spec.ts
index 1c22c20f..0cc8f587 100644
--- a/src/search/FeatureSearch.spec.ts
+++ b/src/search/FeatureSearch.spec.ts
@@ -1,45 +1,46 @@
import * as messages from '@cucumber/messages'
+import { Feature, GherkinDocument } from '@cucumber/messages'
import { expect } from 'chai'
import { makeFeature } from '../../test-utils/index.js'
-import FeatureSearch from './FeatureSearch.js'
+import { createFeatureSearch } from './FeatureSearch.js'
+import { TypedIndex } from './types.js'
describe('FeatureSearch', () => {
- let featureSearch: FeatureSearch
+ let featureSearch: TypedIndex
let gherkinDocument: messages.GherkinDocument
- beforeEach(() => {
- featureSearch = new FeatureSearch()
+ beforeEach(async () => {
+ featureSearch = await createFeatureSearch()
gherkinDocument = {
uri: 'some/feature.file',
comments: [],
feature: makeFeature('this exists', 'description feature', []),
}
-
- featureSearch.add(gherkinDocument)
+ await featureSearch.add(gherkinDocument)
})
describe('#search', () => {
- it('returns an empty array when there are no hits', () => {
- const searchResult = featureSearch.search('banana')
+ it('returns an empty array when there are no hits', async () => {
+ const searchResult = await featureSearch.search('banana')
expect(searchResult).to.deep.eq([])
})
- it('finds results with equal feature name', () => {
- const searchResult = featureSearch.search('this exists')
+ it('finds results with equal feature name', async () => {
+ const searchResult = await featureSearch.search('this exists')
expect(searchResult).to.deep.eq([gherkinDocument.feature])
})
- it('finds results with substring of feature name', () => {
- const searchResult = featureSearch.search('exists')
+ it('finds results with substring of feature name', async () => {
+ const searchResult = await featureSearch.search('exists')
expect(searchResult).to.deep.eq([gherkinDocument.feature])
})
- it('finds results with equal feature description', () => {
- const searchResult = featureSearch.search('description')
+ it('finds results with equal feature description', async () => {
+ const searchResult = await featureSearch.search('description')
expect(searchResult).to.deep.eq([gherkinDocument.feature])
})
diff --git a/src/search/FeatureSearch.ts b/src/search/FeatureSearch.ts
index 1d050f73..e957f16e 100644
--- a/src/search/FeatureSearch.ts
+++ b/src/search/FeatureSearch.ts
@@ -1,47 +1,51 @@
-import * as messages from '@cucumber/messages'
-import elasticlunr from 'elasticlunr'
-
-interface SearchableFeature {
- uri: string
- name: string
- description: string
-}
-
-export default class FeatureSearch {
- private readonly featuresByUri = new Map()
- private readonly index = elasticlunr((ctx) => {
- ctx.setRef('uri')
- ctx.addField('name')
- ctx.addField('description')
- ctx.saveDocument(true)
- })
+import { Feature, GherkinDocument } from '@cucumber/messages'
+import { create, insert, Orama, search } from '@orama/orama'
+
+import { TypedIndex } from './types.js'
+
+const schema = {
+ name: 'string',
+ description: 'string',
+} as const
+
+/**
+ * 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 {
+ private readonly featuresByUri = new Map()
+ private readonly index: Orama
+
+ constructor(index: Orama) {
+ this.index = index
+ }
- public add(gherkinDocument: messages.GherkinDocument) {
- if (!gherkinDocument.feature) return
+ async search(term: string): Promise> {
+ const { hits } = await search(this.index, {
+ term,
+ })
+ return hits.map((hit) => this.featuresByUri.get(hit.id)) as Feature[]
+ }
+ async add(gherkinDocument: GherkinDocument): Promise {
+ if (!gherkinDocument.feature) return this
if (!gherkinDocument.uri) throw new Error('Missing uri on gherkinDocument')
-
this.featuresByUri.set(gherkinDocument.uri, gherkinDocument.feature)
-
- this.index.addDoc({
- uri: gherkinDocument.uri,
+ await insert(this.index, {
+ id: gherkinDocument.uri,
name: gherkinDocument.feature.name,
description: gherkinDocument.feature.description,
})
+ return this
}
+}
- public search(query: string): messages.Feature[] {
- const searchResultsList = this.index.search(query, {
- fields: {
- name: { bool: 'OR', boost: 1 },
- description: { bool: 'OR', boost: 1 },
- },
- })
-
- return searchResultsList.map((searchResults) => {
- const feature = this.featuresByUri.get(searchResults.ref)
- if (!feature) throw new Error(`No feature for ref ${searchResults.ref}`)
- return feature
- })
- }
+export async function createFeatureSearch(): Promise> {
+ const index: Orama = await create({
+ schema,
+ sort: {
+ enabled: false,
+ },
+ })
+ return new FeatureSearch(index)
}
diff --git a/src/search/RuleSearch.spec.ts b/src/search/RuleSearch.spec.ts
deleted file mode 100644
index ae345729..00000000
--- a/src/search/RuleSearch.spec.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import * as messages from '@cucumber/messages'
-import { expect } from 'chai'
-
-import { makeRule } from '../../test-utils/index.js'
-import RuleSearch from './RuleSearch.js'
-
-describe('RuleSearch', () => {
- let ruleSearch: RuleSearch
- let rules: messages.Rule[]
-
- beforeEach(() => {
- ruleSearch = new RuleSearch()
-
- rules = [
- makeRule('first rule', 'a little description', []),
- makeRule('second rule', 'a long description', []),
- makeRule('third rule', 'description', []),
- ]
-
- for (const rule of rules) {
- ruleSearch.add(rule)
- }
- })
-
- describe('#search', () => {
- it('returns an empty list when there is no hits', () => {
- const searchResults = ruleSearch.search('no match there')
- expect(searchResults).to.deep.eq([])
- })
-
- it('returns rule which name match the query', () => {
- const searchResults = ruleSearch.search('second')
- expect(searchResults).to.deep.eq([rules[1]])
- })
-
- it('returns rule which name match the query in description', () => {
- const searchResults = ruleSearch.search('little')
- expect(searchResults).to.deep.eq([rules[0]])
- })
- })
-})
diff --git a/src/search/RuleSearch.ts b/src/search/RuleSearch.ts
deleted file mode 100644
index 25612226..00000000
--- a/src/search/RuleSearch.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as messages from '@cucumber/messages'
-import elasticlunr from 'elasticlunr'
-
-interface SearchableRule {
- id: string
- name: string
- description: string
-}
-
-export default class RuleSearch {
- private readonly index = elasticlunr((ctx) => {
- ctx.setRef('id')
- ctx.addField('name')
- ctx.addField('description')
- ctx.saveDocument(true)
- })
- private ruleById = new Map()
-
- public add(rule: messages.Rule): void {
- this.index.addDoc({
- id: rule.id,
- name: rule.name,
- description: rule.description,
- })
- this.ruleById.set(rule.id, rule)
- }
-
- public search(query: string): messages.Rule[] {
- const results = this.index.search(query, {
- fields: {
- name: { bool: 'OR', boost: 1 },
- description: { bool: 'OR', boost: 1 },
- },
- })
-
- return results.map((result) => this.get(result.ref))
- }
-
- private get(ref: string): messages.Rule {
- const rule = this.ruleById.get(ref)
- if (!rule) throw new Error(`No rule for ref ${ref}`)
- return rule
- }
-}
diff --git a/src/search/ScenarioLikeSearch.spec.ts b/src/search/ScenarioLikeSearch.spec.ts
new file mode 100644
index 00000000..3d4caa31
--- /dev/null
+++ b/src/search/ScenarioLikeSearch.spec.ts
@@ -0,0 +1,51 @@
+import * as messages from '@cucumber/messages'
+import { Scenario } from '@cucumber/messages'
+import { expect } from 'chai'
+
+import { makeScenario } from '../../test-utils/index.js'
+import { createScenarioLikeSearch } from './ScenarioLikeSearch.js'
+import { TypedIndex } from './types.js'
+
+describe('ScenarioLikeSearch', () => {
+ let scenarioSearch: TypedIndex
+ let scenarios: messages.Scenario[]
+
+ beforeEach(async () => {
+ scenarioSearch = await createScenarioLikeSearch()
+
+ scenarios = [
+ makeScenario('a passed scenario', 'a little description', []),
+ makeScenario('another passed scenario', 'a long description of the scenario', []),
+ makeScenario('a failed scenario', 'description', []),
+ ]
+
+ for (const scenario of scenarios) {
+ await scenarioSearch.add(scenario)
+ }
+ })
+
+ describe('#search', () => {
+ it('returns an empty list when there is no hits', async () => {
+ const searchResults = await scenarioSearch.search('no match there')
+ expect(searchResults).to.deep.eq([])
+ })
+
+ it('returns scenario which name match the query', async () => {
+ const searchResults = await scenarioSearch.search('failed')
+ expect(searchResults).to.deep.eq([scenarios[2]])
+ })
+
+ it('may not return results in the original order', async () => {
+ const searchResults = await scenarioSearch.search('scenario')
+
+ for (const scenario of scenarios) {
+ expect(searchResults).to.contain(scenario)
+ }
+ })
+
+ it('returns scenario which description match the query', async () => {
+ const searchResults = await scenarioSearch.search('little')
+ expect(searchResults).to.deep.eq([scenarios[0]])
+ })
+ })
+})
diff --git a/src/search/ScenarioLikeSearch.ts b/src/search/ScenarioLikeSearch.ts
new file mode 100644
index 00000000..6792d4d0
--- /dev/null
+++ b/src/search/ScenarioLikeSearch.ts
@@ -0,0 +1,56 @@
+import { create, insert, Orama, search } from '@orama/orama'
+
+import { TypedIndex } from './types.js'
+
+const schema = {
+ name: 'string',
+ description: 'string',
+} as const
+
+export interface ScenarioLike {
+ id: string
+ name: string
+ description: string
+}
+
+/**
+ * Can be used for Backgrounds, Scenarios and Rules, searching against the
+ * name and description
+ */
+class ScenarioLikeSearch implements TypedIndex {
+ private itemById = new Map()
+ private readonly index: Orama
+
+ constructor(index: Orama) {
+ this.index = index
+ }
+
+ async search(term: string): Promise> {
+ const { hits } = await search(this.index, {
+ term,
+ })
+ return hits.map((hit) => this.itemById.get(hit.id)) as T[]
+ }
+
+ async add(item: T): Promise {
+ this.itemById.set(item.id, item)
+ await insert(this.index, {
+ id: item.id,
+ name: item.name,
+ description: item.description,
+ })
+ 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/ScenarioSearch.spec.ts b/src/search/ScenarioSearch.spec.ts
deleted file mode 100644
index 2fa63270..00000000
--- a/src/search/ScenarioSearch.spec.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import * as messages from '@cucumber/messages'
-import { expect } from 'chai'
-
-import { makeScenario } from '../../test-utils/index.js'
-import ScenarioSearch from './ScenarioSearch.js'
-
-describe('ScenarioSearch', () => {
- let scenarioSearch: ScenarioSearch
- let scenarios: messages.Scenario[]
-
- beforeEach(() => {
- scenarioSearch = new ScenarioSearch()
-
- scenarios = [
- makeScenario('a passed scenario', 'a little description', []),
- makeScenario('another passed scenario', 'a long description of the scenario', []),
- makeScenario('a failed scenario', 'description', []),
- ]
-
- for (const scenario of scenarios) {
- scenarioSearch.add(scenario)
- }
- })
-
- describe('#search', () => {
- it('returns an empty list when there is no hits', () => {
- const searchResults = scenarioSearch.search('no match there')
- expect(searchResults).to.deep.eq([])
- })
-
- it('returns scenario which name match the query', () => {
- const searchResults = scenarioSearch.search('failed')
- expect(searchResults).to.deep.eq([scenarios[2]])
- })
-
- it('may not return results in the original order', () => {
- const searchResults = scenarioSearch.search('scenario')
-
- for (const scenario of scenarios) {
- expect(searchResults).to.contain(scenario)
- }
- })
-
- it('returns scenario which description match the query', () => {
- const searchResults = scenarioSearch.search('little')
- expect(searchResults).to.deep.eq([scenarios[0]])
- })
- })
-})
diff --git a/src/search/ScenarioSearch.ts b/src/search/ScenarioSearch.ts
deleted file mode 100644
index 6071084a..00000000
--- a/src/search/ScenarioSearch.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import * as messages from '@cucumber/messages'
-import elasticlunr from 'elasticlunr'
-
-interface SearchableScenario {
- id: string
- name: string
- description: string
-}
-
-export default class ScenarioSearch {
- private readonly index = elasticlunr((ctx) => {
- ctx.setRef('id')
- ctx.addField('name')
- ctx.addField('description')
- ctx.saveDocument(true)
- })
- private scenarioById = new Map()
-
- public add(scenario: messages.Scenario): void {
- this.index.addDoc({
- id: scenario.id,
- name: scenario.name,
- description: scenario.description,
- })
- this.scenarioById.set(scenario.id, scenario)
- }
-
- public search(query: string): messages.Scenario[] {
- const results = this.index.search(query, {
- fields: {
- name: { bool: 'OR', boost: 1 },
- description: { bool: 'OR', boost: 1 },
- },
- })
-
- return results.map((result) => this.get(result.ref))
- }
-
- private get(ref: string): messages.Scenario {
- const rule = this.scenarioById.get(ref)
- if (!rule) throw new Error(`No scenario for ref ${ref}`)
- return rule
- }
-}
diff --git a/src/search/Search.spec.ts b/src/search/Search.spec.ts
index 568d251e..56759daf 100644
--- a/src/search/Search.spec.ts
+++ b/src/search/Search.spec.ts
@@ -3,10 +3,9 @@ import { pretty, Query as GherkinQuery } from '@cucumber/gherkin-utils'
import * as messages from '@cucumber/messages'
import { expect } from 'chai'
-import Search from './Search.js'
+import { createSearch } from './Search.js'
describe('Search', () => {
- let search: Search
let gherkinQuery: GherkinQuery
const feature = `Feature: Solar System
@@ -22,10 +21,9 @@ describe('Search', () => {
beforeEach(() => {
gherkinQuery = new GherkinQuery()
- search = new Search(gherkinQuery)
})
- function prettyResults(feature: string, query: string): string {
+ async function prettyResults(feature: string, query: string): Promise {
const envelopes = generateMessages(
feature,
'test.feature',
@@ -40,18 +38,14 @@ describe('Search', () => {
for (const envelope of envelopes) {
gherkinQuery.update(envelope)
}
- for (const envelope of envelopes) {
- if (envelope.gherkinDocument) {
- search.add(envelope.gherkinDocument)
- }
- }
- return pretty(search.search(query)[0])
+ 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', () => {
- const results = prettyResults(feature, '@planet')
+ it('uses TagSearch to filter the results', async () => {
+ const results = await prettyResults(feature, '@planet')
expect(results).to.eq(
`Feature: Solar System
@@ -62,8 +56,8 @@ describe('Search', () => {
)
})
- it('does not raises error when tag expression is incorrect', () => {
- const results = prettyResults(feature, '(@planet or @dwarf))')
+ 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
@@ -80,8 +74,8 @@ describe('Search', () => {
})
describe('when using a query which is not a tag expression', () => {
- it('uses TextSearch to filter the results', () => {
- const results = prettyResults(feature, 'not really (')
+ it('uses TextSearch to filter the results', async () => {
+ const results = await prettyResults(feature, 'not really')
expect(results).to.eq(
`Feature: Solar System
diff --git a/src/search/Search.ts b/src/search/Search.ts
index cc84ded7..473d2068 100644
--- a/src/search/Search.ts
+++ b/src/search/Search.ts
@@ -1,22 +1,18 @@
import { Query as GherkinQuery } from '@cucumber/gherkin-utils'
import * as messages from '@cucumber/messages'
-import isTagExpression from '../../src/isTagExpression.js'
-import TagSearch from '../../src/search/TagSearch.js'
-import TextSearch from '../../src/search/TextSearch.js'
+import isTagExpression from '../isTagExpression.js'
+import { createTagSearch } from './TagSearch.js'
+import { createTextSearch } from './TextSearch.js'
+import { Searchable } from './types.js'
-export default class Search {
- private readonly tagSearch: TagSearch
- private readonly textSearch = new TextSearch()
+class Search {
+ constructor(private readonly tagSearch: Searchable, private readonly textSearch: Searchable) {}
- constructor(private readonly gherkinQuery: GherkinQuery) {
- this.tagSearch = new TagSearch(gherkinQuery)
- }
-
- public search(query: string): messages.GherkinDocument[] {
+ public async search(query: string): Promise {
if (isTagExpression(query)) {
try {
- return this.tagSearch.search(query)
+ return await this.tagSearch.search(query)
} catch {
// No-op, we fall back to text search.
}
@@ -25,8 +21,28 @@ export default class Search {
return this.textSearch.search(query)
}
- public add(gherkinDocument: messages.GherkinDocument) {
- this.tagSearch.add(gherkinDocument)
- this.textSearch.add(gherkinDocument)
+ public async add(gherkinDocument: messages.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 dcc9e416..2dd63fbd 100644
--- a/src/search/StepSearch.spec.ts
+++ b/src/search/StepSearch.spec.ts
@@ -1,15 +1,17 @@
import * as messages from '@cucumber/messages'
+import { Step } from '@cucumber/messages'
import { expect } from 'chai'
import { makeStep } from '../../test-utils/index.js'
-import StepSearch from './StepSearch.js'
+import { createStepSearch } from './StepSearch.js'
+import { TypedIndex } from './types.js'
describe('StepSearch', () => {
- let stepSearch: StepSearch
+ let stepSearch: TypedIndex
let steps: messages.Step[]
- beforeEach(() => {
- stepSearch = new StepSearch()
+ beforeEach(async () => {
+ stepSearch = await createStepSearch()
steps = [
makeStep('Given', 'a passed step', 'There is a docstring here'),
@@ -22,47 +24,43 @@ describe('StepSearch', () => {
]
for (const step of steps) {
- stepSearch.add(step)
+ await stepSearch.add(step)
}
})
describe('#search', () => {
- it('returns an empty list when there is no hits', () => {
- const searchResults = stepSearch.search('no match there')
+ it('returns an empty list when there is no hits', async () => {
+ const searchResults = await stepSearch.search('no match')
expect(searchResults).to.deep.eq([])
})
- it('returns step which text match the query', () => {
- const searchResults = stepSearch.search('failed')
+ it('returns step which text match the query', async () => {
+ const searchResults = await stepSearch.search('failed')
expect(searchResults).to.deep.eq([steps[2]])
})
- it('may not return results in the original order', () => {
- const searchResults = stepSearch.search('step')
+ it('may not return results in the original order', async () => {
+ const searchResults = await stepSearch.search('step')
for (const step of steps) {
expect(searchResults).to.contain(step)
}
})
- it('returns step which keyword match the query', () => {
- const searchResults = stepSearch.search('Given')
- expect(searchResults).to.deep.eq([steps[0]])
- })
-
- xit('it does not exclude "Then" and "When" from indexing', () => {
- // By default, ElasticLurn exclude some words from indexing/searching,
- // amongst them are 'Then' and 'When'.
- // See: http://elasticlunr.com/docs/stop_word_filter.js.html#resetStopWords
+ it('returns step which keyword match the query', async () => {
+ for (const step of steps) {
+ const searchResults = await stepSearch.search(step.keyword)
+ expect(searchResults).to.deep.eq([step])
+ }
})
- it('returns step which DocString matches the query', () => {
- const searchResults = stepSearch.search('docstring')
+ it('returns step which DocString matches the query', async () => {
+ const searchResults = await stepSearch.search('docstring')
expect(searchResults).to.deep.eq([steps[0]])
})
- it('returns step which datatable matches the query', () => {
- const searchResults = stepSearch.search('NullPointerException')
+ it('returns step which datatable matches the query', async () => {
+ const searchResults = await stepSearch.search('NullPointerException')
expect(searchResults).to.deep.eq([steps[2]])
})
})
diff --git a/src/search/StepSearch.ts b/src/search/StepSearch.ts
index 624e7a1f..d02a5742 100644
--- a/src/search/StepSearch.ts
+++ b/src/search/StepSearch.ts
@@ -1,62 +1,52 @@
-import * as messages from '@cucumber/messages'
-import elasticlunr from 'elasticlunr'
+import { Step } from '@cucumber/messages'
+import { create, insert, Orama, search } from '@orama/orama'
-interface SearchableStep {
- id: string
- keyword: string
- text: string
- docString?: string
- dataTable?: string
-}
+import { TypedIndex } from './types.js'
-export default class StepSearch {
- private readonly index = elasticlunr((ctx) => {
- ctx.addField('keyword')
- ctx.addField('text')
- ctx.addField('docString')
- ctx.addField('dataTable')
- ctx.setRef('id')
- ctx.saveDocument(true)
- })
- private stepById = new Map()
+const schema = {
+ keyword: 'string',
+ text: 'string',
+ docString: 'string',
+ dataTable: 'string[]',
+} as const
- public add(step: messages.Step): void {
- const doc = {
- id: step.id,
- keyword: step.keyword,
- text: step.text,
- docString: step.docString && StepSearch.docStringToString(step.docString),
- dataTable: step.dataTable && StepSearch.dataTableToString(step.dataTable),
- }
+class StepSearch implements TypedIndex {
+ private readonly stepById = new Map()
+ private readonly index: Orama
- this.index.addDoc(doc)
- this.stepById.set(step.id, step)
+ constructor(index: Orama) {
+ this.index = index
}
- public search(query: string): messages.Step[] {
- const results = this.index.search(query, {
- fields: {
- keyword: { bool: 'OR', boost: 1 },
- text: { bool: 'OR', boost: 2 },
- docString: { bool: 'OR', boost: 1 },
- dataTable: { bool: 'OR', boost: 1 },
+ async search(term: string): Promise> {
+ const { hits } = await search(this.index, {
+ term,
+ boost: {
+ text: 2,
},
})
-
- return results.map((result) => this.get(result.ref))
- }
-
- private get(ref: string): messages.Step {
- const rule = this.stepById.get(ref)
- if (!rule) throw new Error(`No step for ref ${ref}`)
- return rule
+ return hits.map((hit) => this.stepById.get(hit.id)) as Step[]
}
- private static docStringToString(docString: messages.DocString): string {
- return docString.content
+ async add(step: Step): Promise {
+ this.stepById.set(step.id, step)
+ await insert(this.index, {
+ id: step.id,
+ keyword: step.keyword,
+ text: step.text,
+ docString: step.docString?.content,
+ dataTable: step.dataTable?.rows.flatMap((row) => row.cells).map((cell) => cell.value),
+ })
+ return this
}
+}
- private static dataTableToString(dataTable: messages.DataTable): string {
- return dataTable.rows.map((row) => row.cells.map((cell) => cell.value).join(' ')).join(' ')
- }
+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
index 5dbff4d8..ee14a462 100644
--- a/src/search/TagSearch.spec.ts
+++ b/src/search/TagSearch.spec.ts
@@ -3,11 +3,10 @@ import { pretty, Query as GherkinQuery } from '@cucumber/gherkin-utils'
import * as messages from '@cucumber/messages'
import { expect } from 'chai'
-import TagSearch from './TagSearch.js'
+import { createTagSearch } from './TagSearch.js'
-describe('TagSearchTest', () => {
+describe('TagSearch', () => {
let gherkinQuery: GherkinQuery
- let tagSearch: TagSearch
const feature = `@system
Feature: Solar System
@@ -23,10 +22,9 @@ Feature: Solar System
beforeEach(() => {
gherkinQuery = new GherkinQuery()
- tagSearch = new TagSearch(gherkinQuery)
})
- function prettyResults(feature: string, query: string): string {
+ async function prettyResults(feature: string, query: string): Promise {
const envelopes = generateMessages(
feature,
'test.feature',
@@ -37,35 +35,35 @@ Feature: Solar System
newId: messages.IdGenerator.incrementing(),
}
)
+ const tagSearch = await createTagSearch(gherkinQuery)
for (const envelope of envelopes) {
gherkinQuery.update(envelope)
}
- for (const envelope of envelopes) {
- if (envelope.gherkinDocument) {
- tagSearch.add(envelope.gherkinDocument)
- }
+ for (const document of gherkinQuery.getGherkinDocuments()) {
+ await tagSearch.add(document)
}
- return pretty(tagSearch.search(query)[0])
+ return pretty((await tagSearch.search(query))[0])
}
describe('search', () => {
- it('returns an empty list when no documents have been added', () => {
- expect(tagSearch.search('@any')).to.deep.eq([])
+ 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', () => {
- expect(prettyResults(feature, '@planet')).to.contain('Scenario: Earth')
+ it('finds matching scenarios', async () => {
+ expect(await prettyResults(feature, '@planet')).to.contain('Scenario: Earth')
})
- it('takes into account feature tags', () => {
- const results = prettyResults(feature, '@system')
+ 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', () => {
- const results = prettyResults(feature, '@system and not @dwarf')
+ 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')
@@ -92,16 +90,16 @@ Feature: Solar system
Examples: giant gas planets
| jupiter | Io, Europe, Ganymède, Callisto |
`
- it('does not filter non-matching examples', () => {
- const results = prettyResults(exampleFeature, '@solid')
+ 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', () => {
- const results = prettyResults(exampleFeature, '@solid and not @gas')
+ 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')
diff --git a/src/search/TagSearch.ts b/src/search/TagSearch.ts
index 55eb310c..ef6154b8 100644
--- a/src/search/TagSearch.ts
+++ b/src/search/TagSearch.ts
@@ -1,11 +1,16 @@
-import { Query as GherkinQuery } from '@cucumber/gherkin-utils'
-import { GherkinDocumentWalker, rejectAllFilters } from '@cucumber/gherkin-utils'
+import {
+ GherkinDocumentWalker,
+ Query as GherkinQuery,
+ rejectAllFilters,
+} from '@cucumber/gherkin-utils'
import * as messages from '@cucumber/messages'
import { GherkinDocument } from '@cucumber/messages'
import parse from '@cucumber/tag-expressions'
import { ArrayMultimap } from '@teppeis/multimaps'
-export default class TagSearch {
+import { Searchable } from './types.js'
+
+class TagSearch {
private readonly pickleById = new Map()
private readonly picklesByScenarioId = new ArrayMultimap()
private gherkinDocuments: messages.GherkinDocument[] = []
@@ -14,7 +19,7 @@ export default class TagSearch {
this.gherkinQuery = gherkinQuery
}
- public search(query: string): messages.GherkinDocument[] {
+ public async search(query: string): Promise {
const expressionNode = parse(query)
const tagFilters = {
acceptScenario: (scenario: messages.Scenario) => {
@@ -38,7 +43,7 @@ export default class TagSearch {
.filter((gherkinDocument) => gherkinDocument !== null) as GherkinDocument[]
}
- public add(gherkinDocument: messages.GherkinDocument) {
+ public async add(gherkinDocument: messages.GherkinDocument) {
this.gherkinDocuments.push(gherkinDocument)
const pickles = this.gherkinQuery.getPickles()
pickles.forEach((pickle) => this.pickleById.set(pickle.id, pickle))
@@ -59,5 +64,11 @@ export default class TagSearch {
}
)
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 a558ba8e..23216385 100644
--- a/src/search/TextSearch.spec.ts
+++ b/src/search/TextSearch.spec.ts
@@ -3,10 +3,11 @@ import { pretty } from '@cucumber/gherkin-utils'
import * as messages from '@cucumber/messages'
import assert from 'assert'
-import TextSearch from './TextSearch.js'
+import { createTextSearch } from './TextSearch.js'
+import { Searchable } from './types.js'
describe('TextSearch', () => {
- let search: TextSearch
+ let search: Searchable
const source = `Feature: Continents
Background: World
@@ -30,17 +31,17 @@ describe('TextSearch', () => {
Given some scientific bases
`
- beforeEach(() => {
+ beforeEach(async () => {
const gherkinDocument = parse(source)
- search = new TextSearch()
- search.add(gherkinDocument)
+ search = await createTextSearch()
+ await search.add(gherkinDocument)
})
describe('Hit found in step', () => {
// TODO: Fix
- xit('displays just one scenario', () => {
- const searchResults = search.search('Spain')
+ xit('displays just one scenario', async () => {
+ const searchResults = await search.search('Spain')
assert.deepStrictEqual(
pretty(searchResults[0]),
@@ -59,8 +60,8 @@ describe('TextSearch', () => {
})
describe('Hit found in scenario', () => {
- xit('displays just one scenario', () => {
- const searchResults = search.search('europe')
+ xit('displays just one scenario', async () => {
+ const searchResults = await search.search('europe')
assert.deepStrictEqual(
pretty(searchResults[0]),
@@ -79,22 +80,22 @@ describe('TextSearch', () => {
})
describe('Hit found in background', () => {
- it('displays all scenarios', () => {
- const searchResults = search.search('world')
+ it('displays all scenarios', async () => {
+ const searchResults = await search.search('world')
assert.deepStrictEqual(pretty(searchResults[0]), source)
})
- it('finds hits in background steps', () => {
- const searchResults = search.search('exists')
+ it('finds hits in background steps', async () => {
+ const searchResults = await search.search('exists')
assert.deepStrictEqual(pretty(searchResults[0]), source)
})
})
describe('Hit found in rule', () => {
- it('displays a rule', () => {
- const searchResults = search.search('uninhabited')
+ it('displays a rule', async () => {
+ const searchResults = await search.search('uninhabited')
assert.deepStrictEqual(
pretty(searchResults[0]),
@@ -114,8 +115,8 @@ describe('TextSearch', () => {
describe('No hit found', () => {
// TODO: Fix
- xit('returns no hits', () => {
- const searchResults = search.search('saturn')
+ xit('returns no hits', async () => {
+ const searchResults = await search.search('saturn')
assert.deepStrictEqual(searchResults, [])
})
diff --git a/src/search/TextSearch.ts b/src/search/TextSearch.ts
index 536d88bb..5e7632ff 100644
--- a/src/search/TextSearch.ts
+++ b/src/search/TextSearch.ts
@@ -1,57 +1,84 @@
import { GherkinDocumentWalker } from '@cucumber/gherkin-utils'
-import * as messages from '@cucumber/messages'
-import { GherkinDocument } from '@cucumber/messages'
-
-import FeatureSearch from './FeatureSearch.js'
-import RuleSearch from './RuleSearch.js'
-import ScenarioSearch from './ScenarioSearch.js'
-import StepSearch from './StepSearch.js'
-
-export default class TextSearch {
- private readonly featureSearch = new FeatureSearch()
- private readonly backgroundSearch = new ScenarioSearch()
- private readonly scenarioSearch = new ScenarioSearch()
- private readonly stepSearch = new StepSearch()
- private readonly ruleSearch = new RuleSearch()
-
- private readonly gherkinDocuments: messages.GherkinDocument[] = []
-
- public search(query: string): messages.GherkinDocument[] {
- const matchingSteps = this.stepSearch.search(query)
- const matchingBackgrounds = this.backgroundSearch.search(query)
- const matchingScenarios = this.scenarioSearch.search(query)
- const matchingRules = this.ruleSearch.search(query)
- const matchingFeatures = this.featureSearch.search(query)
+import { 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 { Searchable, TypedIndex } from './types.js'
+
+class TextSearch {
+ private readonly gherkinDocuments: GherkinDocument[] = []
+
+ private readonly stepSearch: TypedIndex
+ private readonly backgroundSearch: TypedIndex
+ private readonly scenarioSearch: TypedIndex
+ private readonly ruleSearch: TypedIndex
+ private readonly featureSearch: TypedIndex
+
+ constructor(
+ stepSearch: TypedIndex,
+ backgroundSearch: TypedIndex,
+ scenarioSearch: TypedIndex,
+ ruleSearch: TypedIndex,
+ featureSearch: TypedIndex
+ ) {
+ this.stepSearch = stepSearch
+ this.backgroundSearch = backgroundSearch
+ this.scenarioSearch = scenarioSearch
+ this.ruleSearch = ruleSearch
+ this.featureSearch = featureSearch
+ }
+
+ public async search(query: string): Promise {
+ const [matchingSteps, matchingBackgrounds, matchingScenarios, matchingRules, matchingFeatures] =
+ await Promise.all([
+ this.stepSearch.search(query),
+ this.backgroundSearch.search(query),
+ this.scenarioSearch.search(query),
+ this.ruleSearch.search(query),
+ this.featureSearch.search(query),
+ ])
const walker = new GherkinDocumentWalker({
acceptStep: (step) => matchingSteps.includes(step),
+ acceptBackground: (background) => matchingBackgrounds.includes(background),
acceptScenario: (scenario) => matchingScenarios.includes(scenario),
- // TODO: This is an ugly hack to work around the fact that Scenario and Background are no longer interchangeable,
- // because tags is now mandatory.
- acceptBackground: (background) =>
- matchingBackgrounds.includes(background as messages.Scenario),
acceptRule: (rule) => matchingRules.includes(rule),
acceptFeature: (feature) => matchingFeatures.includes(feature),
})
return this.gherkinDocuments
.map((gherkinDocument) => walker.walkGherkinDocument(gherkinDocument))
- .filter((gherkinDocument) => gherkinDocument !== null) as GherkinDocument[]
+ .filter((gherkinDocument) => !!gherkinDocument) as readonly GherkinDocument[]
}
- public add(gherkinDocument: messages.GherkinDocument) {
+ public async add(gherkinDocument: GherkinDocument) {
this.gherkinDocuments.push(gherkinDocument)
+ const promises: Promise[] = []
const walker = new GherkinDocumentWalker(
{},
{
- handleStep: (step) => this.stepSearch.add(step),
- handleScenario: (scenario) => this.scenarioSearch.add(scenario),
- handleBackground: (background) =>
- this.backgroundSearch.add(background as messages.Scenario),
- handleRule: (rule) => this.ruleSearch.add(rule),
+ handleStep: (step) => promises.push(this.stepSearch.add(step)),
+ handleBackground: (background) => promises.push(this.backgroundSearch.add(background)),
+ handleScenario: (scenario) => promises.push(this.scenarioSearch.add(scenario)),
+ handleRule: (rule) => promises.push(this.ruleSearch.add(rule)),
}
)
- this.featureSearch.add(gherkinDocument)
+ promises.push(this.featureSearch.add(gherkinDocument))
walker.walkGherkinDocument(gherkinDocument)
+ await Promise.all(promises)
+ 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/index.ts b/src/search/index.ts
new file mode 100644
index 00000000..290a1505
--- /dev/null
+++ b/src/search/index.ts
@@ -0,0 +1,2 @@
+export { createSearch } from './Search.js'
+export * from './types.js'
diff --git a/src/search/types.ts b/src/search/types.ts
new file mode 100644
index 00000000..edf80c06
--- /dev/null
+++ b/src/search/types.ts
@@ -0,0 +1,15 @@
+import { 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.
+ */
+export interface TypedIndex {
+ search: (query: string) => Promise
+ add: (item: SourceType) => Promise
+}
+
+/**
+ * Shorthand type for an index of Gherkin documents.
+ */
+export type Searchable = TypedIndex
diff --git a/vite.config.mjs b/vite.config.mjs
deleted file mode 100644
index 44d344a9..00000000
--- a/vite.config.mjs
+++ /dev/null
@@ -1,7 +0,0 @@
-/** @type {import('vite').UserConfig} */
-
-export default {
- define: {
- lunr: {}
- }
-}