From 18a5016458ab20f5f5d1863445a51179aa92f7ee Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 2 Mar 2026 21:34:15 -0700 Subject: [PATCH 1/7] Add streaming test results support via SSE Implement real-time test progress streaming using Server-Sent Events for immediate feedback during test execution instead of waiting for completion. - Add SSEClient.cfc for consuming SSE streams from TestBox - Add StreamingRenderer.cfc for real-time CLI output of test events - Update run.cfc with --streaming flag to enable streaming mode - Compatible with outputFormats for generating multiple report files Usage: testbox run --streaming --- commands/testbox/run.cfc | 115 +++++++++++++++++++- models/SSEClient.cfc | 138 ++++++++++++++++++++++++ models/StreamingRenderer.cfc | 204 +++++++++++++++++++++++++++++++++++ 3 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 models/SSEClient.cfc create mode 100644 models/StreamingRenderer.cfc diff --git a/commands/testbox/run.cfc b/commands/testbox/run.cfc index ef75164..1927298 100644 --- a/commands/testbox/run.cfc +++ b/commands/testbox/run.cfc @@ -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 = { @@ -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( @@ -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 @@ -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(); @@ -374,4 +389,100 @@ 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; + + // 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 ){ + 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 + if ( testsFailed ) { + 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#!" ); + } + } + } diff --git a/models/SSEClient.cfc b/models/SSEClient.cfc new file mode 100644 index 0000000..5a0d2d5 --- /dev/null +++ b/models/SSEClient.cfc @@ -0,0 +1,138 @@ +/** + * 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 = {}; + + try { + // Create URL connection + var netURL = createObject( "java", "java.net.URL" ).init( arguments.url ); + var 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 + var inputStream = connection.getInputStream(); + var 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 ) ) { + 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 = ""; + } + } + + reader.close(); + inputStream.close(); + } catch ( any e ) { + if ( !isNull( arguments.onError ) && isClosure( arguments.onError ) ) { + arguments.onError( e ); + } else { + rethrow; + } + } + + 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 ); + } + } + } + +} diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc new file mode 100644 index 0000000..1676da9 --- /dev/null +++ b/models/StreamingRenderer.cfc @@ -0,0 +1,204 @@ +/** + * Renders streaming test results to the CLI in real-time + * Works with SSE events from TestBox's StreamingRunner + */ +component singleton { + + property name="progressBarGeneric" inject="progressBarGeneric"; + + processingdirective pageEncoding="UTF-8"; + + variables.COLOR = { + PASS : "SpringGreen1", + SKIP : "blue", + ERROR : "boldRed", + FAIL : "red", + RUNNING : "yellow" + }; + + // Track state during streaming + variables.state = { + "currentBundle" : "", + "currentSuite" : "", + "suiteStack" : [], + "totalBundles" : 0, + "completedBundles" : 0, + "totalSpecs" : 0, + "passedSpecs" : 0, + "failedSpecs" : 0, + "errorSpecs" : 0, + "skippedSpecs" : 0 + }; + + /** + * Reset state for a new test run + */ + function resetState(){ + variables.state = { + "currentBundle" : "", + "currentSuite" : "", + "suiteStack" : [], + "totalBundles" : 0, + "completedBundles" : 0, + "totalSpecs" : 0, + "passedSpecs" : 0, + "failedSpecs" : 0, + "errorSpecs" : 0, + "skippedSpecs" : 0 + }; + return this; + } + + /** + * Create event handlers for SSE streaming + * + * @print The print buffer from CommandBox + * @verbose Whether to show passing specs + * + * @return Struct of event handler closures + */ + struct function createEventHandlers( required print, boolean verbose = false ){ + var renderer = this; + var p = arguments.print; + var v = arguments.verbose; + + return { + "testRunStart" : function( data ){ + renderer.resetState(); + variables.state.totalBundles = data.totalBundles ?: 0; + p.line().boldCyanLine( "Starting test run with #variables.state.totalBundles# bundle(s)..." ).toConsole(); + }, + "bundleStart" : function( data ){ + variables.state.currentBundle = data.name ?: data.path ?: "Unknown Bundle"; + variables.state.suiteStack = []; + p.line().boldWhiteLine( "Bundle: #variables.state.currentBundle#" ).toConsole(); + }, + "bundleEnd" : function( data ){ + variables.state.completedBundles++; + var color = renderer.getAggregatedColor( data.totalError ?: 0, data.totalFail ?: 0, 0 ); + p.line( + " [Passed: #data.totalPass ?: 0#] [Failed: #data.totalFail ?: 0#] [Errors: #data.totalError ?: 0#] [Skipped: #data.totalSkipped ?: 0#] (#data.totalDuration ?: 0# ms)", + color + ) + .toConsole(); + }, + "suiteStart" : function( data ){ + variables.state.suiteStack.append( data.name ?: "Unknown Suite" ); + variables.state.currentSuite = data.name ?: "Unknown Suite"; + var indent = repeatString( " ", variables.state.suiteStack.len() ); + if ( v ) { + p.line( "#indent##data.name ?: 'Unknown Suite'#", "white" ).toConsole(); + } + }, + "suiteEnd" : function( data ){ + if ( variables.state.suiteStack.len() ) { + variables.state.suiteStack.deleteAt( variables.state.suiteStack.len() ); + } + if ( variables.state.suiteStack.len() ) { + variables.state.currentSuite = variables.state.suiteStack[ variables.state.suiteStack.len() ]; + } else { + variables.state.currentSuite = ""; + } + }, + "specStart" : function( data ){ + variables.state.totalSpecs++; + // Could show a spinner or "running" indicator here + }, + "specEnd" : function( data ){ + var status = data.status ?: "unknown"; + var name = data.displayName ?: data.name ?: "Unknown Spec"; + var indent = repeatString( " ", variables.state.suiteStack.len() + 1 ); + + // Update counters + switch ( status ) { + case "passed": + variables.state.passedSpecs++; + break; + case "failed": + variables.state.failedSpecs++; + break; + case "error": + variables.state.errorSpecs++; + break; + case "skipped": + variables.state.skippedSpecs++; + break; + } + + // Only show non-passing specs, or all if verbose + if ( status != "passed" || v ) { + var indicator = renderer.getIndicator( status ); + var color = renderer.getStatusColor( status ); + p.line( "#indent##indicator##name# (#data.totalDuration ?: 0# ms)", color ).toConsole(); + + // Show failure details + if ( status == "failed" && len( data.failMessage ?: "" ) ) { + p.line( "#indent# -> Failure: #data.failMessage#", variables.COLOR.FAIL ).toConsole(); + } + + // Show error details + if ( status == "error" && structKeyExists( data, "error" ) && isStruct( data.error ) ) { + p.line( "#indent# -> Error: #data.error.message ?: 'Unknown error'#", variables.COLOR.ERROR ) + .toConsole(); + } + } + }, + "testRunEnd" : function( data ){ + // Final summary is handled by the main renderer using the full results + p.line().boldGreenLine( "Test run complete!" ).toConsole(); + } + }; + } + + /** + * Get status indicator character + */ + function getIndicator( required string status ){ + switch ( arguments.status ) { + case "error": + return "!! "; + case "failed": + return "X "; + case "skipped": + return "- "; + case "passed": + return "√ "; + default: + return "? "; + } + } + + /** + * Get color for a status + */ + function getStatusColor( required string status ){ + switch ( arguments.status ) { + case "error": + return variables.COLOR.ERROR; + case "failed": + return variables.COLOR.FAIL; + case "skipped": + return variables.COLOR.SKIP; + case "passed": + return variables.COLOR.PASS; + default: + return "white"; + } + } + + /** + * Get aggregate color based on error/failure counts + */ + function getAggregatedColor( errors = 0, failures = 0, skips = 0 ){ + if ( arguments.errors ) { + return variables.COLOR.ERROR; + } else if ( arguments.failures ) { + return variables.COLOR.FAIL; + } else if ( arguments.skips ) { + return variables.COLOR.SKIP; + } else { + return variables.COLOR.PASS; + } + } + +} From de1857c0b6a540137b1c7c86983d85da7cd1d5a9 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 2 Mar 2026 23:14:22 -0700 Subject: [PATCH 2/7] Fix real-time running spec indicator display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add terminal.flush() after writer.flush() for immediate display - Use raw terminal writer to bypass AttributedString ANSI processing - Change skipped specs color from blue to gray - Add ANSI escape codes for carriage return and clear line - Add comprehensive tests for StreamingRenderer behavior - Running spec indicator (») now shows in yellow during test execution - Passing specs clear the line without leaving a trace - Failed/errored/skipped specs persist with details --- models/StreamingRenderer.cfc | 76 ++++++- tests/specs/StreamingRendererTest.cfc | 282 ++++++++++++++++++++++++++ 2 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 tests/specs/StreamingRendererTest.cfc diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc index 1676da9..a116c4e 100644 --- a/models/StreamingRenderer.cfc +++ b/models/StreamingRenderer.cfc @@ -5,17 +5,26 @@ component singleton { property name="progressBarGeneric" inject="progressBarGeneric"; + property name="shell" inject="shell"; + property name="print" inject="Print"; processingdirective pageEncoding="UTF-8"; variables.COLOR = { PASS : "SpringGreen1", - SKIP : "blue", + SKIP : "gray", ERROR : "boldRed", FAIL : "red", RUNNING : "yellow" }; + // ANSI escape codes for terminal control + variables.ANSI = { + "CLEAR_LINE" : chr( 27 ) & "[2K", // Clear entire line + "CARRIAGE_RETURN" : chr( 13 ), // Move cursor to beginning of line + "CURSOR_UP" : chr( 27 ) & "[1A" // Move cursor up one line + }; + // Track state during streaming variables.state = { "currentBundle" : "", @@ -27,7 +36,9 @@ component singleton { "passedSpecs" : 0, "failedSpecs" : 0, "errorSpecs" : 0, - "skippedSpecs" : 0 + "skippedSpecs" : 0, + "hasRunningSpec" : false, // Track if we have a running spec line to overwrite + "runningSpecLine" : "" // Track current running spec line content }; /** @@ -44,7 +55,9 @@ component singleton { "passedSpecs" : 0, "failedSpecs" : 0, "errorSpecs" : 0, - "skippedSpecs" : 0 + "skippedSpecs" : 0, + "hasRunningSpec" : false, + "runningSpecLine" : "" }; return this; } @@ -61,6 +74,24 @@ component singleton { var renderer = this; var p = arguments.print; var v = arguments.verbose; + var sh = variables.shell; + var pr = variables.print; // Print helper for getting colored strings + + // Get terminal for real-time output + var terminal = javacast( "null", "" ); + var termWriter = javacast( "null", "" ); + try { + if ( !isNull( sh ) && !isNull( sh.getReader() ) ) { + terminal = sh.getReader().getTerminal(); + termWriter = terminal.writer(); + } + } catch ( any e ) { + // Terminal not available, will fall back to print buffer + } + + // ANSI codes for terminal control (needed in closures) + var ANSI_CR = variables.ANSI.CARRIAGE_RETURN; + var ANSI_CLEAR = variables.ANSI.CLEAR_LINE; return { "testRunStart" : function( data ){ @@ -102,7 +133,26 @@ component singleton { }, "specStart" : function( data ){ variables.state.totalSpecs++; - // Could show a spinner or "running" indicator here + var name = data.displayName ?: data.name ?: "Unknown Spec"; + var indent = repeatString( " ", variables.state.suiteStack.len() + 1 ); + + // Show running spec indicator + // Use raw terminal writer for proper ANSI code handling (bypasses AttributedString) + if ( !isNull( termWriter ) && !isNull( pr ) ) { + var runningText = pr.yellowText( "#indent#» #name#..." ); + termWriter.print( runningText ); + termWriter.flush(); + // Also flush the terminal itself to force immediate display + if ( !isNull( terminal ) ) { + terminal.flush(); + } + variables.state.hasRunningSpec = true; + variables.state.runningSpecLine = runningText; + } else { + // Fallback to print buffer for testing + p.text( "#indent#» #name#...", variables.COLOR.RUNNING ).toConsole(); + variables.state.hasRunningSpec = true; + } }, "specEnd" : function( data ){ var status = data.status ?: "unknown"; @@ -125,7 +175,23 @@ component singleton { break; } - // Only show non-passing specs, or all if verbose + // Clear the running spec line + if ( variables.state.hasRunningSpec ) { + if ( !isNull( termWriter ) ) { + // Move to beginning of line and clear it + termWriter.print( ANSI_CR & ANSI_CLEAR ); + termWriter.flush(); + } else { + // Fallback for testing + p.text( ANSI_CR & ANSI_CLEAR ).toConsole(); + } + variables.state.hasRunningSpec = false; + variables.state.runningSpecLine = ""; + } + + // For passed specs: line is cleared, nothing printed (already overwritten) + // For failed/error/skipped: print the result (it persists) + // If verbose: always print the result if ( status != "passed" || v ) { var indicator = renderer.getIndicator( status ); var color = renderer.getStatusColor( status ); diff --git a/tests/specs/StreamingRendererTest.cfc b/tests/specs/StreamingRendererTest.cfc new file mode 100644 index 0000000..2e9320a --- /dev/null +++ b/tests/specs/StreamingRendererTest.cfc @@ -0,0 +1,282 @@ +/** + * Tests for StreamingRenderer output behavior + */ +component extends="testbox.system.BaseSpec" { + + /*********************************** LIFE CYCLE Methods ***********************************/ + + function beforeAll(){ + variables.renderer = new models.StreamingRenderer(); + // ANSI codes for verification (must match StreamingRenderer.cfc) + variables.ANSI = { + "CLEAR_LINE" : chr( 27 ) & "[2K", // Clear entire line + "CARRIAGE_RETURN" : chr( 13 ) + }; + } + + function afterAll(){ + } + + /*********************************** BDD SUITES ***********************************/ + + function run(){ + describe( "StreamingRenderer", () => { + beforeEach( () => { + // Reset renderer state before each test + renderer.resetState(); + // Create a mock print buffer that captures output + variables.mockPrint = createMockPrint(); + } ); + + describe( "without verbose flag", () => { + it( "should NOT show passing specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + // Simulate a passing spec + handlers.specStart( { "name" : "should pass", "displayName" : "should pass" } ); + handlers.specEnd( { + "name" : "should pass", + "displayName" : "should pass", + "status" : "passed", + "totalDuration" : 10 + } ); + + // The output should only contain the clear line sequence, not the spec result + var output = mockPrint.getOutput(); + // Should have the running indicator cleared + expect( output ).toInclude( variables.ANSI.CARRIAGE_RETURN ); + expect( output ).toInclude( variables.ANSI.CLEAR_LINE ); + // Should NOT have the passed indicator + expect( output ).notToInclude( "√" ); + } ); + + it( "should show failed specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + // Simulate a failing spec + handlers.specStart( { "name" : "should fail", "displayName" : "should fail" } ); + handlers.specEnd( { + "name" : "should fail", + "displayName" : "should fail", + "status" : "failed", + "totalDuration" : 15, + "failMessage" : "Expected true but got false" + } ); + + var output = mockPrint.getOutput(); + // Should show the failed indicator and message + expect( output ).toInclude( "X" ); + expect( output ).toInclude( "should fail" ); + expect( output ).toInclude( "Expected true but got false" ); + } ); + + it( "should show error specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + // Simulate an error spec + handlers.specStart( { "name" : "should error", "displayName" : "should error" } ); + handlers.specEnd( { + "name" : "should error", + "displayName" : "should error", + "status" : "error", + "totalDuration" : 20, + "error" : { "message" : "NullPointerException" } + } ); + + var output = mockPrint.getOutput(); + // Should show the error indicator and message + expect( output ).toInclude( "!!" ); + expect( output ).toInclude( "should error" ); + expect( output ).toInclude( "NullPointerException" ); + } ); + + it( "should show skipped specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + // Simulate a skipped spec + handlers.specStart( { "name" : "should be skipped", "displayName" : "should be skipped" } ); + handlers.specEnd( { + "name" : "should be skipped", + "displayName" : "should be skipped", + "status" : "skipped", + "totalDuration" : 0 + } ); + + var output = mockPrint.getOutput(); + // Should show the skipped indicator + expect( output ).toInclude( "-" ); + expect( output ).toInclude( "should be skipped" ); + } ); + } ); + + describe( "with verbose flag", () => { + it( "should show passing specs when verbose is true", () => { + var handlers = renderer.createEventHandlers( mockPrint, true ); + + // Simulate a passing spec + handlers.specStart( { "name" : "should pass", "displayName" : "should pass" } ); + handlers.specEnd( { + "name" : "should pass", + "displayName" : "should pass", + "status" : "passed", + "totalDuration" : 10 + } ); + + var output = mockPrint.getOutput(); + // Should show the passed indicator + expect( output ).toInclude( "√" ); + expect( output ).toInclude( "should pass" ); + } ); + } ); + + describe( "color configuration", () => { + it( "should use gray color for skipped specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + // Simulate a skipped spec + handlers.specStart( { "name" : "skipped test", "displayName" : "skipped test" } ); + handlers.specEnd( { + "name" : "skipped test", + "displayName" : "skipped test", + "status" : "skipped", + "totalDuration" : 0 + } ); + + // Check the color was set to gray + var colors = mockPrint.getColors(); + expect( colors ).toInclude( "gray" ); + } ); + + it( "should use red color for failed specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + handlers.specStart( { "name" : "failed test", "displayName" : "failed test" } ); + handlers.specEnd( { + "name" : "failed test", + "displayName" : "failed test", + "status" : "failed", + "totalDuration" : 10, + "failMessage" : "Assertion failed" + } ); + + var colors = mockPrint.getColors(); + expect( colors ).toInclude( "red" ); + } ); + + it( "should use boldRed color for error specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + handlers.specStart( { "name" : "error test", "displayName" : "error test" } ); + handlers.specEnd( { + "name" : "error test", + "displayName" : "error test", + "status" : "error", + "totalDuration" : 10, + "error" : { "message" : "Runtime error" } + } ); + + var colors = mockPrint.getColors(); + expect( colors ).toInclude( "boldRed" ); + } ); + + it( "should use yellow color for running specs", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + handlers.specStart( { "name" : "running test", "displayName" : "running test" } ); + + var colors = mockPrint.getColors(); + expect( colors ).toInclude( "yellow" ); + } ); + } ); + + describe( "running spec indicator", () => { + it( "should show running indicator on specStart", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + handlers.specStart( { "name" : "my test", "displayName" : "my test" } ); + + var output = mockPrint.getOutput(); + expect( output ).toInclude( "»" ); + expect( output ).toInclude( "my test" ); + expect( output ).toInclude( "..." ); + } ); + + it( "should clear running line before showing result", () => { + var handlers = renderer.createEventHandlers( mockPrint, false ); + + handlers.specStart( { "name" : "failing test", "displayName" : "failing test" } ); + handlers.specEnd( { + "name" : "failing test", + "displayName" : "failing test", + "status" : "failed", + "totalDuration" : 10, + "failMessage" : "Failed" + } ); + + var output = mockPrint.getOutput(); + // Should have clear line before the result + var clearLinePos = find( variables.ANSI.CLEAR_LINE, output ); + var failedPos = find( "X", output ); + expect( clearLinePos ).toBeGT( 0 ); + expect( failedPos ).toBeGT( clearLinePos ); + } ); + } ); + } ); + } + + /*********************************** HELPER METHODS ***********************************/ + + /** + * Creates a mock print object that captures output for testing + */ + private function createMockPrint(){ + // Use local variables that closures can reference + var outputBuffer = { "value" : "" }; + var colorsBuffer = { "value" : [] }; + + var mock = { + "_outputBuffer" : outputBuffer, + "_colorsBuffer" : colorsBuffer, + "text" : function( text, color = "" ){ + outputBuffer.value &= arguments.text; + if ( len( arguments.color ) ) { + colorsBuffer.value.append( arguments.color ); + } + return mock; + }, + "line" : function( text = "", color = "" ){ + outputBuffer.value &= arguments.text & chr( 10 ); + if ( len( arguments.color ) ) { + colorsBuffer.value.append( arguments.color ); + } + return mock; + }, + "toConsole" : function(){ + return mock; + }, + "boldCyanLine" : function( text ){ + outputBuffer.value &= arguments.text & chr( 10 ); + colorsBuffer.value.append( "boldCyan" ); + return mock; + }, + "boldWhiteLine" : function( text ){ + outputBuffer.value &= arguments.text & chr( 10 ); + colorsBuffer.value.append( "boldWhite" ); + return mock; + }, + "boldGreenLine" : function( text ){ + outputBuffer.value &= arguments.text & chr( 10 ); + colorsBuffer.value.append( "boldGreen" ); + return mock; + }, + "getOutput" : function(){ + return outputBuffer.value; + }, + "getColors" : function(){ + return colorsBuffer.value; + } + }; + return mock; + } + +} From ca6c31b0b20f3199f36d4621453190485cd7aee7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 2 Mar 2026 22:27:16 -0800 Subject: [PATCH 3/7] Formatting --- commands/testbox/run.cfc | 19 +++++-- models/SSEClient.cfc | 17 +++++-- models/StreamingRenderer.cfc | 97 +++++++++++++++++++++++++----------- 3 files changed, 96 insertions(+), 37 deletions(-) diff --git a/commands/testbox/run.cfc b/commands/testbox/run.cfc index 1927298..cec7863 100644 --- a/commands/testbox/run.cfc +++ b/commands/testbox/run.cfc @@ -116,7 +116,7 @@ component extends="testboxCLI.models.BaseCommand" { string outputFile, string outputFormats = "", boolean verbose, - boolean streaming = false, + boolean streaming = false, boolean testboxUseLocal = true ){ // Remove /\ to . in bundles @@ -402,7 +402,10 @@ component extends="testboxCLI.models.BaseCommand" { var isVerbose = arguments.verbose ?: boxOptions.verbose ?: false; // Advise we are running in streaming mode - print.boldCyanLine( "Executing tests in streaming mode..." ).line().toConsole(); + print + .boldCyanLine( "Executing tests in streaming mode..." ) + .line() + .toConsole(); // Create event handlers for streaming output var eventHandlers = StreamingRenderer.createEventHandlers( print, isVerbose ); @@ -411,7 +414,7 @@ component extends="testboxCLI.models.BaseCommand" { var testsFailed = false; // Override testRunEnd to capture failure state - var originalTestRunEnd = eventHandlers.testRunEnd; + var originalTestRunEnd = eventHandlers.testRunEnd; eventHandlers.testRunEnd = function( data ){ // Check for failures in the full results if ( @@ -442,7 +445,10 @@ component extends="testboxCLI.models.BaseCommand" { } ); } catch ( any e ) { - logger.error( "Error during streaming: #e.message# #e.detail#", e ); + logger.error( + "Error during streaming: #e.message# #e.detail#", + e + ); return error( "Error executing streaming tests: #CR# #e.message##CR##e.detail#" ); } @@ -480,7 +486,10 @@ component extends="testboxCLI.models.BaseCommand" { directoryCreate( thisDir ); } - fileWrite( arguments.outputFile, formatterUtil.formatJSON( serializeJSON( finalResults ) ) ); + fileWrite( + arguments.outputFile, + formatterUtil.formatJSON( serializeJSON( finalResults ) ) + ); print.boldGreenLine( "===> JSON Report written to #arguments.outputFile#!" ); } } diff --git a/models/SSEClient.cfc b/models/SSEClient.cfc index 5a0d2d5..6974da1 100644 --- a/models/SSEClient.cfc +++ b/models/SSEClient.cfc @@ -46,8 +46,8 @@ component singleton { } // Read the stream line by line - var inputStream = connection.getInputStream(); - var reader = createObject( "java", "java.io.BufferedReader" ).init( + var inputStream = connection.getInputStream(); + var reader = createObject( "java", "java.io.BufferedReader" ).init( createObject( "java", "java.io.InputStreamReader" ).init( inputStream, "UTF-8" ) ); @@ -115,11 +115,20 @@ component singleton { // If this is the final event, capture the full results if ( arguments.eventType == "testRunEnd" && structKeyExists( data, "results" ) ) { - structAppend( arguments.finalResults, data.results, true ); + structAppend( + arguments.finalResults, + data.results, + true + ); } // Call the appropriate handler if one exists - if ( structKeyExists( arguments.eventHandlers, arguments.eventType ) ) { + if ( + structKeyExists( + arguments.eventHandlers, + arguments.eventType + ) + ) { var handler = arguments.eventHandlers[ arguments.eventType ]; if ( isClosure( handler ) ) { handler( data ); diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc index a116c4e..3d7d248 100644 --- a/models/StreamingRenderer.cfc +++ b/models/StreamingRenderer.cfc @@ -5,8 +5,8 @@ component singleton { property name="progressBarGeneric" inject="progressBarGeneric"; - property name="shell" inject="shell"; - property name="print" inject="Print"; + property name="shell" inject="shell"; + property name="print" inject="Print"; processingdirective pageEncoding="UTF-8"; @@ -20,9 +20,9 @@ component singleton { // ANSI escape codes for terminal control variables.ANSI = { - "CLEAR_LINE" : chr( 27 ) & "[2K", // Clear entire line - "CARRIAGE_RETURN" : chr( 13 ), // Move cursor to beginning of line - "CURSOR_UP" : chr( 27 ) & "[1A" // Move cursor up one line + "CLEAR_LINE" : chr( 27 ) & "[2K", // Clear entire line + "CARRIAGE_RETURN" : chr( 13 ), // Move cursor to beginning of line + "CURSOR_UP" : chr( 27 ) & "[1A" // Move cursor up one line }; // Track state during streaming @@ -37,8 +37,8 @@ component singleton { "failedSpecs" : 0, "errorSpecs" : 0, "skippedSpecs" : 0, - "hasRunningSpec" : false, // Track if we have a running spec line to overwrite - "runningSpecLine" : "" // Track current running spec line content + "hasRunningSpec" : false, // Track if we have a running spec line to overwrite + "runningSpecLine" : "" // Track current running spec line content }; /** @@ -70,19 +70,22 @@ component singleton { * * @return Struct of event handler closures */ - struct function createEventHandlers( required print, boolean verbose = false ){ + struct function createEventHandlers( + required print, + boolean verbose = false + ){ var renderer = this; var p = arguments.print; var v = arguments.verbose; var sh = variables.shell; - var pr = variables.print; // Print helper for getting colored strings - + var pr = variables.print; // Print helper for getting colored strings + // Get terminal for real-time output - var terminal = javacast( "null", "" ); + var terminal = javacast( "null", "" ); var termWriter = javacast( "null", "" ); try { if ( !isNull( sh ) && !isNull( sh.getReader() ) ) { - terminal = sh.getReader().getTerminal(); + terminal = sh.getReader().getTerminal(); termWriter = terminal.writer(); } } catch ( any e ) { @@ -90,23 +93,31 @@ component singleton { } // ANSI codes for terminal control (needed in closures) - var ANSI_CR = variables.ANSI.CARRIAGE_RETURN; + var ANSI_CR = variables.ANSI.CARRIAGE_RETURN; var ANSI_CLEAR = variables.ANSI.CLEAR_LINE; return { "testRunStart" : function( data ){ renderer.resetState(); variables.state.totalBundles = data.totalBundles ?: 0; - p.line().boldCyanLine( "Starting test run with #variables.state.totalBundles# bundle(s)..." ).toConsole(); + p.line() + .boldCyanLine( "Starting test run with #variables.state.totalBundles# bundle(s)..." ) + .toConsole(); }, "bundleStart" : function( data ){ variables.state.currentBundle = data.name ?: data.path ?: "Unknown Bundle"; variables.state.suiteStack = []; - p.line().boldWhiteLine( "Bundle: #variables.state.currentBundle#" ).toConsole(); + p.line() + .boldWhiteLine( "Bundle: #variables.state.currentBundle#" ) + .toConsole(); }, "bundleEnd" : function( data ){ variables.state.completedBundles++; - var color = renderer.getAggregatedColor( data.totalError ?: 0, data.totalFail ?: 0, 0 ); + var color = renderer.getAggregatedColor( + data.totalError ?: 0, + data.totalFail ?: 0, + 0 + ); p.line( " [Passed: #data.totalPass ?: 0#] [Failed: #data.totalFail ?: 0#] [Errors: #data.totalError ?: 0#] [Skipped: #data.totalSkipped ?: 0#] (#data.totalDuration ?: 0# ms)", color @@ -116,9 +127,16 @@ component singleton { "suiteStart" : function( data ){ variables.state.suiteStack.append( data.name ?: "Unknown Suite" ); variables.state.currentSuite = data.name ?: "Unknown Suite"; - var indent = repeatString( " ", variables.state.suiteStack.len() ); + var indent = repeatString( + " ", + variables.state.suiteStack.len() + ); if ( v ) { - p.line( "#indent##data.name ?: 'Unknown Suite'#", "white" ).toConsole(); + p.line( + "#indent##data.name ?: "Unknown Suite"#", + "white" + ) + .toConsole(); } }, "suiteEnd" : function( data ){ @@ -134,8 +152,11 @@ component singleton { "specStart" : function( data ){ variables.state.totalSpecs++; var name = data.displayName ?: data.name ?: "Unknown Spec"; - var indent = repeatString( " ", variables.state.suiteStack.len() + 1 ); - + var indent = repeatString( + " ", + variables.state.suiteStack.len() + 1 + ); + // Show running spec indicator // Use raw terminal writer for proper ANSI code handling (bypasses AttributedString) if ( !isNull( termWriter ) && !isNull( pr ) ) { @@ -146,18 +167,25 @@ component singleton { if ( !isNull( terminal ) ) { terminal.flush(); } - variables.state.hasRunningSpec = true; + variables.state.hasRunningSpec = true; variables.state.runningSpecLine = runningText; } else { // Fallback to print buffer for testing - p.text( "#indent#» #name#...", variables.COLOR.RUNNING ).toConsole(); + p.text( + "#indent#» #name#...", + variables.COLOR.RUNNING + ) + .toConsole(); variables.state.hasRunningSpec = true; } }, "specEnd" : function( data ){ var status = data.status ?: "unknown"; var name = data.displayName ?: data.name ?: "Unknown Spec"; - var indent = repeatString( " ", variables.state.suiteStack.len() + 1 ); + var indent = repeatString( + " ", + variables.state.suiteStack.len() + 1 + ); // Update counters switch ( status ) { @@ -185,7 +213,7 @@ component singleton { // Fallback for testing p.text( ANSI_CR & ANSI_CLEAR ).toConsole(); } - variables.state.hasRunningSpec = false; + variables.state.hasRunningSpec = false; variables.state.runningSpecLine = ""; } @@ -195,23 +223,36 @@ component singleton { if ( status != "passed" || v ) { var indicator = renderer.getIndicator( status ); var color = renderer.getStatusColor( status ); - p.line( "#indent##indicator##name# (#data.totalDuration ?: 0# ms)", color ).toConsole(); + p.line( + "#indent##indicator##name# (#data.totalDuration ?: 0# ms)", + color + ) + .toConsole(); // Show failure details if ( status == "failed" && len( data.failMessage ?: "" ) ) { - p.line( "#indent# -> Failure: #data.failMessage#", variables.COLOR.FAIL ).toConsole(); + p.line( + "#indent# -> Failure: #data.failMessage#", + variables.COLOR.FAIL + ) + .toConsole(); } // Show error details if ( status == "error" && structKeyExists( data, "error" ) && isStruct( data.error ) ) { - p.line( "#indent# -> Error: #data.error.message ?: 'Unknown error'#", variables.COLOR.ERROR ) + p.line( + "#indent# -> Error: #data.error.message ?: "Unknown error"#", + variables.COLOR.ERROR + ) .toConsole(); } } }, "testRunEnd" : function( data ){ // Final summary is handled by the main renderer using the full results - p.line().boldGreenLine( "Test run complete!" ).toConsole(); + p.line() + .boldGreenLine( "Test run complete!" ) + .toConsole(); } }; } From 667c23e805b5a55fbdf884ec6e424bb7f111becd Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 9 Mar 2026 18:42:03 -0600 Subject: [PATCH 4/7] Fix bundleEnd color to include skipped count Pass data.totalSkipped to getAggregatedColor so bundles with only skipped specs show SKIP color instead of PASS color. --- models/StreamingRenderer.cfc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc index 3d7d248..7cfe28b 100644 --- a/models/StreamingRenderer.cfc +++ b/models/StreamingRenderer.cfc @@ -116,7 +116,7 @@ component singleton { var color = renderer.getAggregatedColor( data.totalError ?: 0, data.totalFail ?: 0, - 0 + data.totalSkipped ?: 0 ); p.line( " [Passed: #data.totalPass ?: 0#] [Failed: #data.totalFail ?: 0#] [Errors: #data.totalError ?: 0#] [Skipped: #data.totalSkipped ?: 0#] (#data.totalDuration ?: 0# ms)", From 779148fef88068016488fdcb01f9c4484a74c0c2 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 9 Mar 2026 18:42:42 -0600 Subject: [PATCH 5/7] Add terminal flush to specEnd for consistent real-time rendering Flush both termWriter and terminal when clearing the running spec line, matching the behavior in specStart. --- models/StreamingRenderer.cfc | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc index 7cfe28b..f40853c 100644 --- a/models/StreamingRenderer.cfc +++ b/models/StreamingRenderer.cfc @@ -209,6 +209,10 @@ component singleton { // Move to beginning of line and clear it termWriter.print( ANSI_CR & ANSI_CLEAR ); termWriter.flush(); + // Also flush the terminal itself for consistent real-time rendering + if ( !isNull( terminal ) ) { + terminal.flush(); + } } else { // Fallback for testing p.text( ANSI_CR & ANSI_CLEAR ).toConsole(); From 0ab7aef54afb357a6a3c3a8251e5cb8cb7ed696a Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 9 Mar 2026 18:43:27 -0600 Subject: [PATCH 6/7] Fix resource cleanup and handle stream ending without trailing blank line - Move reader/inputStream/connection close to finally block for proper cleanup - Add connection.disconnect() for complete resource cleanup - Process any buffered event if stream ends without trailing blank line --- models/SSEClient.cfc | 43 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/models/SSEClient.cfc b/models/SSEClient.cfc index 6974da1..d54f79d 100644 --- a/models/SSEClient.cfc +++ b/models/SSEClient.cfc @@ -21,11 +21,14 @@ component singleton { 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 ); - var connection = netURL.openConnection(); + var netURL = createObject( "java", "java.net.URL" ).init( arguments.url ); + connection = netURL.openConnection(); connection.setRequestProperty( "Accept", "text/event-stream" ); connection.setRequestProperty( @@ -46,8 +49,8 @@ component singleton { } // Read the stream line by line - var inputStream = connection.getInputStream(); - var reader = createObject( "java", "java.io.BufferedReader" ).init( + inputStream = connection.getInputStream(); + reader = createObject( "java", "java.io.BufferedReader" ).init( createObject( "java", "java.io.InputStreamReader" ).init( inputStream, "UTF-8" ) ); @@ -62,6 +65,15 @@ component singleton { // 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; } @@ -84,15 +96,32 @@ component singleton { currentData = ""; } } - - reader.close(); - inputStream.close(); } 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; From 4e78303bea16cb04692c04195ea8eceeaabea0a8 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 9 Mar 2026 18:45:13 -0600 Subject: [PATCH 7/7] Set exit code 1 on streaming connection failures Ensures CI can detect streaming failures (connection errors, etc.) by setting streamingError flag in onError callback and including it in the exit code check. --- commands/testbox/run.cfc | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/commands/testbox/run.cfc b/commands/testbox/run.cfc index cec7863..a3baa50 100644 --- a/commands/testbox/run.cfc +++ b/commands/testbox/run.cfc @@ -411,7 +411,8 @@ component extends="testboxCLI.models.BaseCommand" { var eventHandlers = StreamingRenderer.createEventHandlers( print, isVerbose ); // Track if tests failed for exit code - var testsFailed = false; + var testsFailed = false; + var streamingError = false; // Override testRunEnd to capture failure state var originalTestRunEnd = eventHandlers.testRunEnd; @@ -438,6 +439,8 @@ component extends="testboxCLI.models.BaseCommand" { 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(); @@ -452,8 +455,8 @@ component extends="testboxCLI.models.BaseCommand" { return error( "Error executing streaming tests: #CR# #e.message##CR##e.detail#" ); } - // Set exit code based on results - if ( testsFailed ) { + // Set exit code based on results or streaming errors + if ( testsFailed || streamingError ) { setExitCode( 1 ); }