diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3eaf40..b60f635e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- Made `Lineage` APIs public ([#76](https://github.com/cucumber/query/pull/76)) +- New method `findPickleBy(TestStepStarted)` ([#76](https://github.com/cucumber/query/pull/76)) +- New method `findTestCaseBy(TestStepStarted)` ([#76](https://github.com/cucumber/query/pull/76)) +- New method `findTestCaseStartedBy(TestStepStarted)` ([#76](https://github.com/cucumber/query/pull/76)) +- New method `findTestStepBy(TestStepStarted)` ([#76](https://github.com/cucumber/query/pull/76)) +- New method `findTestStepsStartedBy(TestCaseStarted)` ([#76](https://github.com/cucumber/query/pull/76)) + +### Fixed +- `Query.findAllTestCaseStarted` orders events by `timestamp` and `id` ([#76](https://github.com/cucumber/query/pull/76)) ## [13.2.0] - 2025-02-02 ### Changed diff --git a/README.md b/README.md index 9182d34a..4a33b541 100644 --- a/README.md +++ b/README.md @@ -32,44 +32,9 @@ using `id` fields. It's a bit similar to rows in a relational database, with primary and foreign keys. Consumers of these messages may want to *query* the messages for certain information. -For example, [cucumber-react](https://github.com/cucumber/cucumber-react) needs to know the status of -a [Step](../cucumber-messages/messages.md#io.cucumber.messages.GherkinDocument.Feature.Step) as it -is rendering the [GherkinDocument](../cucumber-messages/messages.md#io.cucumber.messages.GherkinDocument) +For example, [@cucumber/react-components](https://github.com/cucumber/react-components) needs to know the status of +a [Step](https://github.com/cucumber/messages/blob/main/messages.md#step) as it +is rendering the [GherkinDocument](https://github.com/cucumber/messages/blob/main/messages.md#gherkindocument) The Query library makes this easy by providing a function to look up the status of a step, a scenario or an entire file. - -## API - -| Query function | .NET | Go | Java | Ruby | TypeScript | -|-----------------------------------------------------------------------------------------------|------|-----|------|------|------------| -| `getStepResults(uri: string, lineNumber: number): messages.ITestResult[]` | | | | | ✓ | -| `getScenarioResults(uri: string, lineNumber: number): messages.ITestResult[]` | | | | | ✓ | -| `getDocumentResults(uri: string): messages.ITestResult[]` | | | | | ✓ | -| `getStepMatchArguments(uri: string, lineNumber: number): messages.IStepMatchArgument[]` | | | | | ✓ | -| `getGherkinStep(gherkinStepId: string): messages.GherkinDocument.Feature.IStep` | | | | | ✓ | -| `countMostSevereTestStepResultStatus(): Map` | | | ✓ | | ✓ | -| `countTestCasesStarted(): int` | | | ✓ | | ✓ | -| `findAllPickles(): List` | | | ✓ | | ✓ | -| `findAllPickleSteps(): List` | | | ✓ | | ✓ | -| `findAllTestCaseStarted(): List` | | | ✓ | | ✓ | -| `findAllTestCaseStartedGroupedByFeature(): Map, List>` | | | ✓ | | ✓ | -| `findAllTestSteps(): List` | | | ✓ | | ✓ | -| `findAttachmentsBy(TestStepFinished): List` | | | ✓ | | ✓ | -| `findFeatureBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findHookBy(TestStep): Optional` | | | ✓ | | ✓ | -| `findMeta(): Optional` | | | ✓ | | ✓ | -| `findMostSevereTestStepResulBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findNameOf(Pickle, NamingStrategy): String` | | | ✓ | | ✓ | -| `findPickleBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findPickleStepBy(TestStep testStep): Optional` | | | ✓ | | ✓ | -| `findStepBy(PickleStep pickleStep): Optional` | | | ✓ | | ✓ | -| `findTestCaseBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findTestCaseDurationBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findTestCaseFinishedBy(TestCaseStarted): Optional` | | | ✓ | | ✓ | -| `findTestRunDuration(): Optional` | | | ✓ | | ✓ | -| `findTestRunFinished(): Optional` | | | ✓ | | ✓ | -| `findTestRunStarted(): Optional` | | | ✓ | | ✓ | -| `findTestStepBy(TestStepFinished): Optional` | | | ✓ | | ✓ | -| `findTestStepsFinishedBy(TestCaseStarted): List` | | | ✓ | | ✓ | -| `findTestStepFinishedAndTestStepBy(TestCaseStarted): List>` | | | ✓ | | ✓ | diff --git a/java/src/main/java/io/cucumber/query/Lineage.java b/java/src/main/java/io/cucumber/query/Lineage.java index 967eec10..37b1e8c3 100644 --- a/java/src/main/java/io/cucumber/query/Lineage.java +++ b/java/src/main/java/io/cucumber/query/Lineage.java @@ -9,7 +9,6 @@ import java.util.Objects; import java.util.Optional; -import java.util.function.Supplier; import static java.util.Objects.requireNonNull; @@ -21,7 +20,7 @@ * * @see LineageReducer */ -class Lineage { +public final class Lineage { private final GherkinDocument document; private final Feature feature; @@ -67,42 +66,38 @@ private Lineage(GherkinDocument document, Feature feature, Rule rule, Scenario s this.exampleIndex = exampleIndex; } - GherkinDocument document() { + public GherkinDocument document() { return document; } - Optional feature() { + public Optional feature() { return Optional.ofNullable(feature); } - Optional rule() { + public Optional rule() { return Optional.ofNullable(rule); } - Optional scenario() { + public Optional scenario() { return Optional.ofNullable(scenario); } - Optional examples() { + public Optional examples() { return Optional.ofNullable(examples); } - Optional example() { + public Optional example() { return Optional.ofNullable(example); } - Optional examplesIndex() { + public Optional examplesIndex() { return Optional.ofNullable(examplesIndex); } - Optional exampleIndex() { + public Optional exampleIndex() { return Optional.ofNullable(exampleIndex); } - LineageReducer reduce(Supplier> collector) { - return new LineageReducerDescending(collector); - } - @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/java/src/main/java/io/cucumber/query/LineageCollector.java b/java/src/main/java/io/cucumber/query/LineageCollector.java deleted file mode 100644 index 19a7553a..00000000 --- a/java/src/main/java/io/cucumber/query/LineageCollector.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.cucumber.query; - -import io.cucumber.messages.types.Examples; -import io.cucumber.messages.types.Feature; -import io.cucumber.messages.types.GherkinDocument; -import io.cucumber.messages.types.Pickle; -import io.cucumber.messages.types.Rule; -import io.cucumber.messages.types.Scenario; -import io.cucumber.messages.types.TableRow; - -/** - * Collect the {@link Lineage} of a - * {@linkplain io.cucumber.messages.types.GherkinDocument GherkinDocument element} - * or {@link Pickle} and reduce it to a single result. - * - * @param the type reduced to. - */ -interface LineageCollector { - default void add(GherkinDocument document) { - - } - - default void add(Feature feature) { - - } - - default void add(Rule rule) { - - } - - default void add(Scenario scenario) { - - } - - default void add(Examples examples, int index) { - } - - default void add(TableRow example, int index) { - } - - default void add(Pickle pickle) { - } - - T finish(); -} diff --git a/java/src/main/java/io/cucumber/query/LineageReducer.java b/java/src/main/java/io/cucumber/query/LineageReducer.java index 96a3cf66..af28d3aa 100644 --- a/java/src/main/java/io/cucumber/query/LineageReducer.java +++ b/java/src/main/java/io/cucumber/query/LineageReducer.java @@ -1,11 +1,15 @@ package io.cucumber.query; +import io.cucumber.messages.types.Examples; +import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.GherkinDocument; import io.cucumber.messages.types.Pickle; +import io.cucumber.messages.types.Rule; +import io.cucumber.messages.types.Scenario; +import io.cucumber.messages.types.TableRow; import java.util.function.Supplier; -import static java.util.Objects.requireNonNull; - /** * Visit the {@link Lineage} of a {@linkplain io.cucumber.messages.types.GherkinDocument GherkinDocument element} * or {@link Pickle} and reduce it. @@ -17,14 +21,53 @@ * * @param the type reduced to. */ -interface LineageReducer { +public interface LineageReducer { - static LineageReducer descending(Supplier> collector) { + static LineageReducer descending(Supplier> collector) { return new LineageReducerDescending<>(collector); } + + static LineageReducer ascending(Supplier> collector) { + return new LineageReducerAscending<>(collector); + } T reduce(Lineage lineage); T reduce(Lineage lineage, Pickle pickle); + /** + * Collect the {@link Lineage} of a + * {@linkplain io.cucumber.messages.types.GherkinDocument GherkinDocument element} + * or {@link Pickle} and reduce it to a single result. + * + * @param the type reduced to. + */ + interface Collector { + default void add(GherkinDocument document) { + + } + + default void add(Feature feature) { + + } + + default void add(Rule rule) { + + } + + default void add(Scenario scenario) { + + } + + default void add(Examples examples, int index) { + } + + default void add(TableRow example, int index) { + } + + default void add(Pickle pickle) { + } + + T finish(); + } } diff --git a/java/src/main/java/io/cucumber/query/LineageReducerAscending.java b/java/src/main/java/io/cucumber/query/LineageReducerAscending.java new file mode 100644 index 00000000..0e0314bc --- /dev/null +++ b/java/src/main/java/io/cucumber/query/LineageReducerAscending.java @@ -0,0 +1,45 @@ +package io.cucumber.query; + +import io.cucumber.messages.types.Pickle; + +import java.util.function.Supplier; + +import static java.util.Objects.requireNonNull; + +/** + * Reduces the lineage of a Gherkin document element in ascending order. + * + * @param type to which the lineage is reduced. + */ +class LineageReducerAscending implements LineageReducer { + + private final Supplier> collectorSupplier; + + LineageReducerAscending(Supplier> collectorSupplier) { + this.collectorSupplier = requireNonNull(collectorSupplier); + } + + @Override + public T reduce(Lineage lineage) { + Collector collector = collectorSupplier.get(); + reduceAddLineage(collector, lineage); + return collector.finish(); + } + + @Override + public T reduce(Lineage lineage, Pickle pickle) { + Collector collector = collectorSupplier.get(); + collector.add(pickle); + reduceAddLineage(collector, lineage); + return collector.finish(); + } + + private static void reduceAddLineage(Collector reducer, Lineage lineage) { + lineage.example().ifPresent(example -> reducer.add(example, lineage.exampleIndex().orElse(0))); + lineage.examples().ifPresent(examples -> reducer.add(examples, lineage.examplesIndex().orElse(0))); + lineage.scenario().ifPresent(reducer::add); + lineage.rule().ifPresent(reducer::add); + lineage.feature().ifPresent(reducer::add); + reducer.add(lineage.document()); + } +} diff --git a/java/src/main/java/io/cucumber/query/LineageReducerDescending.java b/java/src/main/java/io/cucumber/query/LineageReducerDescending.java index 7a089150..759d0d92 100644 --- a/java/src/main/java/io/cucumber/query/LineageReducerDescending.java +++ b/java/src/main/java/io/cucumber/query/LineageReducerDescending.java @@ -13,33 +13,33 @@ */ class LineageReducerDescending implements LineageReducer { - private final Supplier> reducerSupplier; + private final Supplier> collectorSupplier; - LineageReducerDescending(Supplier> reducerSupplier) { - this.reducerSupplier = requireNonNull(reducerSupplier); + LineageReducerDescending(Supplier> collectorSupplier) { + this.collectorSupplier = requireNonNull(collectorSupplier); } @Override public T reduce(Lineage lineage) { - LineageCollector reducer = reducerSupplier.get(); - reduceAddLineage(reducer, lineage); - return reducer.finish(); + Collector collector = collectorSupplier.get(); + reduceAddLineage(collector, lineage); + return collector.finish(); } @Override public T reduce(Lineage lineage, Pickle pickle) { - LineageCollector reducer = reducerSupplier.get(); - reduceAddLineage(reducer, lineage); - reducer.add(pickle); - return reducer.finish(); + Collector collector = collectorSupplier.get(); + reduceAddLineage(collector, lineage); + collector.add(pickle); + return collector.finish(); } - private static void reduceAddLineage(LineageCollector reducer, Lineage lineage) { - reducer.add(lineage.document()); - lineage.feature().ifPresent(reducer::add); - lineage.rule().ifPresent(reducer::add); - lineage.scenario().ifPresent(reducer::add); - lineage.examples().ifPresent(examples -> reducer.add(examples, lineage.examplesIndex().orElse(0))); - lineage.example().ifPresent(example -> reducer.add(example, lineage.exampleIndex().orElse(0))); + private static void reduceAddLineage(Collector collector, Lineage lineage) { + collector.add(lineage.document()); + lineage.feature().ifPresent(collector::add); + lineage.rule().ifPresent(collector::add); + lineage.scenario().ifPresent(collector::add); + lineage.examples().ifPresent(examples -> collector.add(examples, lineage.examplesIndex().orElse(0))); + lineage.example().ifPresent(example -> collector.add(example, lineage.exampleIndex().orElse(0))); } } diff --git a/java/src/main/java/io/cucumber/query/NamingCollector.java b/java/src/main/java/io/cucumber/query/NamingCollector.java index 1d70e53d..1fc037f1 100644 --- a/java/src/main/java/io/cucumber/query/NamingCollector.java +++ b/java/src/main/java/io/cucumber/query/NamingCollector.java @@ -6,6 +6,7 @@ import io.cucumber.messages.types.Rule; import io.cucumber.messages.types.Scenario; import io.cucumber.messages.types.TableRow; +import io.cucumber.query.LineageReducer.Collector; import io.cucumber.query.NamingStrategy.ExampleName; import io.cucumber.query.NamingStrategy.FeatureName; import io.cucumber.query.NamingStrategy.Strategy; @@ -24,9 +25,10 @@ * * @see NamingStrategy */ -class NamingCollector implements LineageCollector { +class NamingCollector implements Collector { - private final Deque parts = new ArrayDeque<>(); + // There are at most 5 levels to a feature file. + private final Deque parts = new ArrayDeque<>(5); private final CharSequence delimiter = " - "; private final Strategy strategy; private final FeatureName featureName; diff --git a/java/src/main/java/io/cucumber/query/Query.java b/java/src/main/java/io/cucumber/query/Query.java index 8141cb37..adf65897 100644 --- a/java/src/main/java/io/cucumber/query/Query.java +++ b/java/src/main/java/io/cucumber/query/Query.java @@ -7,6 +7,7 @@ import io.cucumber.messages.types.Feature; import io.cucumber.messages.types.GherkinDocument; import io.cucumber.messages.types.Hook; +import io.cucumber.messages.types.Location; import io.cucumber.messages.types.Meta; import io.cucumber.messages.types.Pickle; import io.cucumber.messages.types.PickleStep; @@ -23,6 +24,7 @@ import io.cucumber.messages.types.TestStepFinished; import io.cucumber.messages.types.TestStepResult; import io.cucumber.messages.types.TestStepResultStatus; +import io.cucumber.messages.types.TestStepStarted; import io.cucumber.messages.types.Timestamp; import java.time.Duration; @@ -30,13 +32,13 @@ import java.util.AbstractMap.SimpleEntry; import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.BiFunction; import java.util.function.Supplier; import java.util.stream.Collectors; import static java.util.Collections.emptyList; import static java.util.Comparator.comparing; +import static java.util.Comparator.naturalOrder; import static java.util.Comparator.nullsFirst; import static java.util.Objects.requireNonNull; import static java.util.Optional.ofNullable; @@ -62,9 +64,10 @@ public final class Query { private static final Map ZEROES_BY_TEST_STEP_RESULT_STATUSES = Arrays.stream(TestStepResultStatus.values()) .collect(Collectors.toMap(identity(), (s) -> 0L)); private final Comparator testStepResultComparator = nullsFirst(comparing(o -> o.getStatus().ordinal())); - private final Deque testCaseStarted = new ConcurrentLinkedDeque<>(); + private final Map testCaseStartedById = new ConcurrentHashMap<>(); private final Map testCaseFinishedByTestCaseStartedId = new ConcurrentHashMap<>(); private final Map> testStepsFinishedByTestCaseStartedId = new ConcurrentHashMap<>(); + private final Map> testStepsStartedByTestCaseStartedId = new ConcurrentHashMap<>(); private final Map pickleById = new ConcurrentHashMap<>(); private final Map testCaseById = new ConcurrentHashMap<>(); private final Map stepById = new ConcurrentHashMap<>(); @@ -105,8 +108,11 @@ public List findAllPickleSteps() { } public List findAllTestCaseStarted() { - return this.testCaseStarted.stream() - .filter(testCaseStarted1 -> !findTestCaseFinishedBy(testCaseStarted1) + return this.testCaseStartedById.values().stream() + .sorted(comparing(TestCaseStarted::getTimestamp, new TimestampComparator()) + // tie-breaker for stability + .thenComparing(TestCaseStarted::getId)) + .filter(element -> !findTestCaseFinishedBy(element) .filter(TestCaseFinished::getWillBeRetried) .isPresent()) .collect(toList()); @@ -115,14 +121,17 @@ public List findAllTestCaseStarted() { public Map, List> findAllTestCaseStartedGroupedByFeature() { return findAllTestCaseStarted() .stream() - .map(testCaseStarted1 -> { - Optional astNodes = findLineageBy(testCaseStarted1); - return new SimpleEntry<>(astNodes, testCaseStarted1); + .map(testCaseStarted -> { + Optional astNodes = findLineageBy(testCaseStarted); + return new SimpleEntry<>(astNodes, testCaseStarted); }) // Sort entries by gherkin document URI for consistent ordering - .sorted(nullsFirst(comparing(entry1 -> entry1.getKey() - .flatMap(nodes -> nodes.document().getUri()) - .orElse(null)))) + .sorted(comparing( + entry -> entry.getKey() + .flatMap(nodes -> nodes.document().getUri()) + .orElse(null), + nullsFirst(naturalOrder()) + )) .map(entry -> { // Unpack the now sorted entries Optional feature = entry.getKey().flatMap(Lineage::feature); @@ -175,49 +184,56 @@ public Optional findMostSevereTestStepResultBy(TestCaseStarted t public String findNameOf(GherkinDocument element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(Feature element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(Rule element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(Scenario element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(Examples element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(TableRow element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(namingStrategy::reduce) .orElseThrow(createElementWasNotPartOfThisQueryObject()); } public String findNameOf(Pickle element, NamingStrategy namingStrategy) { requireNonNull(element); requireNonNull(namingStrategy); - return reduceLinageOf(element, namingStrategy) + return findLineageBy(element) + .map(lineage -> namingStrategy.reduce(lineage, element)) .orElseGet(element::getName); } @@ -225,53 +241,13 @@ private static Supplier createElementWasNotPartOfThisQ return () -> new IllegalArgumentException("Element was not part of this query object"); } - Optional reduceLinageOf(GherkinDocument element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(Feature element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(Rule element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(Scenario element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(Examples element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(TableRow element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(reducer::reduce); - } - - Optional reduceLinageOf(Pickle element, LineageReducer reducer) { - requireNonNull(element); - requireNonNull(reducer); - return findLineageBy(element) - .map(lineage -> reducer.reduce(lineage, element)); + public Optional findLocationOf(Pickle pickle) { + return findLineageBy(pickle).flatMap(lineage -> { + if (lineage.example().isPresent()) { + return lineage.example().map(TableRow::getLocation); + } + return lineage.scenario().map(Scenario::getLocation); + }); } public Optional findPickleBy(TestCaseStarted testCaseStarted) { @@ -281,6 +257,13 @@ public Optional findPickleBy(TestCaseStarted testCaseStarted) { .map(pickleById::get); } + public Optional findPickleBy(TestStepStarted testStepStarted) { + requireNonNull(testStepStarted); + return findTestCaseBy(testStepStarted) + .map(TestCase::getPickleId) + .map(pickleById::get); + } + public Optional findPickleStepBy(TestStep testStep) { requireNonNull(testStep); return testStep.getPickleStepId() @@ -298,6 +281,12 @@ public Optional findTestCaseBy(TestCaseStarted testCaseStarted) { return ofNullable(testCaseById.get(testCaseStarted.getTestCaseId())); } + public Optional findTestCaseBy(TestStepStarted testStepStarted) { + requireNonNull(testStepStarted); + return findTestCaseStartedBy(testStepStarted) + .flatMap(this::findTestCaseBy); + } + public Optional findTestCaseDurationBy(TestCaseStarted testCaseStarted) { requireNonNull(testCaseStarted); Timestamp started = testCaseStarted.getTimestamp(); @@ -309,6 +298,12 @@ public Optional findTestCaseDurationBy(TestCaseStarted testCaseStarted )); } + public Optional findTestCaseStartedBy(TestStepStarted testStepStarted) { + requireNonNull(testStepStarted); + String testCaseStartedId = testStepStarted.getTestCaseStartedId(); + return ofNullable(testCaseStartedById.get(testCaseStartedId)); + } + public Optional findTestCaseFinishedBy(TestCaseStarted testCaseStarted) { requireNonNull(testCaseStarted); return ofNullable(testCaseFinishedByTestCaseStartedId.get(testCaseStarted.getId())); @@ -333,11 +328,24 @@ public Optional findTestRunStarted() { return ofNullable(testRunStarted); } + public Optional findTestStepBy(TestStepStarted testStepStarted) { + requireNonNull(testStepStarted); + return ofNullable(testStepById.get(testStepStarted.getTestStepId())); + } + public Optional findTestStepBy(TestStepFinished testStepFinished) { requireNonNull(testStepFinished); return ofNullable(testStepById.get(testStepFinished.getTestStepId())); } + public List findTestStepsStartedBy(TestCaseStarted testCaseStarted) { + requireNonNull(testCaseStarted); + List testStepsStarted = testStepsStartedByTestCaseStartedId. + getOrDefault(testCaseStarted.getId(), emptyList()); + // Concurrency + return new ArrayList<>(testStepsStarted); + } + public List findTestStepsFinishedBy(TestCaseStarted testCaseStarted) { requireNonNull(testCaseStarted); List testStepsFinished = testStepsFinishedByTestCaseStartedId. @@ -360,6 +368,7 @@ public void update(Envelope envelope) { envelope.getTestRunFinished().ifPresent(this::updateTestRunFinished); envelope.getTestCaseStarted().ifPresent(this::updateTestCaseStarted); envelope.getTestCaseFinished().ifPresent(this::updateTestCaseFinished); + envelope.getTestStepStarted().ifPresent(this::updateTestStepStarted); envelope.getTestStepFinished().ifPresent(this::updateTestStepFinished); envelope.getGherkinDocument().ifPresent(this::updateGherkinDocument); envelope.getPickle().ifPresent(this::updatePickle); @@ -368,44 +377,44 @@ public void update(Envelope envelope) { envelope.getAttachment().ifPresent(this::updateAttachment); } - private Optional findLineageBy(GherkinDocument element) { + public Optional findLineageBy(GherkinDocument element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element.getUri())); } - private Optional findLineageBy(Feature element) { + public Optional findLineageBy(Feature element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element)); } - private Optional findLineageBy(Rule element) { + public Optional findLineageBy(Rule element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element.getId())); } - private Optional findLineageBy(Scenario element) { + public Optional findLineageBy(Scenario element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element.getId())); } - private Optional findLineageBy(Examples element) { + public Optional findLineageBy(Examples element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element.getId())); } - private Optional findLineageBy(TableRow element) { + public Optional findLineageBy(TableRow element) { requireNonNull(element); return Optional.ofNullable(lineageById.get(element.getId())); } - private Optional findLineageBy(Pickle pickle) { + public Optional findLineageBy(Pickle pickle) { requireNonNull(pickle); List astNodeIds = pickle.getAstNodeIds(); String pickleAstNodeId = astNodeIds.get(astNodeIds.size() - 1); return Optional.ofNullable(lineageById.get(pickleAstNodeId)); } - private Optional findLineageBy(TestCaseStarted testCaseStarted) { + public Optional findLineageBy(TestCaseStarted testCaseStarted) { return findPickleBy(testCaseStarted) .flatMap(this::findLineageBy); } @@ -420,7 +429,7 @@ private void updateHook(Hook hook) { } private void updateTestCaseStarted(TestCaseStarted testCaseStarted) { - this.testCaseStarted.add(testCaseStarted); + this.testCaseStartedById.put(testCaseStarted.getId(), testCaseStarted); } private void updateTestCase(TestCase event) { @@ -450,6 +459,10 @@ private void updateFeature(Feature feature) { }); } + private void updateTestStepStarted(TestStepStarted event) { + this.testStepsStartedByTestCaseStartedId.compute(event.getTestCaseStartedId(), updateList(event)); + } + private void updateTestStepFinished(TestStepFinished event) { this.testStepsFinishedByTestCaseStartedId.compute(event.getTestCaseStartedId(), updateList(event)); } @@ -489,4 +502,5 @@ private BiFunction, List> updateList(E element) { return list; }; } + } diff --git a/java/src/main/java/io/cucumber/query/TimestampComparator.java b/java/src/main/java/io/cucumber/query/TimestampComparator.java new file mode 100644 index 00000000..29ed2794 --- /dev/null +++ b/java/src/main/java/io/cucumber/query/TimestampComparator.java @@ -0,0 +1,30 @@ +package io.cucumber.query; + +import io.cucumber.messages.types.Timestamp; + +import java.util.Comparator; + +class TimestampComparator implements Comparator { + @Override + public int compare(Timestamp a, Timestamp b) { + long sa = a.getSeconds(); + long sb = b.getSeconds(); + + if (sa < sb) { + return -1; + } else if (sb < sa) { + return 1; + } + + long na = a.getNanos(); + long nb = b.getNanos(); + + if (na < nb) { + return -1; + } else if (nb < na) { + return 1; + } + + return 0; + } +} diff --git a/java/src/test/java/io/cucumber/query/QueryAcceptanceTest.java b/java/src/test/java/io/cucumber/query/QueryAcceptanceTest.java index bd0ed49c..dc54e685 100644 --- a/java/src/test/java/io/cucumber/query/QueryAcceptanceTest.java +++ b/java/src/test/java/io/cucumber/query/QueryAcceptanceTest.java @@ -137,6 +137,10 @@ private static Map createQueryResults(Query query) { .map(hook -> hook.map(Hook::getId)) .filter(Optional::isPresent) .collect(toList())); + results.put("findLocationOf", query.findAllPickles().stream() + .map(query::findLocationOf) + .filter(Optional::isPresent) + .collect(toList())); results.put("findMeta", query.findMeta().map(meta -> meta.getImplementation().getName())); results.put("findMostSevereTestStepResultBy", query.findAllTestCaseStarted().stream() .map(query::findMostSevereTestStepResultBy) @@ -191,7 +195,13 @@ private static Map createQueryResults(Query query) { .map(Convertor::toMessage)); results.put("findTestRunFinished", query.findTestRunFinished()); results.put("findTestRunStarted", query.findTestRunStarted()); - results.put("findTestStepBy", query.findAllTestCaseStarted().stream() + results.put("findTestStepByTestStepStarted", query.findAllTestCaseStarted().stream() + .map(query::findTestStepsStartedBy) + .flatMap(Collection::stream) + .map(query::findTestStepBy) + .map(testStep -> testStep.map(TestStep::getId)) + .collect(toList())); + results.put("findTestStepByTestStepFinished", query.findAllTestCaseStarted().stream() .map(query::findTestStepsFinishedBy) .flatMap(Collection::stream) .map(query::findTestStepBy) diff --git a/java/src/test/java/io/cucumber/query/QueryTest.java b/java/src/test/java/io/cucumber/query/QueryTest.java index 5cd21dc1..37bcdf55 100644 --- a/java/src/test/java/io/cucumber/query/QueryTest.java +++ b/java/src/test/java/io/cucumber/query/QueryTest.java @@ -16,10 +16,10 @@ class QueryTest { final Query query = new Query(); @Test - void retainsInsertionOrderForTestCaseStarted() { - TestCaseStarted a = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(0L, 0L)); - TestCaseStarted b = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(0L, 0L)); - TestCaseStarted c = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(0L, 0L)); + void retainsTimestampOrderForTestCaseStarted() { + TestCaseStarted a = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(1L, 0L)); + TestCaseStarted b = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(2L, 0L)); + TestCaseStarted c = new TestCaseStarted(0L, randomId(), randomId(), "main", new Timestamp(3L, 0L)); Stream.of(a, b, c) .map(Envelope::of) @@ -27,6 +27,18 @@ void retainsInsertionOrderForTestCaseStarted() { assertThat(query.findAllTestCaseStarted()).containsExactly(a, b, c); } + @Test + void idIsTieOrderTieBreaker() { + TestCaseStarted a = new TestCaseStarted(0L, "2", randomId(), "main", new Timestamp(1L, 0L)); + TestCaseStarted b = new TestCaseStarted(0L, "1", randomId(), "main", new Timestamp(1L, 0L)); + TestCaseStarted c = new TestCaseStarted(0L, "0", randomId(), "main", new Timestamp(1L, 0L)); + + Stream.of(a, b, c) + .map(Envelope::of) + .forEach(query::update); + + assertThat(query.findAllTestCaseStarted()).containsExactly(c, b, a); + } @Test void omitsTestCaseStartedIfFinishedAndWillBeRetried() { diff --git a/java/src/test/java/io/cucumber/query/TimestampComparatorTest.java b/java/src/test/java/io/cucumber/query/TimestampComparatorTest.java new file mode 100644 index 00000000..d48e6e99 --- /dev/null +++ b/java/src/test/java/io/cucumber/query/TimestampComparatorTest.java @@ -0,0 +1,42 @@ +package io.cucumber.query; + +import io.cucumber.messages.types.Timestamp; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TimestampComparatorTest { + + private final TimestampComparator comparator = new TimestampComparator(); + + @Test + void identity(){ + Timestamp a = new Timestamp(1L, 1L); + Timestamp b = new Timestamp(1L, 1L); + + assertThat(comparator.compare(a, b)).isEqualTo(0); + assertThat(comparator.compare(b, a)).isEqualTo(0); + } + + @Test + void onSeconds(){ + Timestamp a = new Timestamp(1L, 1L); + Timestamp b = new Timestamp(2L, 2L); + assertThat(comparator.compare(a, b)).isEqualTo(-1); + assertThat(comparator.compare(b, a)).isEqualTo(1); + } + + @Test + void onNanoSeconds(){ + Timestamp a = new Timestamp(1L, 1L); + Timestamp b1 = new Timestamp(1L, 0L); + Timestamp b2 = new Timestamp(1L, 2L); + + assertThat(comparator.compare(a, b1)).isEqualTo(1); + assertThat(comparator.compare(b1, a)).isEqualTo(-1); + + assertThat(comparator.compare(a, b2)).isEqualTo(-1); + assertThat(comparator.compare(b2, a)).isEqualTo(1); + + } +} diff --git a/javascript/package-lock.json b/javascript/package-lock.json index deb828f8..79d3d056 100644 --- a/javascript/package-lock.json +++ b/javascript/package-lock.json @@ -9,7 +9,8 @@ "version": "13.2.0", "license": "MIT", "dependencies": { - "@teppeis/multimaps": "3.0.0" + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" }, "devDependencies": { "@cucumber/compatibility-kit": "^18.0.0", @@ -21,6 +22,7 @@ "@eslint/compat": "^1.2.7", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", + "@types/lodash.sortby": "^4.7.9", "@types/mocha": "10.0.10", "@types/node": "22.15.29", "@typescript-eslint/eslint-plugin": "^8.24.1", @@ -685,6 +687,23 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash.sortby": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/@types/lodash.sortby/-/lodash.sortby-4.7.9.tgz", + "integrity": "sha512-PDmjHnOlndLS59GofH0pnxIs+n9i4CWeXGErSB5JyNFHu2cmvW6mQOaUKjG8EDPkni14IgF8NsRW8bKvFzTm9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -3406,6 +3425,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", diff --git a/javascript/package.json b/javascript/package.json index edf35093..b68c0414 100644 --- a/javascript/package.json +++ b/javascript/package.json @@ -35,6 +35,7 @@ "@eslint/compat": "^1.2.7", "@eslint/eslintrc": "^3.3.0", "@eslint/js": "^9.21.0", + "@types/lodash.sortby": "^4.7.9", "@types/mocha": "10.0.10", "@types/node": "22.15.29", "@typescript-eslint/eslint-plugin": "^8.24.1", @@ -59,7 +60,8 @@ "@cucumber/messages": "*" }, "dependencies": { - "@teppeis/multimaps": "3.0.0" + "@teppeis/multimaps": "3.0.0", + "lodash.sortby": "^4.7.0" }, "overrides": { "@cucumber/messages": "27.2.0" diff --git a/javascript/src/Query.spec.ts b/javascript/src/Query.spec.ts index f1ecb404..a29641b2 100644 --- a/javascript/src/Query.spec.ts +++ b/javascript/src/Query.spec.ts @@ -12,6 +12,7 @@ import { import { GherkinStreams } from '@cucumber/gherkin-streams' import { Query as GherkinQuery } from '@cucumber/gherkin-utils' import * as messages from '@cucumber/messages' +import { TestCaseStarted } from '@cucumber/messages' import { pipeline, Readable, Writable } from 'stream' import { promisify } from 'util' @@ -27,6 +28,81 @@ describe('Query', () => { cucumberQuery = new Query() }) + describe('#findAllTestCaseStarted', () => { + it('retains timestamp order', () => { + const testCasesStarted: TestCaseStarted[] = [ + { + id: '1', + testCaseId: '1', + attempt: 0, + timestamp: { + seconds: 1, + nanos: 1, + }, + }, + { + id: '2', + testCaseId: '2', + attempt: 0, + timestamp: { + seconds: 2, + nanos: 1, + }, + }, + { + id: '3', + testCaseId: '3', + attempt: 0, + timestamp: { + seconds: 2, + nanos: 3, + }, + }, + ] + + testCasesStarted + .map((testCaseStarted) => ({ testCaseStarted })) + .reverse() + .forEach((envelope) => { + cucumberQuery.update(envelope) + }) + + assert.deepStrictEqual(cucumberQuery.findAllTestCaseStarted(), testCasesStarted) + }) + + it('uses id as tie breaker', () => { + const testCasesStarted: TestCaseStarted[] = [ + { + id: '1', + testCaseId: '1', + attempt: 0, + timestamp: { + seconds: 1, + nanos: 1, + }, + }, + { + id: '2', + testCaseId: '2', + attempt: 0, + timestamp: { + seconds: 1, + nanos: 1, + }, + }, + ] + + testCasesStarted + .map((testCaseStarted) => ({ testCaseStarted })) + .reverse() + .forEach((envelope) => { + cucumberQuery.update(envelope) + }) + + assert.deepStrictEqual(cucumberQuery.findAllTestCaseStarted(), testCasesStarted) + }) + }) + describe('#getPickleStepTestStepResults(pickleStepIds)', () => { it('returns a single UNKNOWN when the list is empty', () => { const results = cucumberQuery.getPickleTestStepResults([]) diff --git a/javascript/src/Query.ts b/javascript/src/Query.ts index b6be0369..0d704b19 100644 --- a/javascript/src/Query.ts +++ b/javascript/src/Query.ts @@ -6,6 +6,7 @@ import { getWorstTestStepResult, GherkinDocument, Hook, + Location, Meta, Pickle, PickleStep, @@ -21,11 +22,13 @@ import { TestStepFinished, TestStepResult, TestStepResultStatus, + TestStepStarted, TimeConversion, } from '@cucumber/messages' import { ArrayMultimap } from '@teppeis/multimaps' +import sortBy from 'lodash.sortby' -import { assert, comparatorBy, comparatorById, comparatorByStatus } from './helpers' +import { assert, statusOrdinal } from './helpers' import { Lineage, NamingStrategy } from './Lineage' export default class Query { @@ -52,7 +55,7 @@ export default class Query { private meta: Meta private testRunStarted: TestRunStarted private testRunFinished: TestRunFinished - private readonly testCaseStarted: Array = [] + private readonly testCaseStartedById: Map = new Map() private readonly lineageById: Map = new Map() private readonly stepById: Map = new Map() private readonly pickleById: Map = new Map() @@ -60,6 +63,8 @@ export default class Query { private readonly testCaseById: Map = new Map() private readonly testStepById: Map = new Map() private readonly testCaseFinishedByTestCaseStartedId: Map = new Map() + private readonly testStepStartedByTestCaseStartedId: ArrayMultimap = + new ArrayMultimap() private readonly testStepFinishedByTestCaseStartedId: ArrayMultimap = new ArrayMultimap() private readonly attachmentsByTestCaseStartedId: ArrayMultimap = @@ -87,6 +92,9 @@ export default class Query { if (envelope.testCaseStarted) { this.updateTestCaseStarted(envelope.testCaseStarted) } + if (envelope.testStepStarted) { + this.updateTestStepStarted(envelope.testStepStarted) + } if (envelope.attachment) { this.updateAttachment(envelope.attachment) } @@ -195,7 +203,7 @@ export default class Query { } private updateTestCaseStarted(testCaseStarted: TestCaseStarted) { - this.testCaseStarted.push(testCaseStarted) + this.testCaseStartedById.set(testCaseStarted.id, testCaseStarted) /* when a test case attempt starts besides the first one, clear all existing results @@ -203,14 +211,20 @@ export default class Query { (applies to legacy pickle-oriented query methods only) */ const testCase = this.testCaseById.get(testCaseStarted.testCaseId) - this.testStepResultByPickleId.delete(testCase.pickleId) - for (const testStep of testCase.testSteps) { - this.testStepResultsByPickleStepId.delete(testStep.pickleStepId) - this.testStepResultsbyTestStepId.delete(testStep.id) - this.attachmentsByTestStepId.delete(testStep.id) + if (testCase) { + this.testStepResultByPickleId.delete(testCase.pickleId) + for (const testStep of testCase.testSteps) { + this.testStepResultsByPickleStepId.delete(testStep.pickleStepId) + this.testStepResultsbyTestStepId.delete(testStep.id) + this.attachmentsByTestStepId.delete(testStep.id) + } } } + private updateTestStepStarted(testStepStarted: TestStepStarted) { + this.testStepStartedByTestCaseStartedId.put(testStepStarted.testCaseStartedId, testStepStarted) + } + private updateAttachment(attachment: Attachment) { if (attachment.testStepId) { this.attachmentsByTestStepId.put(attachment.testStepId, attachment) @@ -391,10 +405,12 @@ export default class Query { [TestStepResultStatus.UNKNOWN]: 0, } for (const testCaseStarted of this.findAllTestCaseStarted()) { - const mostSevereResult = this.findTestStepFinishedAndTestStepBy(testCaseStarted) - .map(([testStepFinished]) => testStepFinished.testStepResult) - .sort(comparatorByStatus) - .at(-1) + const mostSevereResult = sortBy( + this.findTestStepFinishedAndTestStepBy(testCaseStarted).map( + ([testStepFinished]) => testStepFinished.testStepResult + ), + [(testStepResult) => statusOrdinal(testStepResult.status)] + ).at(-1) if (mostSevereResult) { result[mostSevereResult.status]++ } @@ -408,20 +424,27 @@ export default class Query { public findAllPickles(): ReadonlyArray { const pickles = [...this.pickleById.values()] - return pickles.sort(comparatorById) + return sortBy(pickles, ['id']) } public findAllPickleSteps(): ReadonlyArray { const pickleSteps = [...this.pickleStepById.values()] - return pickleSteps.sort(comparatorById) + return sortBy(pickleSteps, ['id']) } public findAllTestCaseStarted(): ReadonlyArray { - return this.testCaseStarted.filter((testCaseStarted) => { - const testCaseFinished = this.testCaseFinishedByTestCaseStartedId.get(testCaseStarted.id) - // only include if not yet finished OR won't be retried - return !testCaseFinished?.willBeRetried - }) + return sortBy( + [...this.testCaseStartedById.values()].filter((testCaseStarted) => { + const testCaseFinished = this.testCaseFinishedByTestCaseStartedId.get(testCaseStarted.id) + // only include if not yet finished OR won't be retried + return !testCaseFinished?.willBeRetried + }), + [ + (testCaseStarted) => + TimeConversion.timestampToMillisecondsSinceEpoch(testCaseStarted.timestamp), + 'id', + ] + ) } public findAllTestCaseStartedGroupedByFeature(): Map< @@ -429,18 +452,20 @@ export default class Query { ReadonlyArray > { const results = new Map() - this.findAllTestCaseStarted() - .map((testCaseStarted) => [this.findLineageBy(testCaseStarted), testCaseStarted] as const) - .sort(([a], [b]) => comparatorBy(a.gherkinDocument, b.gherkinDocument, 'uri')) - .forEach(([{ feature }, testCaseStarted]) => { - results.set(feature, [...(results.get(feature) ?? []), testCaseStarted]) - }) + sortBy( + this.findAllTestCaseStarted().map( + (testCaseStarted) => [this.findLineageBy(testCaseStarted), testCaseStarted] as const + ), + [([lineage]) => lineage.gherkinDocument.uri] + ).forEach(([{ feature }, testCaseStarted]) => { + results.set(feature, [...(results.get(feature) ?? []), testCaseStarted]) + }) return results } public findAllTestSteps(): ReadonlyArray { const testSteps = [...this.testStepById.values()] - return testSteps.sort(comparatorById) + return sortBy(testSteps, ['id']) } public findAttachmentsBy(testStepFinished: TestStepFinished): ReadonlyArray { @@ -467,10 +492,12 @@ export default class Query { public findMostSevereTestStepResultBy( testCaseStarted: TestCaseStarted ): TestStepResult | undefined { - return this.findTestStepFinishedAndTestStepBy(testCaseStarted) - .map(([testStepFinished]) => testStepFinished.testStepResult) - .sort(comparatorByStatus) - .at(-1) + return sortBy( + this.findTestStepFinishedAndTestStepBy(testCaseStarted).map( + ([testStepFinished]) => testStepFinished.testStepResult + ), + [(testStepResult) => statusOrdinal(testStepResult.status)] + ).at(-1) } public findNameOf(pickle: Pickle, namingStrategy: NamingStrategy): string { @@ -478,8 +505,16 @@ export default class Query { return lineage ? namingStrategy.reduce(lineage, pickle) : pickle.name } - public findPickleBy(testCaseStarted: TestCaseStarted): Pickle | undefined { - const testCase = this.findTestCaseBy(testCaseStarted) + public findLocationOf(pickle: Pickle): Location | undefined { + const lineage = this.findLineageBy(pickle) + if (lineage?.example) { + return lineage.example.location + } + return lineage?.scenario?.location + } + + public findPickleBy(element: TestCaseStarted | TestStepStarted): Pickle | undefined { + const testCase = this.findTestCaseBy(element) assert.ok(testCase, 'Expected to find TestCase from TestCaseStarted') return this.pickleById.get(testCase.pickleId) } @@ -497,7 +532,10 @@ export default class Query { return this.stepById.get(astNodeId) } - public findTestCaseBy(testCaseStarted: TestCaseStarted): TestCase | undefined { + public findTestCaseBy(element: TestCaseStarted | TestStepStarted): TestCase | undefined { + const testCaseStarted = + 'testCaseStartedId' in element ? this.findTestCaseStartedBy(element) : element + assert.ok(testCaseStarted, 'Expected to find TestCaseStarted by TestStepStarted') return this.testCaseById.get(testCaseStarted.testCaseId) } @@ -512,6 +550,10 @@ export default class Query { ) } + public findTestCaseStartedBy(testStepStarted: TestStepStarted): TestCaseStarted | undefined { + return this.testCaseStartedById.get(testStepStarted.testCaseStartedId) + } + public findTestCaseFinishedBy(testCaseStarted: TestCaseStarted): TestCaseFinished | undefined { return this.testCaseFinishedByTestCaseStartedId.get(testCaseStarted.id) } @@ -534,8 +576,13 @@ export default class Query { return this.testRunStarted } - public findTestStepBy(testStepFinished: TestStepFinished): TestStep | undefined { - return this.testStepById.get(testStepFinished.testStepId) + public findTestStepBy(element: TestStepStarted | TestStepFinished): TestStep | undefined { + return this.testStepById.get(element.testStepId) + } + + public findTestStepsStartedBy(testCaseStarted: TestCaseStarted): ReadonlyArray { + // multimaps `get` implements `getOrDefault([])` behaviour internally + return [...this.testStepStartedByTestCaseStartedId.get(testCaseStarted.id)] } public findTestStepsFinishedBy( @@ -557,7 +604,7 @@ export default class Query { }) } - private findLineageBy(element: Pickle | TestCaseStarted) { + public findLineageBy(element: Pickle | TestCaseStarted): Lineage | undefined { const pickle = 'testCaseId' in element ? this.findPickleBy(element) : element const deepestAstNodeId = pickle.astNodeIds.at(-1) assert.ok(deepestAstNodeId, 'Expected Pickle to have at least one astNodeId') diff --git a/javascript/src/acceptance.spec.ts b/javascript/src/acceptance.spec.ts index 04cd73dc..9820d814 100644 --- a/javascript/src/acceptance.spec.ts +++ b/javascript/src/acceptance.spec.ts @@ -143,6 +143,7 @@ describe('Acceptance Tests', async () => { ) ), }, + findLocationOf: query.findAllPickles().map((pickle) => query.findLocationOf(pickle)), findPickleBy: query .findAllTestCaseStarted() .map((testCaseStarted) => query.findPickleBy(testCaseStarted)) @@ -170,7 +171,12 @@ describe('Acceptance Tests', async () => { findTestRunDuration: query.findTestRunDuration(), findTestRunFinished: query.findTestRunFinished(), findTestRunStarted: query.findTestRunStarted(), - findTestStepBy: query + findTestStepByTestStepStarted: query + .findAllTestCaseStarted() + .flatMap((testCaseStarted) => query.findTestStepsStartedBy(testCaseStarted)) + .map((testStepStarted) => query.findTestStepBy(testStepStarted)) + .map((testStep) => testStep?.id), + findTestStepByTestStepFinished: query .findAllTestCaseStarted() .flatMap((testCaseStarted) => query.findTestStepsFinishedBy(testCaseStarted)) .map((testStepFinished) => query.findTestStepBy(testStepFinished)) @@ -212,6 +218,7 @@ interface ResultsFixture { short: Array shortPickleName: Array } + findLocationOf: Array findHookBy: Array findPickleBy: Array findPickleStepBy: Array @@ -222,7 +229,8 @@ interface ResultsFixture { findTestRunDuration: Duration findTestRunFinished: TestRunFinished findTestRunStarted: TestRunStarted - findTestStepBy: Array + findTestStepByTestStepStarted: Array + findTestStepByTestStepFinished: Array findTestStepsFinishedBy: Array> findTestStepFinishedAndTestStepBy: Array<[string, string]> } @@ -246,7 +254,8 @@ const defaults: Partial = { findTestCaseBy: [], findTestCaseDurationBy: [], findTestCaseFinishedBy: [], - findTestStepBy: [], + findTestStepByTestStepStarted: [], + findTestStepByTestStepFinished: [], findTestStepsFinishedBy: [], findTestStepFinishedAndTestStepBy: [], } diff --git a/javascript/src/helpers.spec.ts b/javascript/src/helpers.spec.ts deleted file mode 100644 index 89cc5917..00000000 --- a/javascript/src/helpers.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import assert from 'node:assert' - -import { TestStepResult, TestStepResultStatus } from '@cucumber/messages' - -import { comparatorByStatus } from './helpers' - -describe('comparatorByStatus', () => { - function resultWithStatus(status: TestStepResultStatus) { - return { status } as TestStepResult - } - - it('puts the more severe status after the less severe', () => { - assert.strictEqual( - comparatorByStatus( - resultWithStatus(TestStepResultStatus.PASSED), - resultWithStatus(TestStepResultStatus.FAILED) - ), - -1 - ) - assert.strictEqual( - comparatorByStatus( - resultWithStatus(TestStepResultStatus.FAILED), - resultWithStatus(TestStepResultStatus.PASSED) - ), - 1 - ) - assert.strictEqual( - comparatorByStatus( - resultWithStatus(TestStepResultStatus.FAILED), - resultWithStatus(TestStepResultStatus.FAILED) - ), - 0 - ) - }) -}) diff --git a/javascript/src/helpers.ts b/javascript/src/helpers.ts index 5cfe6dd4..52c0f23c 100644 --- a/javascript/src/helpers.ts +++ b/javascript/src/helpers.ts @@ -1,35 +1,6 @@ -import { TestStepResult, TestStepResultStatus } from '@cucumber/messages' +import { TestStepResultStatus } from '@cucumber/messages' -interface WithId { - id: string | number -} - -export function comparatorById(a: WithId, b: WithId) { - return comparatorBy(a, b, 'id') -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function comparatorBy(a: any, b: any, key: string) { - if (a[key] < b[key]) { - return -1 - } - if (a[key] > b[key]) { - return 1 - } - return 0 -} - -export function comparatorByStatus(a: TestStepResult, b: TestStepResult) { - if (ordinal(a.status) < ordinal(b.status)) { - return -1 - } - if (ordinal(a.status) > ordinal(b.status)) { - return 1 - } - return 0 -} - -function ordinal(status: TestStepResultStatus) { +export function statusOrdinal(status: TestStepResultStatus) { return [ TestStepResultStatus.UNKNOWN, TestStepResultStatus.PASSED, diff --git a/testdata/attachments.feature.query-results.json b/testdata/attachments.feature.query-results.json index cc659157..988c1074 100644 --- a/testdata/attachments.feature.query-results.json +++ b/testdata/attachments.feature.query-results.json @@ -96,6 +96,44 @@ "Attachments", "Attachments" ], + "findLocationOf" : [ + { + "line" : 12, + "column" : 3 + }, + { + "line" : 18, + "column" : 3 + }, + { + "line" : 21, + "column" : 3 + }, + { + "line" : 24, + "column" : 3 + }, + { + "line" : 30, + "column" : 3 + }, + { + "line" : 33, + "column" : 3 + }, + { + "line" : 36, + "column" : 3 + }, + { + "line" : 39, + "column" : 3 + }, + { + "line" : 42, + "column" : 3 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ "PASSED", @@ -277,7 +315,18 @@ }, "id" : "45" }, - "findTestStepBy" : [ + "findTestStepByTestStepStarted" : [ + "46", + "48", + "50", + "52", + "54", + "56", + "58", + "60", + "62" + ], + "findTestStepByTestStepFinished" : [ "46", "48", "50", diff --git a/testdata/empty.feature.query-results.json b/testdata/empty.feature.query-results.json index 1b4b992a..645774a5 100644 --- a/testdata/empty.feature.query-results.json +++ b/testdata/empty.feature.query-results.json @@ -24,6 +24,12 @@ "findFeatureBy" : [ "Empty Scenarios" ], + "findLocationOf" : [ + { + "line" : 7, + "column" : 3 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ null diff --git a/testdata/examples-tables.feature.query-results.json b/testdata/examples-tables.feature.query-results.json index cd6532d2..8380693f 100644 --- a/testdata/examples-tables.feature.query-results.json +++ b/testdata/examples-tables.feature.query-results.json @@ -40,6 +40,44 @@ "Examples Tables", "Examples Tables" ], + "findLocationOf" : [ + { + "line" : 19, + "column" : 7 + }, + { + "line" : 20, + "column" : 7 + }, + { + "line" : 25, + "column" : 7 + }, + { + "line" : 26, + "column" : 7 + }, + { + "line" : 31, + "column" : 7 + }, + { + "line" : 32, + "column" : 7 + }, + { + "line" : 41, + "column" : 7 + }, + { + "line" : 42, + "column" : 7 + }, + { + "line" : 43, + "column" : 7 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ "PASSED", @@ -257,7 +295,36 @@ }, "id" : "69" }, - "findTestStepBy" : [ + "findTestStepByTestStepStarted" : [ + "70", + "71", + "72", + "74", + "75", + "76", + "78", + "79", + "80", + "82", + "83", + "84", + "86", + "87", + "88", + "90", + "91", + "92", + "94", + "95", + "96", + "98", + "99", + "100", + "102", + "103", + "104" + ], + "findTestStepByTestStepFinished" : [ "70", "71", "72", diff --git a/testdata/hooks.feature.query-results.json b/testdata/hooks.feature.query-results.json index 1616156c..8c4fb304 100644 --- a/testdata/hooks.feature.query-results.json +++ b/testdata/hooks.feature.query-results.json @@ -36,6 +36,20 @@ "0", "3" ], + "findLocationOf" : [ + { + "line" : 4, + "column" : 3 + }, + { + "line" : 7, + "column" : 3 + }, + { + "line" : 10, + "column" : 3 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ "PASSED", @@ -127,7 +141,18 @@ }, "id" : "16" }, - "findTestStepBy" : [ + "findTestStepByTestStepStarted" : [ + "17", + "18", + "19", + "21", + "22", + "23", + "25", + "26", + "27" + ], + "findTestStepByTestStepFinished" : [ "17", "18", "19", diff --git a/testdata/minimal.feature.query-results.json b/testdata/minimal.feature.query-results.json index 5f5cc3aa..c4892efa 100644 --- a/testdata/minimal.feature.query-results.json +++ b/testdata/minimal.feature.query-results.json @@ -24,6 +24,12 @@ "findFeatureBy" : [ "minimal" ], + "findLocationOf" : [ + { + "line" : 9, + "column" : 3 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ "PASSED" @@ -85,7 +91,10 @@ }, "id" : "5" }, - "findTestStepBy" : [ + "findTestStepByTestStepStarted" : [ + "6" + ], + "findTestStepByTestStepFinished" : [ "6" ], "findTestStepsFinishedBy" : [ diff --git a/testdata/rules.feature.query-results.json b/testdata/rules.feature.query-results.json index bd6f8706..a49b48ed 100644 --- a/testdata/rules.feature.query-results.json +++ b/testdata/rules.feature.query-results.json @@ -28,6 +28,20 @@ "Usage of a `Rule`", "Usage of a `Rule`" ], + "findLocationOf" : [ + { + "line" : 9, + "column" : 5 + }, + { + "line" : 16, + "column" : 5 + }, + { + "line" : 25, + "column" : 5 + } + ], "findMeta" : "fake-cucumber", "findMostSevereTestStepResultBy" : [ "PASSED", @@ -137,7 +151,21 @@ }, "id" : "39" }, - "findTestStepBy" : [ + "findTestStepByTestStepStarted" : [ + "40", + "41", + "42", + "43", + "45", + "46", + "47", + "48", + "50", + "51", + "52", + "53" + ], + "findTestStepByTestStepFinished" : [ "40", "41", "42",