@@ -27,7 +27,8 @@ class ContinuousIntegration
2727 title : "\033 [1;35m" , # Purple
2828 subtitle : "\033 [1;90m" , # Medium Gray
2929 error : "\033 [1;31m" , # Red
30- success : "\033 [1;32m" # Green
30+ success : "\033 [1;32m" , # Green
31+ progress : "\033 [1;36m" # Cyan
3132 }
3233
3334 attr_reader :results
@@ -55,12 +56,15 @@ class ContinuousIntegration
5556 # end
5657 # end
5758 def self . run ( title = "Continuous Integration" , subtitle = "Running tests, style checks, and security audits" , &block )
58- new . tap do |ci |
59- ENV [ "CI" ] = "true"
60- ci . heading title , subtitle , padding : false
61- ci . report ( title , &block )
62- abort unless ci . success?
63- end
59+ ENV [ "CI" ] = "true"
60+ new . run ( title , subtitle , &block )
61+ end
62+
63+ def run ( title , subtitle , &block )
64+ heading title , subtitle , padding : false
65+ success , seconds = execute ( title , &block )
66+ result_line ( title , success , seconds )
67+ abort unless success?
6468 end
6569
6670 def initialize
@@ -75,8 +79,41 @@ def initialize
7579 # step "Setup", "bin/setup"
7680 # step "Single test", "bin/rails", "test", "--name", "test_that_is_one"
7781 def step ( title , *command )
78- heading title , command . join ( " " ) , type : :title
79- report ( title ) { results << [ system ( *command ) , title ] }
82+ Signal . trap ( "INT" ) { abort colorize ( "\n ❌ #{ title } interrupted" , :error ) }
83+ report_step ( title , command ) do
84+ started = Time . now . to_f
85+ [ system ( *command ) , Time . now . to_f - started ]
86+ end
87+ abort if failing_fast?
88+ end
89+
90+ # Declare a group of steps that can be run in parallel. Steps within the group are collected first,
91+ # then executed either concurrently (when +parallel+ > 1) or sequentially (when +parallel+ is 1).
92+ #
93+ # When running in parallel, each step's output is captured to avoid interleaving, and a progress
94+ # display shows which steps are currently running.
95+ #
96+ # Sub-groups within a parallel group occupy a single parallel slot and run their steps sequentially.
97+ #
98+ # Examples:
99+ #
100+ # group "Checks", parallel: 3 do
101+ # step "Style: Ruby", "bin/rubocop"
102+ # step "Security: Brakeman", "bin/brakeman --quiet"
103+ # step "Security: Gem audit", "bin/bundler-audit"
104+ # end
105+ #
106+ # group "Tests" do
107+ # step "Unit tests", "bin/rails test"
108+ # step "System tests", "bin/rails test:system"
109+ # end
110+ def group ( name , parallel : 1 , &block )
111+ if parallel <= 1
112+ instance_eval ( &block )
113+ else
114+ Group . new ( self , name , parallel : parallel , &block ) . run
115+ end
116+ abort if failing_fast?
80117 end
81118
82119 # Returns true if all steps were successful.
@@ -95,8 +132,6 @@ def failure(title, subtitle = nil)
95132 #
96133 # heading "Smoke Testing", "End-to-end tests verifying key functionality", padding: false
97134 # heading "Skipping video encoding tests", "Install FFmpeg to run these tests", type: :error
98- #
99- # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
100135 def heading ( heading , subtitle = nil , type : :banner , padding : true )
101136 echo "#{ padding ? "\n \n " : "" } #{ heading } " , type : type
102137 echo "#{ subtitle } #{ padding ? "\n " : "" } " , type : :subtitle if subtitle
@@ -108,65 +143,83 @@ def heading(heading, subtitle = nil, type: :banner, padding: true)
108143 #
109144 # echo "This is going to be green!", type: :success
110145 # echo "This is going to be red!", type: :error
111- #
112- # See ActiveSupport::ContinuousIntegration::COLORS for a complete list of options.
113146 def echo ( text , type :)
114147 puts colorize ( text , type )
115148 end
116149
117150 # :nodoc:
118- def report ( title , &block )
119- Signal . trap ( "INT" ) { abort colorize ( "\n ❌ #{ title } interrupted" , :error ) }
120-
121- ci = self . class . new
122- elapsed = timing { ci . instance_eval ( &block ) }
123-
124- if ci . success?
125- echo "\n ✅ #{ title } passed in #{ elapsed } " , type : :success
126- else
127- echo "\n ❌ #{ title } failed in #{ elapsed } " , type : :error
128-
129- abort if ci . fail_fast?
130-
131- if ci . multiple_results?
132- ci . failures . each do |success , title |
133- unless success
134- echo " ↳ #{ title } failed" , type : :error
135- end
136- end
137- end
138- end
139-
140- results . concat ci . results
141- ensure
142- Signal . trap ( "INT" , "-" )
151+ def report_step ( title , command )
152+ heading title , command . join ( " " ) , type : :title
153+ success , seconds = yield
154+ result_line ( title , success , seconds )
155+ results << [ success , title ]
156+ success
143157 end
144158
145159 # :nodoc:
146- def failures
147- results . reject ( & :first )
160+ def colorize ( text , type )
161+ " #{ COLORS . fetch ( type ) } #{ text } \033 [0m"
148162 end
149163
150164 # :nodoc:
151- def multiple_results ?
152- results . size > 1
165+ def fail_fast ?
166+ ARGV . include? ( "-f" ) || ARGV . include? ( "--fail-fast" )
153167 end
154168
155169 # :nodoc:
156- def fail_fast ?
157- ARGV . include? ( "-f" ) || ARGV . include? ( "--fail-fast" )
170+ def failing_fast ?
171+ fail_fast? && failures . any?
158172 end
159173
160174 private
175+ def failures
176+ results . reject ( &:first )
177+ end
178+
179+ def multiple_results?
180+ results . size > 1
181+ end
182+
183+ def execute ( title , &block )
184+ Signal . trap ( "INT" ) { abort colorize ( "\n ❌ #{ title } interrupted" , :error ) }
185+
186+ seconds = timing { instance_eval ( &block ) }
187+
188+ unless success?
189+ if multiple_results?
190+ failures . each do |success , title |
191+ unless success
192+ echo " ↳ #{ title } failed" , type : :error
193+ end
194+ end
195+ end
196+ end
197+
198+ [ success? , seconds ]
199+ ensure
200+ Signal . trap ( "INT" , "-" )
201+ end
202+
203+ def result_line ( title , success , seconds )
204+ elapsed = format_elapsed ( seconds )
205+ if success
206+ echo "\n ✅ #{ title } passed in #{ elapsed } " , type : :success
207+ else
208+ echo "\n ❌ #{ title } failed in #{ elapsed } " , type : :error
209+ end
210+ end
211+
212+ def format_elapsed ( seconds )
213+ min , sec = seconds . divmod ( 60 )
214+ "#{ "#{ min . to_i } m" if min > 0 } %.2fs" % sec
215+ end
216+
161217 def timing
162218 started_at = Time . now . to_f
163219 yield
164- min , sec = ( Time . now . to_f - started_at ) . divmod ( 60 )
165- "#{ "#{ min } m" if min > 0 } %.2fs" % sec
166- end
167-
168- def colorize ( text , type )
169- "#{ COLORS . fetch ( type ) } #{ text } \033 [0m"
220+ Time . now . to_f - started_at
170221 end
171222 end
172223end
224+
225+ require_relative "continuous_integration/group"
0 commit comments