Skip to content

Commit 72ba893

Browse files
authored
Show all test steps in progress formatter (#3029)
The progress formatter historically has only ever rendered pickle steps. Once hooks were added to Cucumber the progress formatter would also include failed hooks, but no other hooks. This seems rather inconsistent. As hooks also take time to execute and may fail it makes sense to include them in the progress bar. Not entirely coincidentally, this also makes it easier to use Cucumber messages.
1 parent 108c9f5 commit 72ba893

File tree

4 files changed

+302
-128
lines changed

4 files changed

+302
-128
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
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] Show all steps in progress formatter ([#3029](https://github.com/cucumber/cucumber-jvm/pull/3029) M.P. Korstanje)
1315

1416
## [7.26.0] - 2025-07-14
1517
### Added
@@ -18,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1820
### Fixed
1921
- [JUnit Platform Engine] Don't use Java 9+ APIs ([#3025](https://github.com/cucumber/cucumber-jvm/pull/3025) M.P. Korstanje)
2022
- [JUnit Platform Engine] Implement toString on custom DiscoverySelectors
21-
[Core] Fix incomplete id for scenarios under rules in json output ([#3026](https://github.com/cucumber/cucumber-jvm/pull/3026) M.P. Korstanje)
23+
- [Core] Fix incomplete id for scenarios under rules in json output ([#3026](https://github.com/cucumber/cucumber-jvm/pull/3026) M.P. Korstanje)
2224

2325
## [7.25.0] - 2025-07-10
2426
### Changed
Lines changed: 140 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,75 @@
11
package io.cucumber.core.plugin;
22

3+
import io.cucumber.messages.types.Envelope;
4+
import io.cucumber.messages.types.TestRunFinished;
5+
import io.cucumber.messages.types.TestStepFinished;
6+
import io.cucumber.messages.types.TestStepResultStatus;
37
import io.cucumber.plugin.ColorAware;
48
import io.cucumber.plugin.ConcurrentEventListener;
59
import io.cucumber.plugin.event.EventPublisher;
6-
import io.cucumber.plugin.event.PickleStepTestStep;
7-
import io.cucumber.plugin.event.Status;
8-
import io.cucumber.plugin.event.TestRunFinished;
9-
import io.cucumber.plugin.event.TestStepFinished;
1010

1111
import java.io.OutputStream;
12-
import java.util.HashMap;
12+
import java.io.OutputStreamWriter;
13+
import java.io.PrintWriter;
14+
import java.nio.charset.StandardCharsets;
15+
import java.util.EnumMap;
1316
import java.util.Map;
1417

18+
import static io.cucumber.core.plugin.ProgressFormatter.Ansi.Attributes.FOREGROUND_CYAN;
19+
import static io.cucumber.core.plugin.ProgressFormatter.Ansi.Attributes.FOREGROUND_DEFAULT;
20+
import static io.cucumber.core.plugin.ProgressFormatter.Ansi.Attributes.FOREGROUND_GREEN;
21+
import static io.cucumber.core.plugin.ProgressFormatter.Ansi.Attributes.FOREGROUND_RED;
22+
import static io.cucumber.core.plugin.ProgressFormatter.Ansi.Attributes.FOREGROUND_YELLOW;
23+
import static io.cucumber.messages.types.TestStepResultStatus.AMBIGUOUS;
24+
import static io.cucumber.messages.types.TestStepResultStatus.FAILED;
25+
import static io.cucumber.messages.types.TestStepResultStatus.PASSED;
26+
import static io.cucumber.messages.types.TestStepResultStatus.PENDING;
27+
import static io.cucumber.messages.types.TestStepResultStatus.SKIPPED;
28+
import static io.cucumber.messages.types.TestStepResultStatus.UNDEFINED;
29+
import static java.lang.System.lineSeparator;
30+
import static java.util.Objects.requireNonNull;
31+
32+
/**
33+
* Renders a rudimentary progress bar.
34+
* <p>
35+
* Each character in the bar represents either a step or hook. The status of
36+
* that step or hook is indicated by the character and its color.
37+
*/
1538
public final class ProgressFormatter implements ConcurrentEventListener, ColorAware {
1639

17-
private static final Map<Status, Character> CHARS = new HashMap<Status, Character>() {
18-
{
19-
put(Status.PASSED, '.');
20-
put(Status.UNDEFINED, 'U');
21-
put(Status.PENDING, 'P');
22-
put(Status.SKIPPED, '-');
23-
put(Status.FAILED, 'F');
24-
put(Status.AMBIGUOUS, 'A');
25-
}
26-
};
27-
private static final Map<Status, AnsiEscapes> ANSI_ESCAPES = new HashMap<Status, AnsiEscapes>() {
28-
{
29-
put(Status.PASSED, AnsiEscapes.GREEN);
30-
put(Status.UNDEFINED, AnsiEscapes.YELLOW);
31-
put(Status.PENDING, AnsiEscapes.YELLOW);
32-
put(Status.SKIPPED, AnsiEscapes.CYAN);
33-
put(Status.FAILED, AnsiEscapes.RED);
34-
put(Status.AMBIGUOUS, AnsiEscapes.RED);
35-
}
36-
};
40+
private static final int MAX_WIDTH = 80;
41+
private static final Map<TestStepResultStatus, String> SYMBOLS = new EnumMap<>(TestStepResultStatus.class);
42+
private static final Map<TestStepResultStatus, Ansi> ESCAPES = new EnumMap<>(TestStepResultStatus.class);
43+
private static final Ansi RESET = Ansi.with(FOREGROUND_DEFAULT);
44+
static {
45+
SYMBOLS.put(PASSED, ".");
46+
SYMBOLS.put(UNDEFINED, "U");
47+
SYMBOLS.put(PENDING, "P");
48+
SYMBOLS.put(SKIPPED, "-");
49+
SYMBOLS.put(FAILED, "F");
50+
SYMBOLS.put(AMBIGUOUS, "A");
3751

38-
private final UTF8PrintWriter out;
52+
ESCAPES.put(PASSED, Ansi.with(FOREGROUND_GREEN));
53+
ESCAPES.put(UNDEFINED, Ansi.with(FOREGROUND_YELLOW));
54+
ESCAPES.put(PENDING, Ansi.with(FOREGROUND_YELLOW));
55+
ESCAPES.put(SKIPPED, Ansi.with(FOREGROUND_CYAN));
56+
ESCAPES.put(FAILED, Ansi.with(FOREGROUND_RED));
57+
ESCAPES.put(AMBIGUOUS, Ansi.with(FOREGROUND_RED));
58+
}
59+
60+
private final PrintWriter writer;
3961
private boolean monochrome = false;
62+
private int width = 0;
4063

4164
public ProgressFormatter(OutputStream out) {
42-
this.out = new UTF8PrintWriter(out);
65+
this.writer = createPrintWriter(out);
66+
}
67+
68+
private static PrintWriter createPrintWriter(OutputStream out) {
69+
return new PrintWriter(
70+
new OutputStreamWriter(
71+
requireNonNull(out),
72+
StandardCharsets.UTF_8));
4373
}
4474

4575
@Override
@@ -49,32 +79,101 @@ public void setMonochrome(boolean monochrome) {
4979

5080
@Override
5181
public void setEventPublisher(EventPublisher publisher) {
52-
publisher.registerHandlerFor(TestStepFinished.class, this::handleTestStepFinished);
53-
publisher.registerHandlerFor(TestRunFinished.class, this::handleTestRunFinished);
82+
publisher.registerHandlerFor(Envelope.class, event -> {
83+
event.getTestStepFinished().ifPresent(this::handleTestStepFinished);
84+
event.getTestRunFinished().ifPresent(this::handleTestRunFinished);
85+
});
5486
}
5587

5688
private void handleTestStepFinished(TestStepFinished event) {
57-
boolean isTestStep = event.getTestStep() instanceof PickleStepTestStep;
58-
boolean isFailedHookOrTestStep = event.getResult().getStatus().is(Status.FAILED);
59-
if (!(isTestStep || isFailedHookOrTestStep)) {
60-
return;
61-
}
89+
TestStepResultStatus status = event.getTestStepResult().getStatus();
6290
// Prevent tearing in output when multiple threads write to System.out
6391
StringBuilder buffer = new StringBuilder();
6492
if (!monochrome) {
65-
ANSI_ESCAPES.get(event.getResult().getStatus()).appendTo(buffer);
93+
buffer.append(ESCAPES.get(status));
6694
}
67-
buffer.append(CHARS.get(event.getResult().getStatus()));
95+
buffer.append(SYMBOLS.get(status));
6896
if (!monochrome) {
69-
AnsiEscapes.RESET.appendTo(buffer);
97+
buffer.append(RESET);
98+
}
99+
// Start a new line if at the end of this one
100+
if (++width % MAX_WIDTH == 0) {
101+
width = 0;
102+
buffer.append(lineSeparator());
70103
}
71-
out.append(buffer);
72-
out.flush();
104+
writer.append(buffer);
105+
// Flush to provide immediate feedback.
106+
writer.flush();
73107
}
74108

75109
private void handleTestRunFinished(TestRunFinished testRunFinished) {
76-
out.println();
77-
out.close();
110+
writer.println();
111+
writer.close();
112+
}
113+
114+
/**
115+
* Represents an
116+
* <a href="https://en.wikipedia.org/wiki/ANSI_escape_code">ANSI escape
117+
* code</a> in the format {@code CSI n m}.
118+
*/
119+
static final class Ansi {
120+
121+
private static final char FIRST_ESCAPE = 27;
122+
private static final char SECOND_ESCAPE = '[';
123+
private static final String END_SEQUENCE = "m";
124+
private final String controlSequence;
125+
126+
/**
127+
* Constructs an ANSI escape code with the given attributes.
128+
*
129+
* @param attributes to include.
130+
* @return an ANSI escape code with the given attributes
131+
*/
132+
public static Ansi with(Ansi.Attributes... attributes) {
133+
return new Ansi(requireNonNull(attributes));
134+
}
135+
136+
private Ansi(Ansi.Attributes... attributes) {
137+
this.controlSequence = createControlSequence(attributes);
138+
}
139+
140+
private String createControlSequence(Ansi.Attributes... attributes) {
141+
StringBuilder a = new StringBuilder(attributes.length * 5);
142+
143+
for (Ansi.Attributes attribute : attributes) {
144+
a.append(FIRST_ESCAPE).append(SECOND_ESCAPE);
145+
a.append(attribute.value);
146+
a.append(END_SEQUENCE);
147+
}
148+
149+
return a.toString();
150+
}
151+
152+
@Override
153+
public String toString() {
154+
return controlSequence;
155+
}
156+
157+
/**
158+
* A select number of attributes from all the available <a
159+
* href=https://en.wikipedia.org/wiki/ANSI_escape_code#Select_Graphic_Rendition_parameters>Select
160+
* Graphic Rendition attributes</a>.
161+
*/
162+
enum Attributes {
163+
164+
// https://en.wikipedia.org/wiki/ANSI_escape_code#Colors
165+
FOREGROUND_RED(31),
166+
FOREGROUND_GREEN(32),
167+
FOREGROUND_YELLOW(33),
168+
FOREGROUND_CYAN(36),
169+
FOREGROUND_DEFAULT(39);
170+
171+
private final int value;
172+
173+
Attributes(int index) {
174+
this.value = index;
175+
}
176+
}
78177
}
79178

80179
}

cucumber-core/src/test/java/io/cucumber/core/plugin/PluginFactoryTest.java

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

3-
import io.cucumber.core.eventbus.EventBus;
43
import io.cucumber.core.exception.CucumberException;
54
import io.cucumber.core.options.PluginOption;
6-
import io.cucumber.core.runner.ClockStub;
7-
import io.cucumber.core.runtime.TimeServiceEventBus;
85
import io.cucumber.messages.types.Envelope;
96
import io.cucumber.plugin.ConcurrentEventListener;
107
import io.cucumber.plugin.EventListener;
118
import io.cucumber.plugin.event.EventHandler;
129
import io.cucumber.plugin.event.EventPublisher;
13-
import io.cucumber.plugin.event.PickleStepTestStep;
1410
import io.cucumber.plugin.event.Result;
1511
import io.cucumber.plugin.event.Status;
16-
import io.cucumber.plugin.event.TestCase;
1712
import io.cucumber.plugin.event.TestRunFinished;
1813
import io.cucumber.plugin.event.TestRunStarted;
19-
import io.cucumber.plugin.event.TestStepFinished;
2014
import org.junit.jupiter.api.AfterEach;
2115
import org.junit.jupiter.api.Test;
2216
import org.junit.jupiter.api.function.Executable;
2317
import org.junit.jupiter.api.io.TempDir;
2418

25-
import java.io.ByteArrayOutputStream;
2619
import java.io.Closeable;
2720
import java.io.File;
2821
import java.io.IOException;
2922
import java.io.OutputStream;
30-
import java.io.PrintStream;
3123
import java.net.URL;
3224
import java.nio.file.Files;
3325
import java.nio.file.Path;
3426
import java.util.Objects;
35-
import java.util.UUID;
3627

3728
import static io.cucumber.core.options.TestPluginOption.parse;
3829
import static io.cucumber.messages.Convertor.toMessage;
@@ -48,7 +39,6 @@
4839
import static org.junit.jupiter.api.Assertions.assertAll;
4940
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
5041
import static org.junit.jupiter.api.Assertions.assertThrows;
51-
import static org.mockito.Mockito.mock;
5242

5343
class PluginFactoryTest {
5444

@@ -179,33 +169,6 @@ void instantiates_usage_plugin_with_file_arg() {
179169
assertThat(plugin.getClass(), is(equalTo(UsageFormatter.class)));
180170
}
181171

182-
@Test
183-
void plugin_does_not_buffer_its_output() {
184-
PrintStream previousSystemOut = System.out;
185-
OutputStream mockSystemOut = new ByteArrayOutputStream();
186-
187-
try {
188-
System.setOut(new PrintStream(mockSystemOut));
189-
190-
// Need to create a new plugin factory here since we need it to pick
191-
// up the new value of System.out
192-
fc = new PluginFactory();
193-
194-
PluginOption option = parse("progress");
195-
ProgressFormatter plugin = (ProgressFormatter) fc.create(option);
196-
EventBus bus = new TimeServiceEventBus(new ClockStub(ZERO), UUID::randomUUID);
197-
plugin.setEventPublisher(bus);
198-
Result result = new Result(Status.PASSED, ZERO, null);
199-
TestStepFinished event = new TestStepFinished(bus.getInstant(), mock(TestCase.class),
200-
mock(PickleStepTestStep.class), result);
201-
bus.send(event);
202-
203-
assertThat(mockSystemOut.toString(), is(not(equalTo(""))));
204-
} finally {
205-
System.setOut(previousSystemOut);
206-
}
207-
}
208-
209172
@Test
210173
void instantiates_single_custom_appendable_plugin_with_stdout() {
211174
PluginOption option = parse(WantsOutputStream.class.getName());

0 commit comments

Comments
 (0)