Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions commands/testbox/run.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,20 @@
* testbox run outputformats=json,antjunit,simple
* testbox run outputformats=json,antjunit,simple outputFile=myresults
* {code}
* .
* You can stream test results in real-time for immediate feedback during test execution
* {code:bash}
* testbox run --streaming
* {code}
*
**/
component extends="testboxCLI.models.BaseCommand" {

// DI
property name="testingService" inject="TestingService@testbox-cli";
property name="CLIRenderer" inject="CLIRenderer@testbox-cli";
property name="testingService" inject="TestingService@testbox-cli";
property name="CLIRenderer" inject="CLIRenderer@testbox-cli";
property name="SSEClient" inject="SSEClient@testbox-cli";
property name="StreamingRenderer" inject="StreamingRenderer@testbox-cli";

// Default Runner Options
variables.RUNNER_OPTIONS = {
Expand Down Expand Up @@ -91,6 +98,7 @@ component extends="testboxCLI.models.BaseCommand" {
* @outputFile We will store the results in this output file as well as presenting it to you.
* @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
* @verbose Display extra details including passing and skipped tests.
* @streaming Stream test results in real-time via Server-Sent Events (SSE) for immediate feedback during test execution.
* @testboxUseLocal When using outputformats, prefer testbox installation in current working directory over bundled version. If none found, it tries to download one
**/
function run(
Expand All @@ -108,6 +116,7 @@ component extends="testboxCLI.models.BaseCommand" {
string outputFile,
string outputFormats = "",
boolean verbose,
boolean streaming = false,
boolean testboxUseLocal = true
){
// Remove /\ to . in bundles
Expand All @@ -124,6 +133,12 @@ component extends="testboxCLI.models.BaseCommand" {
// Incorporate runner options
arguments.testboxUrl = addRunnerOptions( argumentCollection = arguments );

// If streaming mode, use SSE client
if ( arguments.streaming ) {
runStreaming( argumentCollection = arguments );
return;
}

// Advise we are running
print.boldGreenLine( "Executing tests #testboxUrl# please wait..." ).toConsole();

Expand Down Expand Up @@ -374,4 +389,112 @@ component extends="testboxCLI.models.BaseCommand" {
}
}

/**
* Run tests in streaming mode using Server-Sent Events (SSE)
* This provides real-time feedback as tests execute
*/
private function runStreaming(){
// Add streaming=true to the URL
var streamingUrl = arguments.testboxUrl & "&streaming=true";

// Get verbose setting
var boxOptions = packageService.readPackageDescriptor( getCWD() ).testbox;
var isVerbose = arguments.verbose ?: boxOptions.verbose ?: false;

// Advise we are running in streaming mode
print
.boldCyanLine( "Executing tests in streaming mode..." )
.line()
.toConsole();

// Create event handlers for streaming output
var eventHandlers = StreamingRenderer.createEventHandlers( print, isVerbose );

// Track if tests failed for exit code
var testsFailed = false;
var streamingError = false;

// Override testRunEnd to capture failure state
var originalTestRunEnd = eventHandlers.testRunEnd;
eventHandlers.testRunEnd = function( data ){
// Check for failures in the full results
if (
structKeyExists( data, "results" ) && (
( data.results.totalFail ?: 0 ) > 0 ||
( data.results.totalError ?: 0 ) > 0
)
) {
testsFailed = true;
} else if ( ( data.totalFail ?: 0 ) > 0 || ( data.totalError ?: 0 ) > 0 ) {
testsFailed = true;
}
// Call original handler
originalTestRunEnd( data );
};

// Consume the SSE stream
var finalResults = {};
try {
finalResults = SSEClient.consumeStream(
url = streamingUrl,
eventHandlers = eventHandlers,
onError = function( error ){
// Mark streaming as failed for exit code
streamingError = true;
print.boldRedLine( "Streaming error: #error.message#" ).toConsole();
if ( structKeyExists( error, "detail" ) && len( error.detail ) ) {
print.redLine( error.detail ).toConsole();
}
}
);
} catch ( any e ) {
logger.error(
"Error during streaming: #e.message# #e.detail#",
e
);
return error( "Error executing streaming tests: #CR# #e.message##CR##e.detail#" );
}

// Set exit code based on results or streaming errors
if ( testsFailed || streamingError ) {
setExitCode( 1 );
}

// Render final summary using CLIRenderer if we have full results
if ( !structIsEmpty( finalResults ) ) {
print.line();
CLIRenderer.render( print, finalResults, isVerbose );
}

// Handle output formats if specified
if ( len( arguments.outputFormats ) && !structIsEmpty( finalResults ) ) {
print
.line()
.blueLine( "Output formats detected (#arguments.outputFormats#), building out reports..." )
.toConsole();

buildOutputFormats(
arguments.outputFile ?: "test-results",
arguments.outputFormats,
serializeJSON( finalResults )
);
}

// Handle legacy output file
if ( !isNull( arguments.outputFile ) && !len( arguments.outputFormats ) && !structIsEmpty( finalResults ) ) {
arguments.outputFile = resolvePath( arguments.outputFile );

var thisDir = getDirectoryFromPath( arguments.outputFile );
if ( !directoryExists( thisDir ) ) {
directoryCreate( thisDir );
}

fileWrite(
arguments.outputFile,
formatterUtil.formatJSON( serializeJSON( finalResults ) )
);
print.boldGreenLine( "===> JSON Report written to #arguments.outputFile#!" );
}
}

}
176 changes: 176 additions & 0 deletions models/SSEClient.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* Service for consuming Server-Sent Events (SSE) streams from TestBox
* Parses SSE format and invokes callbacks for each event type
*/
component singleton {

property name="shell" inject="shell";

/**
* Consume an SSE stream from a URL
*
* @url The URL to stream from (should have streaming=true)
* @eventHandlers A struct of callbacks keyed by event type (e.g., bundleStart, specEnd, testRunEnd)
* @onError Callback for connection errors
*
* @return The final testRunEnd event data containing full results, or empty struct on error
*/
public struct function consumeStream(
required string url,
required struct eventHandlers,
any onError
){
var finalResults = {};
var reader = javacast( "null", "" );
var inputStream = javacast( "null", "" );
var connection = javacast( "null", "" );

try {
// Create URL connection
var netURL = createObject( "java", "java.net.URL" ).init( arguments.url );
connection = netURL.openConnection();

connection.setRequestProperty( "Accept", "text/event-stream" );
connection.setRequestProperty(
"User-Agent",
"Mozilla/5.0 (Compatible MSIE 9.0;Windows NT 6.1;WOW64; Trident/5.0)"
);
connection.setConnectTimeout( 30000 );
connection.setReadTimeout( 0 ); // No read timeout for streaming

connection.connect();

// Check response code
if ( connection.responseCode < 200 || connection.responseCode > 299 ) {
throw(
message = "HTTP Error: #connection.responseCode# #connection.responseMessage#",
detail = arguments.url
);
}

// Read the stream line by line
inputStream = connection.getInputStream();
reader = createObject( "java", "java.io.BufferedReader" ).init(
createObject( "java", "java.io.InputStreamReader" ).init( inputStream, "UTF-8" )
);

var currentEvent = "";
var currentData = "";

while ( true ) {
// Check for user interrupt
shell.checkInterrupted();

var line = reader.readLine();

// End of stream
if ( isNull( line ) ) {
// Process any buffered event if stream ends without trailing blank line
if ( len( currentEvent ) && len( currentData ) ) {
processEvent(
eventType = currentEvent,
eventData = currentData,
eventHandlers = arguments.eventHandlers,
finalResults = finalResults
);
}
break;
}

// Parse SSE format
if ( line.startsWith( "event:" ) ) {
currentEvent = trim( line.mid( 7, len( line ) ) );
} else if ( line.startsWith( "data:" ) ) {
currentData = trim( line.mid( 6, len( line ) ) );
} else if ( line == "" && len( currentEvent ) && len( currentData ) ) {
// Empty line signals end of event - process it
processEvent(
eventType = currentEvent,
eventData = currentData,
eventHandlers = arguments.eventHandlers,
finalResults = finalResults
);

// Reset for next event
currentEvent = "";
currentData = "";
}
}
} catch ( any e ) {
if ( !isNull( arguments.onError ) && isClosure( arguments.onError ) ) {
arguments.onError( e );
} else {
rethrow;
}
} finally {
// Clean up resources
try {
if ( !isNull( reader ) ) {
reader.close();
}
} catch ( any ignore ) {
}
try {
if ( !isNull( inputStream ) ) {
inputStream.close();
}
} catch ( any ignore ) {
}
try {
if ( !isNull( connection ) ) {
connection.disconnect();
}
} catch ( any ignore ) {
}
}

return finalResults;
}

/**
* Process a single SSE event
*/
private function processEvent(
required string eventType,
required string eventData,
required struct eventHandlers,
required struct finalResults
){
// Parse JSON data
var data = {};
if ( isJSON( arguments.eventData ) ) {
data = deserializeJSON( arguments.eventData );
}

// If this is the final event, capture the full results
if ( arguments.eventType == "testRunEnd" && structKeyExists( data, "results" ) ) {
structAppend(
arguments.finalResults,
data.results,
true
);
}

// Call the appropriate handler if one exists
if (
structKeyExists(
arguments.eventHandlers,
arguments.eventType
)
) {
var handler = arguments.eventHandlers[ arguments.eventType ];
if ( isClosure( handler ) ) {
handler( data );
}
}

// Also call a generic "onEvent" handler if present
if ( structKeyExists( arguments.eventHandlers, "onEvent" ) ) {
var handler = arguments.eventHandlers[ "onEvent" ];
if ( isClosure( handler ) ) {
handler( arguments.eventType, data );
}
}
}

}
Loading