Skip to content

Commit 485d67b

Browse files
authored
Merge pull request #21 from Ortus-Solutions/feature/streaming-test-results
Add streaming test results support via SSE
2 parents 94206fe + 4e78303 commit 485d67b

4 files changed

Lines changed: 898 additions & 2 deletions

File tree

commands/testbox/run.cfc

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@
5252
* testbox run outputformats=json,antjunit,simple
5353
* testbox run outputformats=json,antjunit,simple outputFile=myresults
5454
* {code}
55+
* .
56+
* You can stream test results in real-time for immediate feedback during test execution
57+
* {code:bash}
58+
* testbox run --streaming
59+
* {code}
5560
*
5661
**/
5762
component extends="testboxCLI.models.BaseCommand" {
5863

5964
// DI
60-
property name="testingService" inject="TestingService@testbox-cli";
61-
property name="CLIRenderer" inject="CLIRenderer@testbox-cli";
65+
property name="testingService" inject="TestingService@testbox-cli";
66+
property name="CLIRenderer" inject="CLIRenderer@testbox-cli";
67+
property name="SSEClient" inject="SSEClient@testbox-cli";
68+
property name="StreamingRenderer" inject="StreamingRenderer@testbox-cli";
6269

6370
// Default Runner Options
6471
variables.RUNNER_OPTIONS = {
@@ -91,6 +98,7 @@ component extends="testboxCLI.models.BaseCommand" {
9198
* @outputFile We will store the results in this output file as well as presenting it to you.
9299
* @outputFormats A list of output reporter to produce using the runner's JSON results only. Available formats are: json,xml,junit,antjunit,simple,dot,doc,min,mintext,doc,text,tap,codexwiki
93100
* @verbose Display extra details including passing and skipped tests.
101+
* @streaming Stream test results in real-time via Server-Sent Events (SSE) for immediate feedback during test execution.
94102
* @testboxUseLocal When using outputformats, prefer testbox installation in current working directory over bundled version. If none found, it tries to download one
95103
**/
96104
function run(
@@ -108,6 +116,7 @@ component extends="testboxCLI.models.BaseCommand" {
108116
string outputFile,
109117
string outputFormats = "",
110118
boolean verbose,
119+
boolean streaming = false,
111120
boolean testboxUseLocal = true
112121
){
113122
// Remove /\ to . in bundles
@@ -124,6 +133,12 @@ component extends="testboxCLI.models.BaseCommand" {
124133
// Incorporate runner options
125134
arguments.testboxUrl = addRunnerOptions( argumentCollection = arguments );
126135

136+
// If streaming mode, use SSE client
137+
if ( arguments.streaming ) {
138+
runStreaming( argumentCollection = arguments );
139+
return;
140+
}
141+
127142
// Advise we are running
128143
print.boldGreenLine( "Executing tests #testboxUrl# please wait..." ).toConsole();
129144

@@ -374,4 +389,112 @@ component extends="testboxCLI.models.BaseCommand" {
374389
}
375390
}
376391

392+
/**
393+
* Run tests in streaming mode using Server-Sent Events (SSE)
394+
* This provides real-time feedback as tests execute
395+
*/
396+
private function runStreaming(){
397+
// Add streaming=true to the URL
398+
var streamingUrl = arguments.testboxUrl & "&streaming=true";
399+
400+
// Get verbose setting
401+
var boxOptions = packageService.readPackageDescriptor( getCWD() ).testbox;
402+
var isVerbose = arguments.verbose ?: boxOptions.verbose ?: false;
403+
404+
// Advise we are running in streaming mode
405+
print
406+
.boldCyanLine( "Executing tests in streaming mode..." )
407+
.line()
408+
.toConsole();
409+
410+
// Create event handlers for streaming output
411+
var eventHandlers = StreamingRenderer.createEventHandlers( print, isVerbose );
412+
413+
// Track if tests failed for exit code
414+
var testsFailed = false;
415+
var streamingError = false;
416+
417+
// Override testRunEnd to capture failure state
418+
var originalTestRunEnd = eventHandlers.testRunEnd;
419+
eventHandlers.testRunEnd = function( data ){
420+
// Check for failures in the full results
421+
if (
422+
structKeyExists( data, "results" ) && (
423+
( data.results.totalFail ?: 0 ) > 0 ||
424+
( data.results.totalError ?: 0 ) > 0
425+
)
426+
) {
427+
testsFailed = true;
428+
} else if ( ( data.totalFail ?: 0 ) > 0 || ( data.totalError ?: 0 ) > 0 ) {
429+
testsFailed = true;
430+
}
431+
// Call original handler
432+
originalTestRunEnd( data );
433+
};
434+
435+
// Consume the SSE stream
436+
var finalResults = {};
437+
try {
438+
finalResults = SSEClient.consumeStream(
439+
url = streamingUrl,
440+
eventHandlers = eventHandlers,
441+
onError = function( error ){
442+
// Mark streaming as failed for exit code
443+
streamingError = true;
444+
print.boldRedLine( "Streaming error: #error.message#" ).toConsole();
445+
if ( structKeyExists( error, "detail" ) && len( error.detail ) ) {
446+
print.redLine( error.detail ).toConsole();
447+
}
448+
}
449+
);
450+
} catch ( any e ) {
451+
logger.error(
452+
"Error during streaming: #e.message# #e.detail#",
453+
e
454+
);
455+
return error( "Error executing streaming tests: #CR# #e.message##CR##e.detail#" );
456+
}
457+
458+
// Set exit code based on results or streaming errors
459+
if ( testsFailed || streamingError ) {
460+
setExitCode( 1 );
461+
}
462+
463+
// Render final summary using CLIRenderer if we have full results
464+
if ( !structIsEmpty( finalResults ) ) {
465+
print.line();
466+
CLIRenderer.render( print, finalResults, isVerbose );
467+
}
468+
469+
// Handle output formats if specified
470+
if ( len( arguments.outputFormats ) && !structIsEmpty( finalResults ) ) {
471+
print
472+
.line()
473+
.blueLine( "Output formats detected (#arguments.outputFormats#), building out reports..." )
474+
.toConsole();
475+
476+
buildOutputFormats(
477+
arguments.outputFile ?: "test-results",
478+
arguments.outputFormats,
479+
serializeJSON( finalResults )
480+
);
481+
}
482+
483+
// Handle legacy output file
484+
if ( !isNull( arguments.outputFile ) && !len( arguments.outputFormats ) && !structIsEmpty( finalResults ) ) {
485+
arguments.outputFile = resolvePath( arguments.outputFile );
486+
487+
var thisDir = getDirectoryFromPath( arguments.outputFile );
488+
if ( !directoryExists( thisDir ) ) {
489+
directoryCreate( thisDir );
490+
}
491+
492+
fileWrite(
493+
arguments.outputFile,
494+
formatterUtil.formatJSON( serializeJSON( finalResults ) )
495+
);
496+
print.boldGreenLine( "===> JSON Report written to #arguments.outputFile#!" );
497+
}
498+
}
499+
377500
}

models/SSEClient.cfc

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
/**
2+
* Service for consuming Server-Sent Events (SSE) streams from TestBox
3+
* Parses SSE format and invokes callbacks for each event type
4+
*/
5+
component singleton {
6+
7+
property name="shell" inject="shell";
8+
9+
/**
10+
* Consume an SSE stream from a URL
11+
*
12+
* @url The URL to stream from (should have streaming=true)
13+
* @eventHandlers A struct of callbacks keyed by event type (e.g., bundleStart, specEnd, testRunEnd)
14+
* @onError Callback for connection errors
15+
*
16+
* @return The final testRunEnd event data containing full results, or empty struct on error
17+
*/
18+
public struct function consumeStream(
19+
required string url,
20+
required struct eventHandlers,
21+
any onError
22+
){
23+
var finalResults = {};
24+
var reader = javacast( "null", "" );
25+
var inputStream = javacast( "null", "" );
26+
var connection = javacast( "null", "" );
27+
28+
try {
29+
// Create URL connection
30+
var netURL = createObject( "java", "java.net.URL" ).init( arguments.url );
31+
connection = netURL.openConnection();
32+
33+
connection.setRequestProperty( "Accept", "text/event-stream" );
34+
connection.setRequestProperty(
35+
"User-Agent",
36+
"Mozilla/5.0 (Compatible MSIE 9.0;Windows NT 6.1;WOW64; Trident/5.0)"
37+
);
38+
connection.setConnectTimeout( 30000 );
39+
connection.setReadTimeout( 0 ); // No read timeout for streaming
40+
41+
connection.connect();
42+
43+
// Check response code
44+
if ( connection.responseCode < 200 || connection.responseCode > 299 ) {
45+
throw(
46+
message = "HTTP Error: #connection.responseCode# #connection.responseMessage#",
47+
detail = arguments.url
48+
);
49+
}
50+
51+
// Read the stream line by line
52+
inputStream = connection.getInputStream();
53+
reader = createObject( "java", "java.io.BufferedReader" ).init(
54+
createObject( "java", "java.io.InputStreamReader" ).init( inputStream, "UTF-8" )
55+
);
56+
57+
var currentEvent = "";
58+
var currentData = "";
59+
60+
while ( true ) {
61+
// Check for user interrupt
62+
shell.checkInterrupted();
63+
64+
var line = reader.readLine();
65+
66+
// End of stream
67+
if ( isNull( line ) ) {
68+
// Process any buffered event if stream ends without trailing blank line
69+
if ( len( currentEvent ) && len( currentData ) ) {
70+
processEvent(
71+
eventType = currentEvent,
72+
eventData = currentData,
73+
eventHandlers = arguments.eventHandlers,
74+
finalResults = finalResults
75+
);
76+
}
77+
break;
78+
}
79+
80+
// Parse SSE format
81+
if ( line.startsWith( "event:" ) ) {
82+
currentEvent = trim( line.mid( 7, len( line ) ) );
83+
} else if ( line.startsWith( "data:" ) ) {
84+
currentData = trim( line.mid( 6, len( line ) ) );
85+
} else if ( line == "" && len( currentEvent ) && len( currentData ) ) {
86+
// Empty line signals end of event - process it
87+
processEvent(
88+
eventType = currentEvent,
89+
eventData = currentData,
90+
eventHandlers = arguments.eventHandlers,
91+
finalResults = finalResults
92+
);
93+
94+
// Reset for next event
95+
currentEvent = "";
96+
currentData = "";
97+
}
98+
}
99+
} catch ( any e ) {
100+
if ( !isNull( arguments.onError ) && isClosure( arguments.onError ) ) {
101+
arguments.onError( e );
102+
} else {
103+
rethrow;
104+
}
105+
} finally {
106+
// Clean up resources
107+
try {
108+
if ( !isNull( reader ) ) {
109+
reader.close();
110+
}
111+
} catch ( any ignore ) {
112+
}
113+
try {
114+
if ( !isNull( inputStream ) ) {
115+
inputStream.close();
116+
}
117+
} catch ( any ignore ) {
118+
}
119+
try {
120+
if ( !isNull( connection ) ) {
121+
connection.disconnect();
122+
}
123+
} catch ( any ignore ) {
124+
}
125+
}
126+
127+
return finalResults;
128+
}
129+
130+
/**
131+
* Process a single SSE event
132+
*/
133+
private function processEvent(
134+
required string eventType,
135+
required string eventData,
136+
required struct eventHandlers,
137+
required struct finalResults
138+
){
139+
// Parse JSON data
140+
var data = {};
141+
if ( isJSON( arguments.eventData ) ) {
142+
data = deserializeJSON( arguments.eventData );
143+
}
144+
145+
// If this is the final event, capture the full results
146+
if ( arguments.eventType == "testRunEnd" && structKeyExists( data, "results" ) ) {
147+
structAppend(
148+
arguments.finalResults,
149+
data.results,
150+
true
151+
);
152+
}
153+
154+
// Call the appropriate handler if one exists
155+
if (
156+
structKeyExists(
157+
arguments.eventHandlers,
158+
arguments.eventType
159+
)
160+
) {
161+
var handler = arguments.eventHandlers[ arguments.eventType ];
162+
if ( isClosure( handler ) ) {
163+
handler( data );
164+
}
165+
}
166+
167+
// Also call a generic "onEvent" handler if present
168+
if ( structKeyExists( arguments.eventHandlers, "onEvent" ) ) {
169+
var handler = arguments.eventHandlers[ "onEvent" ];
170+
if ( isClosure( handler ) ) {
171+
handler( arguments.eventType, data );
172+
}
173+
}
174+
}
175+
176+
}

0 commit comments

Comments
 (0)