Skip to content

Commit 81361ec

Browse files
authored
Use a message based timeline formatter (#3095)
1 parent c3a0a89 commit 81361ec

File tree

3 files changed

+196
-147
lines changed

3 files changed

+196
-147
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
1010
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
1111

1212
## [Unreleased]
13+
### Changed
14+
- [Core] Use a message based `TimeLineFormatter` ([#3095](https://github.com/cucumber/cucumber-jvm/pull/3095) M.P. Korstanje)
1315

1416
## [7.30.0] - 2025-10-01
1517
### Changed

cucumber-core/src/main/java/io/cucumber/core/plugin/TimelineFormatter.java

Lines changed: 156 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,53 @@
11
package io.cucumber.core.plugin;
22

33
import com.fasterxml.jackson.annotation.JsonInclude.Include;
4-
import com.fasterxml.jackson.core.JsonGenerator.Feature;
4+
import com.fasterxml.jackson.core.JsonGenerator;
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import com.fasterxml.jackson.databind.SerializationFeature;
77
import io.cucumber.core.exception.CucumberException;
8+
import io.cucumber.messages.Convertor;
9+
import io.cucumber.messages.types.Envelope;
10+
import io.cucumber.messages.types.Feature;
11+
import io.cucumber.messages.types.Pickle;
12+
import io.cucumber.messages.types.PickleTag;
13+
import io.cucumber.messages.types.TestCaseFinished;
14+
import io.cucumber.messages.types.TestCaseStarted;
15+
import io.cucumber.messages.types.TestStepResult;
16+
import io.cucumber.messages.types.TestStepResultStatus;
817
import io.cucumber.plugin.ConcurrentEventListener;
918
import io.cucumber.plugin.event.EventPublisher;
10-
import io.cucumber.plugin.event.Location;
11-
import io.cucumber.plugin.event.Node;
12-
import io.cucumber.plugin.event.TestCase;
13-
import io.cucumber.plugin.event.TestCaseEvent;
14-
import io.cucumber.plugin.event.TestCaseFinished;
15-
import io.cucumber.plugin.event.TestCaseStarted;
16-
import io.cucumber.plugin.event.TestRunFinished;
17-
import io.cucumber.plugin.event.TestSourceParsed;
18-
19-
import java.io.Closeable;
19+
import io.cucumber.query.Lineage;
20+
import io.cucumber.query.Query;
21+
import io.cucumber.query.Repository;
22+
23+
import java.io.BufferedWriter;
2024
import java.io.File;
2125
import java.io.FileNotFoundException;
22-
import java.io.FileOutputStream;
2326
import java.io.IOException;
2427
import java.io.InputStream;
2528
import java.io.OutputStream;
26-
import java.net.URI;
29+
import java.nio.charset.StandardCharsets;
30+
import java.nio.file.Files;
2731
import java.util.Collection;
2832
import java.util.HashMap;
33+
import java.util.List;
2934
import java.util.Map;
3035
import java.util.Optional;
3136
import java.util.TreeMap;
32-
import java.util.function.Predicate;
37+
import java.util.concurrent.atomic.AtomicInteger;
38+
import java.util.function.Function;
39+
import java.util.function.Supplier;
40+
import java.util.stream.Collectors;
3341

42+
import static com.fasterxml.jackson.annotation.JsonInclude.Value.construct;
43+
import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_GHERKIN_DOCUMENTS;
3444
import static java.util.Locale.ROOT;
3545

46+
/**
47+
* Writes a timeline of scenario execution.
48+
* <p>
49+
* Note: The report is only written once the test run is finished.
50+
*/
3651
public final class TimelineFormatter implements ConcurrentEventListener {
3752

3853
private static final String[] TEXT_ASSETS = new String[] {
@@ -49,164 +64,195 @@ public final class TimelineFormatter implements ConcurrentEventListener {
4964
"/io/cucumber/core/plugin/timeline/chosen-sprite.png"
5065
};
5166

52-
private final Map<String, TestData> allTests = new HashMap<>();
53-
private final Map<Long, GroupData> threadGroups = new HashMap<>();
67+
private final Repository repository = Repository.builder()
68+
.feature(INCLUDE_GHERKIN_DOCUMENTS, true)
69+
.build();
70+
private final Query query = new Query(repository);
5471

5572
private final File reportDir;
56-
private final UTF8OutputStreamWriter reportJs;
57-
private final Map<URI, Collection<Node>> parsedTestSources = new HashMap<>();
5873

5974
private final ObjectMapper objectMapper = new ObjectMapper()
60-
.setSerializationInclusion(Include.NON_NULL)
75+
.setDefaultPropertyInclusion(construct(Include.NON_ABSENT, Include.NON_ABSENT))
6176
.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING)
62-
.disable(Feature.AUTO_CLOSE_TARGET);
77+
.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET);
6378

64-
@SuppressWarnings("unused") // Used by PluginFactory
79+
@SuppressWarnings({ "unused", "RedundantThrows" }) // Used by PluginFactory
6580
public TimelineFormatter(File reportDir) throws FileNotFoundException {
66-
reportDir.mkdirs();
81+
boolean dontCare = reportDir.mkdirs();
6782
if (!reportDir.isDirectory()) {
6883
throw new CucumberException(String.format("The %s needs an existing directory. Not a directory: %s",
6984
getClass().getName(), reportDir.getAbsolutePath()));
7085
}
71-
7286
this.reportDir = reportDir;
73-
this.reportJs = new UTF8OutputStreamWriter(new FileOutputStream(new File(reportDir, "report.js")));
7487
}
7588

7689
@Override
7790
public void setEventPublisher(EventPublisher publisher) {
78-
publisher.registerHandlerFor(TestSourceParsed.class, this::handleTestSourceParsed);
79-
publisher.registerHandlerFor(TestCaseStarted.class, this::handleTestCaseStarted);
80-
publisher.registerHandlerFor(TestCaseFinished.class, this::handleTestCaseFinished);
81-
publisher.registerHandlerFor(TestRunFinished.class, this::finishReport);
91+
publisher.registerHandlerFor(Envelope.class, this::write);
8292
}
8393

84-
private void handleTestSourceParsed(TestSourceParsed event) {
85-
parsedTestSources.put(event.getUri(), event.getNodes());
94+
private void write(Envelope event) {
95+
repository.update(event);
96+
97+
// TODO: Plugins should implement the closable interface
98+
// and be closed by Cucumber
99+
if (event.getTestRunFinished().isPresent()) {
100+
try {
101+
writeTimeLineReport();
102+
} catch (IOException e) {
103+
throw new IllegalStateException(e);
104+
}
105+
}
86106
}
87107

88-
private void handleTestCaseStarted(TestCaseStarted event) {
89-
Thread thread = Thread.currentThread();
90-
threadGroups.computeIfAbsent(thread.getId(), threadId -> {
91-
GroupData group = new GroupData();
92-
group.setContent(thread.toString());
93-
group.setId(threadId);
94-
return group;
95-
});
96-
97-
TestCase testCase = event.getTestCase();
98-
TestData data = new TestData();
99-
data.setId(getId(event));
100-
data.setFeature(findRootNodeName(testCase));
101-
data.setScenario(testCase.getName());
102-
data.setStart(event.getInstant().toEpochMilli());
103-
data.setTags(buildTagsValue(testCase));
104-
data.setGroup(thread.getId());
105-
allTests.put(data.getId(), data);
108+
private void writeTimeLineReport() throws IOException {
109+
Map<String, TimeLineGroup> timeLineGroups = new HashMap<>();
110+
AtomicInteger nextGroupId = new AtomicInteger();
111+
List<TimeLineItem> timeLineItems = query.findAllTestCaseFinished().stream()
112+
.map(testCaseFinished -> query.findTestCaseStartedBy(testCaseFinished)
113+
.map(testCaseStarted -> createTestData(
114+
testCaseFinished, //
115+
testCaseStarted, //
116+
createTimeLineGroup(timeLineGroups, nextGroupId) //
117+
)))
118+
.filter(Optional::isPresent)
119+
.map(Optional::get)
120+
.collect(Collectors.toList());
121+
122+
writeTimeLineReport(timeLineGroups, timeLineItems);
106123
}
107124

108-
private String buildTagsValue(TestCase testCase) {
109-
StringBuilder tags = new StringBuilder();
110-
for (String tag : testCase.getTags()) {
111-
tags.append(tag.toLowerCase()).append(",");
112-
}
113-
return tags.toString();
125+
private Function<String, TimeLineGroup> createTimeLineGroup(
126+
Map<String, TimeLineGroup> timeLineGroups,
127+
AtomicInteger nextGroupId
128+
) {
129+
130+
return workerId -> timeLineGroups.computeIfAbsent(workerId, createTimeLineGroup(nextGroupId::incrementAndGet));
114131
}
115132

116-
private void handleTestCaseFinished(TestCaseFinished event) {
117-
TestData data = allTests.get(getId(event));
118-
data.setEnd(event.getInstant().toEpochMilli());
119-
data.setClassName(event.getResult().getStatus().name().toLowerCase(ROOT));
133+
private Function<String, TimeLineGroup> createTimeLineGroup(Supplier<Integer> nextGroupId) {
134+
return workerId -> {
135+
TimeLineGroup timeLineGroup = new TimeLineGroup();
136+
timeLineGroup.setContent(workerId);
137+
timeLineGroup.setId(nextGroupId.get());
138+
return timeLineGroup;
139+
};
120140
}
121141

122-
private String findRootNodeName(TestCase testCase) {
123-
Location location = testCase.getLocation();
124-
Predicate<Node> withLocation = candidate -> candidate.getLocation().equals(location);
125-
return parsedTestSources.get(testCase.getUri())
126-
.stream()
127-
.map(node -> node.findPathTo(withLocation))
128-
.filter(Optional::isPresent)
129-
.map(Optional::get)
130-
.findFirst()
131-
.map(nodes -> nodes.get(0))
132-
.flatMap(Node::getName)
133-
.orElse("Unknown");
142+
private TimeLineItem createTestData(
143+
TestCaseFinished testCaseFinished, TestCaseStarted testCaseStarted,
144+
Function<String, TimeLineGroup> timeLineGroupCreator
145+
) {
146+
String workerId = testCaseStarted.getWorkerId().orElse("");
147+
TimeLineGroup timeLineGroup = timeLineGroupCreator.apply(workerId);
148+
return createTestData(testCaseFinished, testCaseStarted, timeLineGroup);
149+
}
150+
151+
private TimeLineItem createTestData(
152+
TestCaseFinished testCaseFinished, TestCaseStarted testCaseStarted, TimeLineGroup timeLineGroup
153+
) {
154+
TimeLineItem data = new TimeLineItem();
155+
data.setId(testCaseStarted.getTestCaseId());
156+
data.setFeature(getFeatureName(testCaseStarted));
157+
data.setScenario(getPickleName(testCaseStarted));
158+
data.setStart(Convertor.toInstant(testCaseStarted.getTimestamp()).toEpochMilli());
159+
data.setTags(getTagsValue(testCaseStarted));
160+
data.setGroup(timeLineGroup.getId());
161+
data.setEnd(Convertor.toInstant(testCaseFinished.getTimestamp()).toEpochMilli());
162+
data.setClassName(getTestStepStatusResult(testCaseFinished));
163+
return data;
134164
}
135165

136-
private void finishReport(TestRunFinished event) {
166+
private String getTestStepStatusResult(TestCaseFinished event) {
167+
return query.findMostSevereTestStepResultBy(event)
168+
.map(TestStepResult::getStatus)
169+
.map(TestStepResultStatus::value)
170+
.map(s -> s.toLowerCase(ROOT))
171+
// By definition
172+
.orElse("passed");
173+
}
174+
175+
private String getPickleName(TestCaseStarted testCaseStarted) {
176+
return query.findPickleBy(testCaseStarted)
177+
.map(Pickle::getName)
178+
.orElse("");
179+
}
180+
181+
private String getFeatureName(TestCaseStarted testCaseStarted) {
182+
return query.findLineageBy(testCaseStarted)
183+
.flatMap(Lineage::feature)
184+
.map(Feature::getName)
185+
.orElse("");
186+
}
187+
188+
private String getTagsValue(TestCaseStarted testCaseStarted) {
189+
return query.findPickleBy(testCaseStarted)
190+
.map(pickle -> {
191+
StringBuilder tags = new StringBuilder();
192+
for (PickleTag tag : pickle.getTags()) {
193+
tags.append(tag.getName().toLowerCase()).append(",");
194+
}
195+
return tags.toString();
196+
}).orElse("");
197+
}
137198

138-
try {
199+
private void writeTimeLineReport(Map<String, TimeLineGroup> timeLineGroups, List<TimeLineItem> timeLineItems)
200+
throws IOException {
201+
writeReportJs(timeLineGroups, timeLineItems);
202+
copyReportFiles();
203+
}
204+
205+
private void writeReportJs(Map<String, TimeLineGroup> timeLineGroups, List<TimeLineItem> timeLineItems)
206+
throws IOException {
207+
File reportJsFile = new File(reportDir, "report.js");
208+
try (BufferedWriter reportJs = Files.newBufferedWriter(reportJsFile.toPath(), StandardCharsets.UTF_8)) {
139209
reportJs.append("$(document).ready(function() {");
140210
reportJs.append("\n");
141-
appendAsJsonToJs(reportJs, "timelineItems", allTests.values());
211+
appendAsJsonToJs(reportJs, "timelineItems", timeLineItems);
142212
reportJs.append("\n");
143213
// Need to sort groups by id, so can guarantee output of order in
144214
// rendered timeline
145-
appendAsJsonToJs(reportJs, "timelineGroups", new TreeMap<>(threadGroups).values());
215+
appendAsJsonToJs(reportJs, "timelineGroups", new TreeMap<>(timeLineGroups).values());
146216
reportJs.append("\n");
147217
reportJs.append("});");
148-
reportJs.close();
149-
copyReportFiles();
150-
} catch (IOException e) {
151-
throw new RuntimeException(e);
152218
}
153219
}
154220

155-
private static String getId(TestCaseEvent testCaseEvent) {
156-
return testCaseEvent.getTestCase().getId().toString();
157-
}
158-
159221
private void appendAsJsonToJs(
160-
UTF8OutputStreamWriter out, String pushTo, Collection<?> content
222+
BufferedWriter out, String pushTo, Collection<?> content
161223
) throws IOException {
162224
out.append("CucumberHTML.").append(pushTo).append(".pushArray(");
163225
objectMapper.writeValue(out, content);
164226
out.append(");");
165227
}
166228

167-
private void copyReportFiles() {
229+
private void copyReportFiles() throws IOException {
168230
if (reportDir == null) {
169231
return;
170232
}
171233
File outputDir = new File(reportDir.getPath());
172234
for (String textAsset : TEXT_ASSETS) {
173-
InputStream textAssetStream = getClass().getResourceAsStream(textAsset);
174-
if (textAssetStream == null) {
175-
throw new CucumberException("Couldn't find " + textAsset);
235+
try (InputStream textAssetStream = getClass().getResourceAsStream(textAsset)) {
236+
if (textAssetStream == null) {
237+
throw new CucumberException("Couldn't find " + textAsset);
238+
}
239+
String fileName = new File(textAsset).getName();
240+
copyFile(textAssetStream, new File(outputDir, fileName));
176241
}
177-
String fileName = new File(textAsset).getName();
178-
copyFile(textAssetStream, new File(outputDir, fileName));
179-
closeQuietly(textAssetStream);
180242
}
181243
}
182244

183-
private static void copyFile(InputStream source, File dest) throws CucumberException {
184-
OutputStream os = null;
185-
try {
186-
os = new FileOutputStream(dest);
245+
private static void copyFile(InputStream source, File dest) throws IOException {
246+
try (OutputStream os = Files.newOutputStream(dest.toPath())) {
187247
byte[] buffer = new byte[1024];
188248
int length;
189249
while ((length = source.read(buffer)) > 0) {
190250
os.write(buffer, 0, length);
191251
}
192-
} catch (IOException e) {
193-
throw new CucumberException("Unable to write to report file item: ", e);
194-
} finally {
195-
closeQuietly(os);
196-
}
197-
}
198-
199-
private static void closeQuietly(Closeable out) {
200-
try {
201-
if (out != null) {
202-
out.close();
203-
}
204-
} catch (IOException ignored) {
205-
// go gentle into that good night
206252
}
207253
}
208254

209-
static class GroupData {
255+
static class TimeLineGroup {
210256

211257
private long id;
212258
private String content;
@@ -229,7 +275,7 @@ public String getContent() {
229275

230276
}
231277

232-
static class TestData {
278+
static class TimeLineItem {
233279

234280
private String id;
235281
private String feature;

0 commit comments

Comments
 (0)