ruptr 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pp'
4
+
5
+ module Ruptr
6
+ # Helper to get a string representation of an arbitrary object and write it to an IO stream while
7
+ # attempting to deal with weird/buggy objects as well as possible.
8
+ class Stringified
9
+ def self.stringification_methods = %i[pretty_inspect inspect to_s]
10
+
11
+ def self.from(value) = value.is_a?(self) ? value : new(value)
12
+
13
+ def initialize(value)
14
+ # String.new is used to get an immutable snapshot of the strings and also to make sure that
15
+ # they are not derived classes or have instance variables that could make them unserializable.
16
+ @original_class_name = String.new(value.class.to_s)
17
+ if (@originally_a_string = value.class <= String)
18
+ @stringified = String.new(value)
19
+ else
20
+ if (name = self.class.stringification_methods.find { |name| value.respond_to?(name) })
21
+ # NOTE: String.new may call #to_s on the method's return value if needed.
22
+ @stringified = String.new(value.public_send(name))
23
+ @stringification_method = name
24
+ end
25
+ end
26
+ end
27
+
28
+ attr_reader :original_class_name,
29
+ :stringified,
30
+ :stringification_method
31
+
32
+ def originally_a_string? = @originally_a_string
33
+ def stringified? = !@stringified.nil?
34
+
35
+ def compatible_with_io?(io)
36
+ return nil unless stringified?
37
+ @stringified.valid_encoding? &&
38
+ Encoding.compatible?(@stringified, io.external_encoding) == io.external_encoding
39
+ end
40
+
41
+ private def unstringifiable_fallback = "#<#{self.class}: unstringifiable object of class #{@original_class_name}>"
42
+
43
+ def string_for_io(io)
44
+ case
45
+ when !stringified? then unstringifiable_fallback
46
+ when compatible_with_io?(io) then @stringified
47
+ else @stringified.b.inspect
48
+ end
49
+ end
50
+
51
+ def write_to_io(io) = io.write(string_for_io(io))
52
+
53
+ def string = stringified? ? @stringified : unstringifiable_fallback
54
+ alias to_s string
55
+ alias to_str string
56
+ end
57
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ class TestElement
5
+ DEFAULT_TAGS = {}.freeze
6
+
7
+ def initialize(label = nil, identifier: label, tags: DEFAULT_TAGS, &block)
8
+ @parent = nil
9
+ @label = label
10
+ @identifier = identifier
11
+ @tags = tags
12
+ @block = block
13
+ end
14
+
15
+ attr_accessor :label, :identifier, :tags, :block
16
+
17
+ attr_reader :parent
18
+
19
+ def orphan? = @parent.nil?
20
+
21
+ def reparent!(new_parent)
22
+ fail "already has a parent" if new_parent && @parent
23
+ @parent = new_parent
24
+ @description = nil
25
+ end
26
+
27
+ def orphanize! = reparent!(nil)
28
+
29
+ def description
30
+ return label.nil? ? '' : label if orphan?
31
+ @description ||= if label.nil?
32
+ parent.description
33
+ elsif parent.description.empty?
34
+ label
35
+ else
36
+ "#{parent.description} #{label}"
37
+ end
38
+ end
39
+
40
+ def each_parent_and_self(&)
41
+ return to_enum __method__ unless block_given?
42
+ parent.each_parent_and_self(&) unless orphan?
43
+ yield self
44
+ end
45
+
46
+ def path_labels = each_parent_and_self.map(&:label)
47
+ def path_identifiers = each_parent_and_self.map(&:identifier)
48
+
49
+ def test_case? = false
50
+ def test_group? = false
51
+
52
+ def block? = !@block.nil?
53
+
54
+ def initialize_dup(_)
55
+ super
56
+ orphanize!
57
+ end
58
+
59
+ def to_s = "#<#{self.class}: #{description.inspect}>"
60
+ end
61
+
62
+ class TestCase < TestElement
63
+ def test_case? = true
64
+
65
+ def run_context(context) = @block.call(context)
66
+ end
67
+
68
+ class TestGroup < TestElement
69
+ def initialize(...)
70
+ super
71
+ @test_cases = []
72
+ @test_subgroups = []
73
+ end
74
+
75
+ def test_group? = true
76
+
77
+ def wrap_context(context, &) = @block.call(context, &)
78
+
79
+ def each_test_case(&) = @test_cases.each(&)
80
+ def each_test_subgroup(&) = @test_subgroups.each(&)
81
+
82
+ def each_test_element(&)
83
+ return to_enum __method__ unless block_given?
84
+ each_test_case(&)
85
+ each_test_subgroup(&)
86
+ end
87
+
88
+ def each_test_element_recursive(&)
89
+ return to_enum __method__ unless block_given?
90
+ yield self
91
+ each_test_case(&)
92
+ each_test_subgroup { |tg| tg.each_test_element_recursive(&) }
93
+ end
94
+
95
+ def each_test_case_recursive(&)
96
+ return to_enum __method__ unless block_given?
97
+ each_test_case(&)
98
+ each_test_subgroup { |tg| tg.each_test_case_recursive(&) }
99
+ end
100
+
101
+ def each_test_group_recursive(&)
102
+ return to_enum __method__ unless block_given?
103
+ yield self
104
+ each_test_subgroup do |tg|
105
+ yield tg
106
+ tg.each_test_subgroup_recursive(&)
107
+ end
108
+ end
109
+
110
+ def each_test_subgroup_recursive(&)
111
+ return to_enum __method__ unless block_given?
112
+ each_test_subgroup do |tg|
113
+ yield tg
114
+ tg.each_test_subgroup_recursive(&)
115
+ end
116
+ end
117
+
118
+ def count_test_elements(&) = each_test_element_recursive.count(&)
119
+ def count_test_cases(&) = each_test_case_recursive.count(&)
120
+ def count_test_groups(&) = each_test_group_recursive.count(&)
121
+ def count_test_subgroups(&) = each_test_subgroup_recursive.count(&)
122
+
123
+ def empty? = @test_cases.empty? && @test_subgroups.empty?
124
+
125
+ def clear_test_cases
126
+ @test_cases.each(&:orphanize!)
127
+ @test_cases.clear
128
+ end
129
+
130
+ def clear_test_subgroups
131
+ @test_subgroups.each(&:orphanize!)
132
+ @test_subgroups.clear
133
+ end
134
+
135
+ def add_test_case(tc)
136
+ tc.reparent!(self)
137
+ @test_cases << tc
138
+ end
139
+
140
+ def add_test_subgroup(tg)
141
+ tg.reparent!(self)
142
+ @test_subgroups << tg
143
+ end
144
+
145
+ def add_test_element(te)
146
+ case
147
+ when te.test_case? then add_test_case(te)
148
+ when te.test_group? then add_test_subgroup(te)
149
+ else raise ArgumentError
150
+ end
151
+ end
152
+
153
+ def filter_recursive!(&)
154
+ @test_cases.filter!(&)
155
+ @test_subgroups.filter! do |tg|
156
+ if yield tg
157
+ tg.filter_recursive!(&)
158
+ true
159
+ end
160
+ end
161
+ end
162
+
163
+ def filter_recursive(&)
164
+ dup.tap { |tg| tg.filter_recursive!(&) }
165
+ end
166
+
167
+ def filter_test_cases_recursive(&)
168
+ filter_recursive { |te| !te.test_case? || yield(te) }
169
+ end
170
+
171
+ def initialize_dup(_)
172
+ super
173
+ @test_cases = @test_cases.map { |tc| tc.dup.tap { |tc| tc.reparent!(self) } }
174
+ @test_subgroups = @test_subgroups.map { |tc| tc.dup.tap { |tc| tc.reparent!(self) } }
175
+ end
176
+
177
+ def freeze
178
+ @test_cases.each(&:freeze)
179
+ @test_subgroups.each(&:freeze)
180
+ @test_cases.freeze
181
+ @test_subgroups.freeze
182
+ super
183
+ end
184
+ end
185
+
186
+ class TestSuite < TestGroup
187
+ end
188
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ # Exception that copy some of the information of another original exception in a way that
5
+ # (hopefully) can be safely marshalled.
6
+ class SurrogateException < Exception
7
+ def self.detailed_message_supported? = Exception.method_defined?(:detailed_message)
8
+
9
+ def self.from_1(original_exception)
10
+ new(original_exception.message,
11
+ **if detailed_message_supported?
12
+ {
13
+ detailed_message: original_exception.detailed_message(highlight: false),
14
+ highlighted_detailed_message: original_exception.detailed_message(highlight: true),
15
+ }
16
+ else
17
+ {}
18
+ end,
19
+ original_class_name: original_exception.class.name,
20
+ backtrace: original_exception.backtrace)
21
+ end
22
+
23
+ def self.from(original_exception)
24
+ return from_1(original_exception) unless original_exception.cause
25
+ # Recreate the cause exception chain by raising the surrogate exceptions. Simply redefining
26
+ # the #cause method does not work in CRuby (apparently the cause is stored in a special
27
+ # instance variable that is then accessed directly).
28
+ rec = lambda do |ex|
29
+ if ex.cause
30
+ if ex.cause.cause
31
+ begin
32
+ rec.call(ex.cause)
33
+ rescue self
34
+ raise from_1(ex)
35
+ end
36
+ else
37
+ raise from_1(ex), cause: from_1(ex.cause)
38
+ end
39
+ else
40
+ raise from_1(ex), cause: nil # don't use implicit $!, if any
41
+ end
42
+ end
43
+ begin
44
+ rec.call(original_exception)
45
+ rescue self
46
+ $!
47
+ end
48
+ end
49
+
50
+ private def safe_string(v) = v.nil? ? v : String.new(v)
51
+
52
+ def initialize(message = nil,
53
+ detailed_message: nil, highlighted_detailed_message: nil,
54
+ original_class_name: nil,
55
+ backtrace: nil)
56
+ super(safe_string(message))
57
+ @detailed_message = safe_string(detailed_message)
58
+ @highlighted_detailed_message = safe_string(highlighted_detailed_message)
59
+ set_backtrace(backtrace)
60
+ @original_class_name = safe_string(original_class_name)
61
+ end
62
+
63
+ attr_reader :original_class_name
64
+
65
+ if detailed_message_supported?
66
+ def detailed_message(highlight: false, **_)
67
+ (highlight ? @highlighted_detailed_message || @detailed_message : @detailed_message) || super
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'formatter'
4
+ require_relative 'result'
5
+
6
+ module Ruptr
7
+ class Formatter::Tabular < Formatter
8
+ self.formatter_name = :tabular
9
+
10
+ private
11
+
12
+ def initialize(output, **opts)
13
+ super(**opts)
14
+ @output = output
15
+ end
16
+
17
+ def finish_element(te, tr)
18
+ @output.printf("%-7s %12.6f %s\n", tr.status, tr.processor_time || Float::NAN, te.description)
19
+ end
20
+ end
21
+ end
data/lib/ruptr/tap.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'sink'
4
+ require_relative 'formatter'
5
+ require_relative 'exceptions'
6
+
7
+ module Ruptr
8
+ class Formatter::TAP < Formatter
9
+ self.formatter_name = :tap
10
+
11
+ include Sink
12
+
13
+ def initialize(output, **opts)
14
+ super(**opts)
15
+ @output = output
16
+ end
17
+
18
+ def begin_plan(fields)
19
+ @total_tests = 0
20
+ if (n = fields[:planned_test_case_count])
21
+ @output << "1..#{n}\n"
22
+ @emitted_plan = true
23
+ else
24
+ @emitted_plan = false
25
+ end
26
+ end
27
+
28
+ def finish_plan(_fields)
29
+ return if @emitted_plan
30
+ @output << "1..#{@total_tests}\n"
31
+ end
32
+
33
+ private def escape(s) = s.gsub(/[\\#]/) { |m| "\\#{m}" }
34
+
35
+ def finish_element(te, tr)
36
+ pending = tr.skipped? && tr.exception.is_a?(PendingSkippedMixin)
37
+ @output << (tr.passed? || (tr.skipped? && !pending) ? "ok" : "not ok")
38
+ @output << ' ' << @total_tests.succ.to_s
39
+ @output << ' - ' << escape(te.description) if te.description
40
+ if tr.skipped?
41
+ @output << ' # ' << (pending ? "TODO" : "SKIP")
42
+ @output << ' ' << escape(tr.exception.message) if tr.exception&.message
43
+ end
44
+ @output << "\n"
45
+ if tr.failed? && tr.exception
46
+ tr.exception.full_message(highlight: false).each_line { |s| @output << '# ' << s }
47
+ end
48
+ @total_tests += 1
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../testunit'
4
+ Ruptr::Compat::TestUnit.new.prepare_autorun!
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+ require_relative 'compat'
5
+ require_relative 'autorun'
6
+ require_relative 'adapters/assertions'
7
+
8
+ module Ruptr
9
+ class Compat
10
+ class TestUnit < self
11
+ def default_project_load_paths = %w[test]
12
+
13
+ def default_project_test_globs = %w[test/**/*[-_]test.rb test/**/test[-_]*.rb]
14
+
15
+ def global_install!
16
+ m = if Object.const_defined?(:Test)
17
+ Object.const_get(:Test)
18
+ else
19
+ Object.const_set(:Test, Module.new)
20
+ end
21
+ if m.const_defined?(:Unit)
22
+ return if m.const_get(:Unit) == @adapter_module
23
+ fail "test/unit already loaded!"
24
+ end
25
+ ::Test.const_set(:Unit, adapter_module)
26
+ this = self
27
+ m = Module.new do
28
+ define_method(:require) do |name|
29
+ name = name.to_path unless name.is_a?(String)
30
+ case name
31
+ when 'test/unit'
32
+ return
33
+ when 'test/unit/autorun'
34
+ this.schedule_autorun!
35
+ return
36
+ when 'core_assertions'
37
+ # Test::Unit::CoreAssertions#assert_separately spawns an interpreter with gems
38
+ # disabled and include path arguments based on the current $LOAD_PATH.
39
+ Gem.try_activate('test/unit')
40
+ else
41
+ fail "#{self.class.name}: unknown test/unit library: #{name}" if name.start_with?('test/unit/')
42
+ end
43
+ super(name)
44
+ end
45
+ end
46
+ Kernel.prepend(m)
47
+ end
48
+
49
+ module DefBlockHelpers
50
+ def def_module(name, &) = const_set(name, Module.new(&))
51
+ def def_class(name, &) = const_set(name, Class.new(&))
52
+ end
53
+
54
+ def adapter_module
55
+ @adapter_module ||= Module.new do
56
+ extend(DefBlockHelpers)
57
+
58
+ const_set :PendedError, Assertions::SkippedException
59
+ const_set :AssertionFailedError, Assertions::AssertionError
60
+
61
+ def_module(:Util) do
62
+ extend(DefBlockHelpers)
63
+ def_module(:Output) do
64
+ def capture_output(&) = Assertions.capture_io(&)
65
+ end
66
+ end
67
+
68
+ assertions_module = def_module(:Assertions) do
69
+ include(Adapters::RuptrAssertions)
70
+ end
71
+
72
+ def_class(:TestCase) do
73
+ include(TestInstance)
74
+ include(assertions_module)
75
+
76
+ attr_accessor :method_name
77
+
78
+ def setup = nil
79
+ def teardown = nil
80
+ end
81
+ end
82
+ end
83
+
84
+ private def make_run_block(klass, method_name)
85
+ lambda do |context|
86
+ inst = klass.new
87
+ inst.method_name = method_name
88
+ inst.ruptr_initialize_test_instance(context)
89
+ inst.ruptr_wrap_test_instance do
90
+ inst.setup
91
+ inst.public_send(method_name)
92
+ ensure
93
+ inst.teardown
94
+ end
95
+ end
96
+ end
97
+
98
+ def adapted_test_group
99
+ traverse = lambda do |klass|
100
+ root = klass.equal?(adapter_module::TestCase)
101
+ TestGroup.new(root ? "[TestUnit]" : klass.name,
102
+ identifier: root ? :testunit : klass.name).tap do |tg|
103
+ klass.public_instance_methods(true)
104
+ .filter { |sym| sym.start_with?('test_') }.each do |test_method_name|
105
+ tc = TestCase.new(test_method_name.to_s, &make_run_block(klass, test_method_name))
106
+ tg.add_test_case(tc)
107
+ end
108
+ klass.subclasses.each do |subklass|
109
+ tg.add_test_subgroup(traverse.call(subklass))
110
+ end
111
+ end
112
+ end
113
+ traverse.call(adapter_module::TestCase)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib' # for crc32
4
+ require 'pathname'
5
+
6
+ require_relative 'sink'
7
+
8
+ module Ruptr
9
+ class TimingCache
10
+ TIMING_CACHE_FILENAME = 'timing'
11
+
12
+ def initialize(state_dir, test_suite)
13
+ @state_path = Pathname(state_dir)
14
+ @test_suite = test_suite
15
+ end
16
+
17
+ attr_reader :test_suite
18
+
19
+ def timing_store
20
+ @timing_store ||= begin
21
+ @cache_path = @state_path / TIMING_CACHE_FILENAME
22
+ if @cache_path.exist?
23
+ TimingCache::Store.load(@test_suite, @cache_path)
24
+ else
25
+ TimingCache::Store.new
26
+ end
27
+ end
28
+ end
29
+
30
+ def save!(replace: true)
31
+ return unless @timing_store
32
+ @timing_store.dump(@test_suite, @cache_path, replace:)
33
+ end
34
+
35
+ class Store
36
+ def self.load(...) = new.tap { |o| o.load(...) }
37
+
38
+ def initialize
39
+ @current = {}
40
+ end
41
+
42
+ private def te_hash(te) = Zlib.crc32(te.description)
43
+
44
+ private def load_hashed(path)
45
+ s = Pathname(path).binread
46
+ n = s.length / 8
47
+ a = s.unpack("V#{n}e#{n}")
48
+ h = {}
49
+ n.times { |i| h[a[i]] = a[i + n] }
50
+ h
51
+ end
52
+
53
+ def load(ts, path)
54
+ h = load_hashed(path)
55
+
56
+ traverse = lambda do |tg|
57
+ total_time = 0
58
+ tg.each_test_case do |tc|
59
+ time = h[te_hash(tc)] or next
60
+ @current[tc] = time
61
+ total_time += time
62
+ end
63
+ tg.each_test_subgroup do |tg|
64
+ total_time += traverse.call(tg)
65
+ end
66
+ @current[tg] = total_time
67
+ end
68
+ traverse.call(ts)
69
+ end
70
+
71
+ private def dump_hashed(path, h)
72
+ n = h.size
73
+ s = (h.keys + h.values).pack("V#{n}e#{n}")
74
+ Pathname(path).binwrite(s)
75
+ end
76
+
77
+ def dump(_ts, path, replace: true)
78
+ h = replace ? {} : load_hashed(path)
79
+ @updated.each_pair do |te, time|
80
+ k = te_hash(te)
81
+ h[k] = [h[k] || 0, time].max
82
+ end
83
+ dump_hashed(path, h)
84
+ end
85
+
86
+ def [](te) = @current[te] || Float::INFINITY
87
+
88
+ include Sink
89
+
90
+ def begin_plan(_)
91
+ @updated = {}
92
+ end
93
+
94
+ def finish_case(tc, tr)
95
+ time = tr.processor_time
96
+ @updated[tc] = time if time
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ class TTYColors
5
+ def self.probably_ansi_terminal?(io) = io.tty? && !['dumb', 'unknown', ''].include?(ENV.fetch('TERM', ''))
6
+ def self.want_cli_colors? = ENV.include?('CLICOLOR') || ENV.include?('LS_COLORS')
7
+ def self.for(io) = (want_cli_colors? && probably_ansi_terminal?(io) ? ANSICodes : Dummy).new
8
+
9
+ def self.seems_to_contain_formatting_codes?(s) = s.match?(/[\e\b]/)
10
+
11
+ class Dummy < self
12
+ def supports?(*args) = args.empty?
13
+ def wrap(s, **_opts) = s.to_s
14
+ end
15
+
16
+ class Overstrike < self
17
+ SUPPORTED = %i[bright underline].freeze
18
+
19
+ def supports?(*args) = args.all? { |name| SUPPORTED.include?(name) }
20
+
21
+ def wrap(s, bright: nil, underline: nil, **_opts)
22
+ s.to_s.chars.map { |c| %(#{c}#{bright ? "\b#{c}" : ''}#{underline ? "\b_" : ''}) }.join
23
+ end
24
+ end
25
+
26
+ class ANSICodes < self
27
+ SUPPORTED = %i[bright faint italic underline reverse strike overstrike].freeze
28
+ COLORS = %i[black red green yellow blue magenta cyan white].freeze
29
+
30
+ def supports?(*args)
31
+ args.all? do |name|
32
+ case name
33
+ when :color, :bg_color then COLORS.include?(name)
34
+ else SUPPORTED.include?(name)
35
+ end
36
+ end
37
+ end
38
+
39
+ def wrap(s, **opts)
40
+ b = +''; e = +''
41
+ if opts[:bright] then b << "\e[1m"; e << "\e[22m" end
42
+ if opts[:faint] then b << "\e[2m"; e << "\e[22m" end
43
+ if opts[:italic] then b << "\e[3m"; e << "\e[23m" end
44
+ if opts[:underline] then b << "\e[4m"; e << "\e[24m" end
45
+ if opts[:reverse] then b << "\e[7m"; e << "\e[27m" end
46
+ if opts[:strike] then b << "\e[9m"; e << "\e[29m" end
47
+ if opts[:overstrike] then b << "\e[53m"; e << "\e[55m" end
48
+ if (color_index = COLORS.find_index(opts[:color]))
49
+ b << "\e[#{30 + color_index}m"
50
+ e << "\e[39m"
51
+ end
52
+ if (color_index = COLORS.find_index(opts[:bg_color]))
53
+ b << "\e[#{40 + color_index}m"
54
+ e << "\e[49m"
55
+ end
56
+ "#{b}#{s}#{e}"
57
+ end
58
+ end
59
+ end
60
+ end