From c3574094d58526b5f1383e4ba1d2d7ff07765e18 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sat, 20 Dec 2025 02:14:18 +0100 Subject: [PATCH 1/6] Add `Lineage.background()` and `.ruleBackground()` --- CHANGELOG.md | 2 ++ .../main/java/io/cucumber/query/Lineage.java | 25 +++++++++++++++++++ .../main/java/io/cucumber/query/Lineages.java | 2 -- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2957359b..4b1ca658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +## Added +- [Java] Add `Lineage.background()` and `.ruleBackground()` ## [14.7.0] - 2025-12-08 ### Added diff --git a/java/src/main/java/io/cucumber/query/Lineage.java b/java/src/main/java/io/cucumber/query/Lineage.java index 37b1e8c3..a1d80285 100644 --- a/java/src/main/java/io/cucumber/query/Lineage.java +++ b/java/src/main/java/io/cucumber/query/Lineage.java @@ -1,9 +1,12 @@ package io.cucumber.query; +import io.cucumber.messages.types.Background; import io.cucumber.messages.types.Examples; import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.FeatureChild; import io.cucumber.messages.types.GherkinDocument; import io.cucumber.messages.types.Rule; +import io.cucumber.messages.types.RuleChild; import io.cucumber.messages.types.Scenario; import io.cucumber.messages.types.TableRow; @@ -74,10 +77,32 @@ public Optional feature() { return Optional.ofNullable(feature); } + public Optional background() { + if (feature == null) { + return Optional.empty(); + } + return feature.getChildren().stream() + .map(FeatureChild::getBackground) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + } + public Optional rule() { return Optional.ofNullable(rule); } + public Optional ruleBackground() { + if (rule == null) { + return Optional.empty(); + } + return rule.getChildren().stream() + .map(RuleChild::getBackground) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + } + public Optional scenario() { return Optional.ofNullable(scenario); } diff --git a/java/src/main/java/io/cucumber/query/Lineages.java b/java/src/main/java/io/cucumber/query/Lineages.java index a82b0bc1..0deec269 100644 --- a/java/src/main/java/io/cucumber/query/Lineages.java +++ b/java/src/main/java/io/cucumber/query/Lineages.java @@ -12,10 +12,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.function.BiConsumer; import java.util.function.Consumer; -import java.util.function.Supplier; class Lineages { From 62247c6b726ebac53eb5cd4f9bf025d9be5e7841 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 21 Dec 2025 07:40:02 +0000 Subject: [PATCH 2/6] update types --- javascript/src/Lineage.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/javascript/src/Lineage.ts b/javascript/src/Lineage.ts index a5214253..bc6b6eb5 100644 --- a/javascript/src/Lineage.ts +++ b/javascript/src/Lineage.ts @@ -1,4 +1,5 @@ import { + Background, Examples, Feature, GherkinDocument, @@ -11,7 +12,9 @@ import { export interface Lineage { gherkinDocument?: GherkinDocument feature?: Feature + background?: Background rule?: Rule + ruleBackground?: Background scenario?: Scenario examples?: Examples examplesIndex?: number From b5440f40ac9d87705a1493e0dbac8a1ed34fa5fe Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 21 Dec 2025 08:00:40 +0000 Subject: [PATCH 3/6] add lineage tests --- javascript/src/Query.spec.ts | 90 +++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/javascript/src/Query.spec.ts b/javascript/src/Query.spec.ts index a29641b2..fb4faa37 100644 --- a/javascript/src/Query.spec.ts +++ b/javascript/src/Query.spec.ts @@ -1,4 +1,6 @@ import assert from 'node:assert' +import fs from 'node:fs/promises' +import path from 'node:path' import { IncrementClock, @@ -12,10 +14,11 @@ 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 { Envelope, TestCaseStarted } from '@cucumber/messages' import { pipeline, Readable, Writable } from 'stream' import { promisify } from 'util' +import { Lineage } from './Lineage' import Query from './Query' const pipelinePromise = promisify(pipeline) @@ -103,6 +106,91 @@ describe('Query', () => { }) }) + describe('#findLineageBy', () => { + it('returns correct lineage for a minimal scenario', async () => { + const envelopes: ReadonlyArray = ( + await fs.readFile(path.join(__dirname, '../../testdata/src/minimal.ndjson'), { + encoding: 'utf-8', + }) + ) + .split('\n') + .filter((line) => !!line) + .map((line) => JSON.parse(line)) + envelopes.forEach((envelope) => cucumberQuery.update(envelope)) + + const gherkinDocument = envelopes.find((envelope) => envelope.gherkinDocument).gherkinDocument + const feature = gherkinDocument.feature + const scenario = feature.children.find((child) => child.scenario).scenario + const pickle = envelopes.find((envelope) => envelope.pickle).pickle + + assert.deepStrictEqual(cucumberQuery.findLineageBy(pickle), { + gherkinDocument, + feature, + scenario, + } satisfies Lineage) + }) + + it('returns correct lineage for a pickle from an examples table', async () => { + const envelopes: ReadonlyArray = ( + await fs.readFile(path.join(__dirname, '../../testdata/src/examples-tables.ndjson'), { + encoding: 'utf-8', + }) + ) + .split('\n') + .filter((line) => !!line) + .map((line) => JSON.parse(line)) + envelopes.forEach((envelope) => cucumberQuery.update(envelope)) + + const gherkinDocument = envelopes.find((envelope) => envelope.gherkinDocument).gherkinDocument + const feature = gherkinDocument.feature + const scenario = feature.children.find((child) => child.scenario).scenario + const pickle = envelopes.find((envelope) => envelope.pickle).pickle + const examples = scenario.examples[0] + const example = examples.tableBody[0] + + assert.deepStrictEqual(cucumberQuery.findLineageBy(pickle), { + gherkinDocument, + feature, + scenario, + examples, + examplesIndex: 0, + example, + exampleIndex: 0, + } satisfies Lineage) + }) + + it('returns correct lineage for a pickle with background-derived steps', async () => { + const envelopes: ReadonlyArray = ( + await fs.readFile(path.join(__dirname, '../../testdata/src/rules-backgrounds.ndjson'), { + encoding: 'utf-8', + }) + ) + .split('\n') + .filter((line) => !!line) + .map((line) => JSON.parse(line)) + envelopes.forEach((envelope) => cucumberQuery.update(envelope)) + + const gherkinDocument = envelopes.find((envelope) => envelope.gherkinDocument).gherkinDocument + const feature = gherkinDocument.feature + const background = gherkinDocument.feature.children.find( + (child) => child.background + ).background + const rule = feature.children.find((child) => child.rule).rule + const ruleBackground = rule.children.find((child) => child.background).background + const scenario = rule.children.find((child) => child.scenario).scenario + const pickle = envelopes.find((envelope) => envelope.pickle).pickle + + assert.deepStrictEqual(cucumberQuery.findLineageBy(pickle), { + gherkinDocument, + feature, + background, + rule, + ruleBackground, + scenario, + } satisfies Lineage) + }) + }) + describe('#getPickleStepTestStepResults(pickleStepIds)', () => { it('returns a single UNKNOWN when the list is empty', () => { const results = cucumberQuery.getPickleTestStepResults([]) From 0c6a4d6b22a86369d0cc3b15c3097c4f31c3485e Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 21 Dec 2025 08:21:21 +0000 Subject: [PATCH 4/6] implement new logic --- javascript/src/Query.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/javascript/src/Query.ts b/javascript/src/Query.ts index 6ae9fcab..a7849291 100644 --- a/javascript/src/Query.ts +++ b/javascript/src/Query.ts @@ -149,6 +149,7 @@ export default class Query { private updateFeature(feature: Feature, lineage: Lineage) { feature.children.forEach((featureChild) => { if (featureChild.background) { + lineage.background = featureChild.background this.updateSteps(featureChild.background.steps) } if (featureChild.scenario) { @@ -169,6 +170,7 @@ export default class Query { private updateRule(rule: Rule, lineage: Lineage) { rule.children.forEach((ruleChild) => { if (ruleChild.background) { + lineage.ruleBackground = ruleChild.background this.updateSteps(ruleChild.background.steps) } if (ruleChild.scenario) { From 5ae70a66e8f0131ec91952bda8837fb61e3e99d1 Mon Sep 17 00:00:00 2001 From: David Goss Date: Sun, 21 Dec 2025 08:22:27 +0000 Subject: [PATCH 5/6] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b1ca658..b011006b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ## Added -- [Java] Add `Lineage.background()` and `.ruleBackground()` +- [Javam, JavaScript] Add `Lineage.background()` and `.ruleBackground()` ([#140](https://github.com/cucumber/query/pull/140)) ## [14.7.0] - 2025-12-08 ### Added From 87dcd27e28f3837a56eded980f5775bc9a2265d0 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 23 Dec 2025 02:05:07 +0100 Subject: [PATCH 6/6] Add tests for lineage --- .../java/io/cucumber/query/LineageTest.java | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 java/src/test/java/io/cucumber/query/LineageTest.java diff --git a/java/src/test/java/io/cucumber/query/LineageTest.java b/java/src/test/java/io/cucumber/query/LineageTest.java new file mode 100644 index 00000000..498e6881 --- /dev/null +++ b/java/src/test/java/io/cucumber/query/LineageTest.java @@ -0,0 +1,128 @@ +package io.cucumber.query; + +import io.cucumber.messages.NdjsonToMessageIterable; +import io.cucumber.messages.types.Background; +import io.cucumber.messages.types.Envelope; +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 org.jspecify.annotations.NonNull; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_GHERKIN_DOCUMENTS; +import static org.assertj.core.api.Assertions.assertThat; + +class LineageTest { + + final Repository repository = Repository.builder() + .feature(INCLUDE_GHERKIN_DOCUMENTS, true) + .build(); + final Query query = new Query(repository); + + @Test + void minimal() throws IOException { + List messages = readMessages(Paths.get("../testdata/src/minimal.ndjson")); + messages.forEach(repository::update); + Pickle pickle = query.findAllPickles().stream() + .findFirst() + .get(); + Lineage lineage = query.findLineageBy(pickle).get(); + + GherkinDocument gherkinDocument = messages.stream().filter(envelope -> envelope.getGherkinDocument().isPresent()) + .map(Envelope::getGherkinDocument) + .map(Optional::get) + .findFirst() + .get(); + Optional feature = gherkinDocument.getFeature(); + Optional scenario = feature.get().getChildren().get(0).getScenario(); + + assertThat(lineage.document()).isEqualTo(gherkinDocument); + assertThat(lineage.feature()).isEqualTo(feature); + assertThat(lineage.background()).isEmpty(); + assertThat(lineage.rule()).isEmpty(); + assertThat(lineage.ruleBackground()).isEmpty(); + assertThat(lineage.scenario()).isEqualTo(scenario); + assertThat(lineage.examples()).isEmpty(); + assertThat(lineage.example()).isEmpty(); + } + + @Test + void exampleTables() throws IOException { + List messages = readMessages(Paths.get("../testdata/src/examples-tables.ndjson")); + messages.forEach(repository::update); + Pickle pickle = query.findAllPickles().stream() + .findFirst() + .get(); + Lineage lineage = query.findLineageBy(pickle).get(); + + GherkinDocument gherkinDocument = messages.stream().filter(envelope -> envelope.getGherkinDocument().isPresent()) + .map(Envelope::getGherkinDocument) + .map(Optional::get) + .findFirst() + .get(); + Optional feature = gherkinDocument.getFeature(); + Optional scenario = feature.get().getChildren().get(0).getScenario(); + Examples examples = scenario.get().getExamples().get(0); + TableRow example = examples.getTableBody().get(0); + + assertThat(lineage.document()).isEqualTo(gherkinDocument); + assertThat(lineage.feature()).isEqualTo(feature); + assertThat(lineage.background()).isEmpty(); + assertThat(lineage.rule()).isEmpty(); + assertThat(lineage.ruleBackground()).isEmpty(); + assertThat(lineage.scenario()).isEqualTo(scenario); + assertThat(lineage.examples()).contains(examples); + assertThat(lineage.example()).contains(example); + } + + @Test + void rulesBackgrounds() throws IOException { + List messages = readMessages(Paths.get("../testdata/src/rules-backgrounds.ndjson")); + messages.forEach(repository::update); + Pickle pickle = query.findAllPickles().stream() + .findFirst() + .get(); + Lineage lineage = query.findLineageBy(pickle).get(); + + GherkinDocument gherkinDocument = messages.stream().filter(envelope -> envelope.getGherkinDocument().isPresent()) + .map(Envelope::getGherkinDocument) + .map(Optional::get) + .findFirst() + .get(); + Optional feature = gherkinDocument.getFeature(); + Optional background = feature.get().getChildren().get(0).getBackground(); + Optional rule = feature.get().getChildren().get(1).getRule(); + Optional ruleBackGround = rule.get().getChildren().get(0).getBackground(); + Optional scenario = rule.get().getChildren().get(1).getScenario(); + + assertThat(lineage.document()).isEqualTo(gherkinDocument); + assertThat(lineage.feature()).isEqualTo(feature); + assertThat(lineage.background()).isEqualTo(background); + assertThat(lineage.rule()).isEqualTo(rule); + assertThat(lineage.ruleBackground()).isEqualTo(ruleBackGround); + assertThat(lineage.scenario()).isEqualTo(scenario); + assertThat(lineage.examples()).isEmpty(); + assertThat(lineage.example()).isEmpty(); + } + + private static @NonNull List readMessages(Path path) throws IOException { + InputStream in = Files.newInputStream(path); + NdjsonToMessageIterable messages = new NdjsonToMessageIterable(in, json -> Jackson.OBJECT_MAPPER.readValue(json, Envelope.class)); + List e = new ArrayList<>(); + messages.forEach(e::add); + return e; + } +}