require "./item" module Spec # Base type for `ExampleGroup`. abstract class Context # All the children, which can be `describe`/`context` or `it` getter children = [] of ExampleGroup | Example protected abstract def cli : CLI def randomize(randomizer) children.each do |child| child.randomize(randomizer) if child.is_a?(ExampleGroup) end children.shuffle!(randomizer) end protected def internal_run run_before_all_hooks children.each &.run run_after_all_hooks end protected def before_each(&block) (@before_each ||= [] of ->) << block end protected def run_before_each_hooks @before_each.try &.each &.call end protected def after_each(&block) (@after_each ||= [] of ->) << block end protected def run_after_each_hooks @after_each.try &.reverse_each &.call end protected def before_all(&block) (@before_all ||= [] of ->) << block end protected def run_before_all_hooks @before_all.try &.each &.call end protected def after_all(&block) (@after_all ||= [] of ->) << block end protected def run_after_all_hooks @after_all.try &.reverse_each &.call end protected def around_each(&block : Example::Procsy ->) (@around_each ||= [] of Example::Procsy ->) << block end protected def run_around_each_hooks(procsy : Example::Procsy) : Bool internal_run_around_each_hooks(procsy) end protected def internal_run_around_each_hooks(procsy : Example::Procsy) : Bool around_each = @around_each return false unless around_each run_around_each_hook(around_each, procsy, 0) true end protected def run_around_each_hook(around_each, procsy, index) : Nil around_each[index].call( if index == around_each.size - 1 # If we don't have any more hooks after this one, call the procsy procsy else # Otherwise, create a procsy that will invoke the next hook Example::Procsy.new(procsy.example) do run_around_each_hook(around_each, procsy, index + 1) end end ) end protected def around_all(&block : ExampleGroup::Procsy ->) (@around_all ||= [] of ExampleGroup::Procsy ->) << block end protected def run_around_all_hooks(procsy : ExampleGroup::Procsy) : Bool around_all = @around_all return false unless around_all run_around_all_hook(around_all, procsy, 0) true end protected def run_around_all_hook(around_all, procsy, index) : Nil around_all[index].call( if index == around_all.size - 1 # If we don't have any more hooks after this one, call the procsy procsy else # Otherwise, create a procsy that will invoke the next hook ExampleGroup::Procsy.new(procsy.example_group) do run_around_all_hook(around_all, procsy, index + 1) end end ) end end # :nodoc: enum Status Success Fail Error Pending def color : Colorize::Color case self in Success then Colorize::ColorANSI::Green in Fail then Colorize::ColorANSI::Red in Error then Colorize::ColorANSI::Red in Pending then Colorize::ColorANSI::Yellow end end def letter : Char case self in Success then '.' in Fail then 'F' in Error then 'E' in Pending then '*' end end end # :nodoc: record Result, kind : Status, description : String, file : String, line : Int32, elapsed : Time::Span?, exception : Exception? # :nodoc: class CLI getter root_context : RootContext { RootContext.new(self) } property current_context : Context { root_context } end # :nodoc: # # The root context is the main interface that the spec DSL interacts with. class RootContext < Context @results : Hash(Status, Array(Result)) protected getter cli : CLI def results_for(status : Status) @results[status] end def initialize(@cli : CLI) @results = Status.values.to_h { |status| {status, [] of Result} } end def run print_order_message(cli.stdout) internal_run end def report(status : Status, full_description, file, line, elapsed = nil, ex = nil) result = Result.new(status, full_description, file, line, elapsed, ex) report_formatters result @results[status] << result end def report_formatters(result) cli.formatters.each(&.report(result)) end def succeeded results_for(:fail).empty? && results_for(:error).empty? end def finish(elapsed_time, aborted = false) cli.formatters.each(&.finish(elapsed_time, aborted)) if cli.formatters.any?(&.should_print_summary?) print_summary(cli.stdout, elapsed_time, aborted) end end def print_summary(io : IO, elapsed_time, aborted = false) pendings = results_for(:pending) unless pendings.empty? io.puts io.puts "Pending:" pendings.each do |pending| io.puts cli.colorize(" #{pending.description}", :pending) end end failures = results_for(:fail) errors = results_for(:error) cwd = Dir.current failures_and_errors = failures + errors unless failures_and_errors.empty? io.puts io.puts "Failures:" failures_and_errors.each_with_index do |fail, i| if ex = fail.exception io.puts io.puts "#{(i + 1).to_s.rjust(3, ' ')}) #{fail.description}" if ex.is_a?(SpecError) source_line = Spec.read_line(ex.file, ex.line) if source_line io.puts cli.colorize(" Failure/Error: #{source_line.strip}", :error) end end io.puts message = ex.is_a?(SpecError) ? ex.to_s : ex.inspect_with_backtrace message.split('\n') do |line| io.print " " io.puts cli.colorize(line, :error) end if ex.is_a?(SpecError) io.puts io.puts cli.colorize(" # #{Path[ex.file].relative_to(cwd)}:#{ex.line}", :comment) end end end end if cli.slowest io.puts results = results_for(:success) + results_for(:fail) top_n = results.sort_by { |res| -res.elapsed.not_nil!.to_f }[0..cli.slowest.not_nil!] top_n_time = top_n.sum &.elapsed.not_nil!.total_seconds percent = (top_n_time * 100) / elapsed_time.total_seconds io.puts "Top #{cli.slowest} slowest examples (#{top_n_time.humanize} seconds, #{percent.round(2)}% of total time):" top_n.each do |res| io.puts " #{res.description}" res_elapsed = res.elapsed.not_nil!.total_seconds.humanize io.puts " #{res_elapsed.colorize.bold.toggle(cli.color?)} seconds #{Path[res.file].relative_to(cwd)}:#{res.line}" end end io.puts success = results_for(:success) total = pendings.size + failures.size + errors.size + success.size final_status = case when aborted then Status::Error when (failures.size + errors.size) > 0 then Status::Fail when pendings.size > 0 then Status::Pending else Status::Success end io.puts "Aborted!".colorize.red.toggle(cli.color?) if aborted io.puts "Finished in #{Spec.to_human(elapsed_time)}" io.puts cli.colorize("#{total} examples, #{failures.size} failures, #{errors.size} errors, #{pendings.size} pending", final_status) io.puts cli.colorize("Only running `focus: true`", :focus) if cli.focus? unless failures_and_errors.empty? io.puts io.puts "Failed examples:" io.puts failures_and_errors.each do |fail| io.print cli.colorize("crystal spec #{Path[fail.file].relative_to(cwd)}:#{fail.line}", :error) io.puts cli.colorize(" # #{fail.description}", :comment) end end print_order_message(io) end def print_order_message(io : IO) if randomizer_seed = cli.randomizer_seed io.puts cli.colorize("Randomized with seed: #{randomizer_seed}", :order) end end def describe(description, file, line, end_line, focus, tags, &block) cli.focus = true if focus context = Spec::ExampleGroup.new(cli.current_context, description, file, line, end_line, focus, tags) cli.current_context.children << context old_context = cli.current_context cli.current_context = context begin block.call ensure cli.current_context = old_context end end def it(description, file, line, end_line, focus, tags, &block) add_example(description, file, line, end_line, focus, tags, block) end def pending(description, file, line, end_line, focus, tags) add_example(description, file, line, end_line, focus, tags, nil) end private def add_example(description, file, line, end_line, focus, tags, block) check_nesting_spec(file, line) do cli.focus = true if focus cli.current_context.children << Example.new(cli.current_context, description, file, line, end_line, focus, tags, block) end end @spec_nesting = false def check_nesting_spec(file, line, &block) raise NestingSpecError.new("Can't nest `it` or `pending`", file, line) if @spec_nesting @spec_nesting = true begin yield ensure @spec_nesting = false end end protected def around_all(&block : ExampleGroup::Procsy ->) raise "Can't call `around_all` outside of a describe/context" end end # Represents a `describe` or `context`. class ExampleGroup < Context include Item def initialize(@parent : Context, @description : String, @file : String, @line : Int32, @end_line : Int32, @focus : Bool, tags) initialize_tags(tags) end # :nodoc: def cli : CLI @parent.cli end # :nodoc: def run cli.formatters.each(&.push(self)) ran = run_around_all_hooks(ExampleGroup::Procsy.new(self) { internal_run }) ran || internal_run cli.formatters.each(&.pop) end protected def report(status : Status, description, file, line, elapsed = nil, ex = nil) parent.report status, "#{@description} #{description}", file, line, elapsed, ex end protected def run_before_each_hooks @parent.run_before_each_hooks super end protected def run_after_each_hooks super @parent.run_after_each_hooks end protected def run_around_each_hooks(procsy : Example::Procsy) : Bool ran = @parent.run_around_each_hooks(Example::Procsy.new(procsy.example) do if @around_each # If we have around callbacks we execute them, and it will # eventually run the example internal_run_around_each_hooks(procsy) else # Otherwise we have to run the example now, because the parent # around hooks won't run it procsy.run end end) ran || internal_run_around_each_hooks(procsy) end end end require "./example_group/procsy"