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,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'delegate'
4
+ require 'stringio'
5
+
6
+ module Ruptr
7
+ class CaptureOutput < Delegator
8
+ def initialize(real_io, tls_key)
9
+ @real_io = real_io
10
+ @tls_key = tls_key
11
+ end
12
+
13
+ attr_reader :real_io
14
+
15
+ if Fiber.respond_to?(:[])
16
+ private def tls_obj = Fiber
17
+ else
18
+ private def tls_obj = Thread.current
19
+ end
20
+
21
+ private def __getobj__
22
+ tls_obj[@tls_key] || @real_io
23
+ end
24
+
25
+ def capture
26
+ strio = StringIO.new(+'', 'w')
27
+ saved = tls_obj[@tls_key]
28
+ begin
29
+ tls_obj[@tls_key] = strio
30
+ yield
31
+ strio.string
32
+ ensure
33
+ tls_obj[@tls_key] = saved
34
+ strio.close
35
+ end
36
+ end
37
+
38
+ @mutex = Mutex.new
39
+ @pinned = 0
40
+ @fixed = false
41
+
42
+ class << self
43
+ def installed? = $stdout.is_a?(self) && $stderr.is_a?(self)
44
+
45
+ def install!
46
+ $stdout = new($stdout, :ruptr_stdout)
47
+ $stderr = new($stderr, :ruptr_stderr)
48
+ end
49
+
50
+ def uninstall!
51
+ $stdout = $stdout.real_io
52
+ $stderr = $stderr.real_io
53
+ end
54
+
55
+ def reset!
56
+ uninstall! if @fixed || @pinned.positive?
57
+ @fixed = false
58
+ @pinned = 0
59
+ end
60
+
61
+ def fixed_install!
62
+ return block_given? ? yield : nil if @fixed
63
+ @fixed = true
64
+ install!
65
+ return unless block_given?
66
+ begin
67
+ yield
68
+ ensure
69
+ @fixed = false
70
+ uninstall!
71
+ end
72
+ end
73
+
74
+ def capture_output(&)
75
+ unless @fixed
76
+ if (ractor = defined?(::Ractor) && Ractor.current != Ractor.main)
77
+ install! unless installed?
78
+ else
79
+ @mutex.synchronize do
80
+ install! unless @pinned.positive?
81
+ @pinned += 1
82
+ end
83
+ end
84
+ end
85
+ begin
86
+ stdout = stderr = nil
87
+ stderr = $stderr.capture { stdout = $stdout.capture(&) }
88
+ [stdout, stderr]
89
+ ensure
90
+ unless @fixed || ractor
91
+ @mutex.synchronize do
92
+ @pinned -= 1
93
+ uninstall! unless @pinned.positive?
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ module ForkHandler
101
+ # NOTE: This assumes that the child will exit and the parent unwinds.
102
+ def _fork = super.tap { |pid| CaptureOutput.reset! if pid.nil? || pid.zero? }
103
+ end
104
+ Process.singleton_class.prepend(ForkHandler)
105
+ end
106
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+
5
+ module Ruptr
6
+ class Compat
7
+ def filter_test_group(tg) = tg
8
+
9
+ def adapted_test_suite
10
+ filter_test_group(TestSuite.new.tap { |ts| ts.add_test_subgroup(adapted_test_group) })
11
+ end
12
+
13
+ def global_install! = nil
14
+ def global_uninstall! = nil
15
+ def global_monkey_patch! = nil
16
+ def finalize_configuration! = nil
17
+
18
+ def default_project_load_paths = []
19
+
20
+ def default_project_test_globs = []
21
+
22
+ def each_default_project_test_file(project_path, &)
23
+ return to_enum(__method__, project_path) unless block_given?
24
+ project_path.glob(default_project_test_globs, &)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ module AssertionErrorMixin; end
5
+
6
+ module SkippedExceptionMixin; end
7
+
8
+ module PendingPassedMixin; end
9
+
10
+ module PendingSkippedMixin; end
11
+
12
+ module SaveMessageAsReason
13
+ def initialize(msg = nil)
14
+ super
15
+ @reason = msg&.to_s # preserve nil as-is (unlike #message)
16
+ end
17
+
18
+ attr_reader :reason
19
+
20
+ def reason? = !reason.nil?
21
+ end
22
+
23
+ class AssertionError < StandardError
24
+ include AssertionErrorMixin
25
+ end
26
+
27
+ class SkippedException < Exception
28
+ include SkippedExceptionMixin
29
+ include SaveMessageAsReason
30
+ end
31
+
32
+ class PendingPassedError < StandardError
33
+ include PendingPassedMixin
34
+ end
35
+
36
+ class PendingSkippedException < SkippedException
37
+ include PendingSkippedMixin
38
+ include SaveMessageAsReason
39
+ end
40
+
41
+ class << self
42
+ attr_accessor :passthrough_exceptions
43
+ end
44
+
45
+ PASSTHROUGH_EXCEPTIONS = [NoMemoryError, SignalException, SystemExit].freeze
46
+ self.passthrough_exceptions = PASSTHROUGH_EXCEPTIONS
47
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'suite'
4
+ require_relative 'result'
5
+ require_relative 'sink'
6
+ require_relative 'tty_colors'
7
+
8
+ module Ruptr
9
+ class Formatter
10
+ class << self
11
+ def class_from_env(env = ENV)
12
+ if (s = env['RUPTR_FORMAT'])
13
+ find_formatter(s.to_sym) or fail "unknown formatter: #{s}"
14
+ else
15
+ require_relative 'plain'
16
+ Plain
17
+ end
18
+ end
19
+
20
+ def opts_from_env(klass, env = ENV, **opts)
21
+ if klass.include?(Verbosity)
22
+ if (s = env['RUPTR_VERBOSE'])
23
+ opts[:verbosity] = /\A-?\d+\z/.match?(s) ? s.to_i : 1
24
+ end
25
+ end
26
+ opts
27
+ end
28
+
29
+ def from_env(output, env = ENV, **opts)
30
+ klass = class_from_env(env)
31
+ opts = opts_from_env(klass, env, **opts)
32
+ klass.new(output, **opts)
33
+ end
34
+
35
+ attr_accessor :formatter_name
36
+
37
+ def find_formatter(name)
38
+ traverse = proc do |c|
39
+ return c if c.formatter_name == name
40
+ c.subclasses.each(&traverse)
41
+ end
42
+ traverse.call(self)
43
+ nil
44
+ end
45
+ end
46
+
47
+ include Sink
48
+
49
+ private def each_exception_cause_innermost_first(ex, &)
50
+ return to_enum __method__, ex unless block_given?
51
+ each_exception_cause_innermost_first(ex.cause, &) if ex.cause
52
+ yield ex
53
+ end
54
+
55
+ module Colorizing
56
+ private
57
+
58
+ def initialize(*args, colorizer: TTYColors::Dummy.new, **opts)
59
+ super(*args, **opts)
60
+ @colorizer = colorizer
61
+ end
62
+
63
+ def colorize(s, **opts) = @colorizer.wrap(s, **opts)
64
+ end
65
+
66
+ module Verbosity
67
+ private
68
+
69
+ def initialize(*args, verbosity: 0, **opts)
70
+ super(*args, **opts)
71
+ @verbosity = verbosity
72
+ end
73
+
74
+ def verbose?(n = 1) = @verbosity >= n
75
+ def quiet?(n = 1) = @verbosity <= -n
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'set'
5
+
6
+ module Ruptr
7
+ class GoldenMaster
8
+ GOLDEN_STORE_GOLDEN_FILENAME = 'golden'
9
+ GOLDEN_STORE_TRIAL_FILENAME = 'trial'
10
+ GOLDEN_STORE_TRIAL_PRESERVE_FILENAME = 'trial-preserve'
11
+
12
+ def initialize(state_dir,
13
+ original_test_suite: nil,
14
+ filtered_test_suite: original_test_suite)
15
+ @state_path = Pathname(state_dir)
16
+ @original_test_suite = original_test_suite
17
+ @filtered_test_suite = filtered_test_suite
18
+ end
19
+
20
+ attr_reader :original_test_suite, :filtered_test_suite
21
+
22
+ def test_suite = filtered_test_suite || original_test_suite
23
+
24
+ def golden_store
25
+ @golden_store ||= begin
26
+ (@state_path / "#{GOLDEN_STORE_TRIAL_PRESERVE_FILENAME}.new").open('w') do |io|
27
+ Marshal.dump(
28
+ if !@original_test_suite
29
+ nil # preserve everything
30
+ elsif @original_test_suite.equal?(@filtered_test_suite)
31
+ [] # preserve nothing
32
+ else
33
+ # preserve records for test elements that have been filtered out
34
+ @original_test_suite.each_test_element_recursive.map(&:path_identifiers) -
35
+ @filtered_test_suite.each_test_element_recursive.map(&:path_identifiers)
36
+ end,
37
+ io
38
+ )
39
+ end
40
+ GoldenMaster::Store::FS.new(
41
+ golden_path: @state_path / GOLDEN_STORE_GOLDEN_FILENAME,
42
+ trial_path: @state_path / GOLDEN_STORE_TRIAL_FILENAME,
43
+ ).tap(&:load_golden)
44
+ end
45
+ end
46
+
47
+ def save_trial!
48
+ return unless @golden_store
49
+ (@state_path / "#{GOLDEN_STORE_TRIAL_PRESERVE_FILENAME}.new")
50
+ .rename(@state_path / GOLDEN_STORE_TRIAL_PRESERVE_FILENAME)
51
+ @golden_store.dump_trial
52
+ end
53
+
54
+ def accept_trial!
55
+ fail "no trial data" unless (@state_path / GOLDEN_STORE_TRIAL_FILENAME).exist?
56
+ golden_store.accept_trial(
57
+ preserve: (@state_path / GOLDEN_STORE_TRIAL_PRESERVE_FILENAME).open do |io|
58
+ v = Marshal.load(io)
59
+ if v.nil?
60
+ proc { true }
61
+ else
62
+ s = v.to_set
63
+ proc { |(id, _)| s.include?(id) }
64
+ end
65
+ end
66
+ )
67
+ end
68
+
69
+ class Store
70
+ def initialize
71
+ @golden = {}
72
+ @trial = {}
73
+ end
74
+
75
+ def get_golden(k, &)
76
+ @golden.fetch(k, &)
77
+ end
78
+
79
+ def set_trial(k, v)
80
+ raise ArgumentError, "key already used: #{k.inspect}" if @trial.include?(k)
81
+ @trial.store(k, v)
82
+ end
83
+
84
+ def flush_trial = nil
85
+
86
+ def accept_trial(preserve: nil)
87
+ @golden.each_pair { |k, v| @trial[k] = v if !@trial.include?(k) && preserve.call(k) } if preserve
88
+ @golden = @trial
89
+ @trial = {}
90
+ end
91
+
92
+ class FS < self
93
+ # The same store must be usable from multiple forked process. For each process, trial data is
94
+ # accumulated in @trial and appended to the @trial_tmp_path file before the process exits.
95
+
96
+ def initialize(golden_path:, trial_path:)
97
+ super()
98
+ @golden_path = golden_path
99
+ @golden_tmp_path = Pathname("#{golden_path}.new")
100
+ @trial_path = trial_path
101
+ @trial_tmp_path = Pathname("#{trial_path}.new")
102
+ @trial_tmp_path.truncate(0) if @trial_tmp_path.exist?
103
+ end
104
+
105
+ def load_golden
106
+ # NOTE: The golden file is always a single Marshal chunk.
107
+ @golden = @golden_path.exist? ? @golden_path.open { |io| Marshal.load(io) } : {}
108
+ end
109
+
110
+ def load_trial
111
+ # NOTE: The trial file may be made up of multiple Marshal chunks.
112
+ @trial = {}.tap do |h|
113
+ @trial_path.open { |io| h.merge!(Marshal.load(io)) until io.eof? } if @trial_path.exist?
114
+ end
115
+ end
116
+
117
+ def flush_trial
118
+ return if @trial.empty?
119
+ @trial_tmp_path.open('a') do |io|
120
+ # Ensuring (hopefully) that the Marshal chunk is appended with a single write(2) call.
121
+ io.sync = true
122
+ # XXX Would be better if Marshal errors were caught earlier.
123
+ io.write(Marshal.dump(@trial))
124
+ end
125
+ @trial.clear
126
+ end
127
+
128
+ def dump_trial
129
+ flush_trial
130
+ @trial_tmp_path.rename(@trial_path) if @trial_tmp_path.exist?
131
+ end
132
+
133
+ def accept_trial(preserve: nil)
134
+ load_trial
135
+ super
136
+ @golden = @golden.to_a.sort.to_h # keep golden file identical if possible
137
+ @golden_tmp_path.open('w') { |io| Marshal.dump(@golden, io) }
138
+ @golden_tmp_path.rename(@golden_path)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ruptr
4
+ module TestInstance
5
+ def ruptr_initialize_test_instance(context)
6
+ @ruptr_context = context
7
+ end
8
+
9
+ attr_reader :ruptr_context
10
+
11
+ def ruptr_test_element = ruptr_context.test_element
12
+
13
+ # Common methods to let multiple assertions/expectations libraries use a shared assertions
14
+ # counter. The @_assertions instance variable name was chosen to be compatible with
15
+ # Test::Unit::Assertions::CoreAssertions which accesses it directly.
16
+
17
+ def ruptr_assertions_count
18
+ @_assertions || 0
19
+ end
20
+
21
+ def ruptr_assertions_count=(n)
22
+ @_assertions = n
23
+ end
24
+
25
+ def ruptr_internal_variable?(name)
26
+ name == :@_assertions || name == :@ruptr_context
27
+ end
28
+
29
+ def ruptr_wrap_test_instance
30
+ yield
31
+ ensure
32
+ ruptr_context.assertions_count += ruptr_assertions_count
33
+ end
34
+
35
+ def inspect = "#<#{self.class}: #{ruptr_context.test_element}>"
36
+ end
37
+ end