diff --git a/commands/testbox/run.cfc b/commands/testbox/run.cfc index ef75164..a3baa50 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,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#!" ); + } + } + } diff --git a/models/SSEClient.cfc b/models/SSEClient.cfc new file mode 100644 index 0000000..d54f79d --- /dev/null +++ b/models/SSEClient.cfc @@ -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 ); + } + } + } + +} diff --git a/models/StreamingRenderer.cfc b/models/StreamingRenderer.cfc new file mode 100644 index 0000000..f40853c --- /dev/null +++ b/models/StreamingRenderer.cfc @@ -0,0 +1,315 @@ +/** + * 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"; + property name="shell" inject="shell"; + property name="print" inject="Print"; + + processingdirective pageEncoding="UTF-8"; + + variables.COLOR = { + PASS : "SpringGreen1", + 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" : "", + "currentSuite" : "", + "suiteStack" : [], + "totalBundles" : 0, + "completedBundles" : 0, + "totalSpecs" : 0, + "passedSpecs" : 0, + "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 + }; + + /** + * 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, + "hasRunningSpec" : false, + "runningSpecLine" : "" + }; + 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; + 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 ){ + 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, + 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)", + 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++; + 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"; + 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; + } + + // 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(); + // 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(); + } + 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 ); + 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; + } + } + +} 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; + } + +}