11package io .cucumber .core .plugin ;
22
33import com .fasterxml .jackson .annotation .JsonInclude .Include ;
4- import com .fasterxml .jackson .core .JsonGenerator . Feature ;
4+ import com .fasterxml .jackson .core .JsonGenerator ;
55import com .fasterxml .jackson .databind .ObjectMapper ;
66import com .fasterxml .jackson .databind .SerializationFeature ;
77import 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 ;
817import io .cucumber .plugin .ConcurrentEventListener ;
918import 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 ;
2024import java .io .File ;
2125import java .io .FileNotFoundException ;
22- import java .io .FileOutputStream ;
2326import java .io .IOException ;
2427import java .io .InputStream ;
2528import java .io .OutputStream ;
26- import java .net .URI ;
29+ import java .nio .charset .StandardCharsets ;
30+ import java .nio .file .Files ;
2731import java .util .Collection ;
2832import java .util .HashMap ;
33+ import java .util .List ;
2934import java .util .Map ;
3035import java .util .Optional ;
3136import 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 ;
3444import 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+ */
3651public 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