verity 0.1.0

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,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: Verbose reporter that prints one indented line per test, nesting
6
+ # output under group headers. Uses ANSI colors (green/red/yellow/magenta)
7
+ # when outputting to a TTY, unless suppressed by NO_COLOR or forced via
8
+ # FORCE_COLOR / VERITY_FORCE_COLOR.
9
+ class DocumentationReporter
10
+ include Verity::Reporter
11
+
12
+ ESC = "\e["
13
+ RESET = "#{ESC}0m"
14
+ PASS_STYLE = "#{ESC}32m"
15
+ FAIL_STYLE = "#{ESC}31m"
16
+ SKIP_STYLE = "#{ESC}33m"
17
+ ERROR_STYLE = "#{ESC}35m"
18
+
19
+ # Public: Create a new DocumentationReporter.
20
+ #
21
+ # io - IO object for output (default $stdout).
22
+ # color - Boolean to force color on/off, or nil for auto-detect.
23
+ def initialize(io = $stdout, color: nil)
24
+ @io = io
25
+ @color_override = color
26
+ end
27
+
28
+ # Public: Print a "Running N tests..." header.
29
+ def on_run_start(total:, worker_id:)
30
+ @last_group_path = nil
31
+ return if total.nil?
32
+
33
+ @io.puts "Running #{total} tests..."
34
+ @io.puts
35
+ end
36
+
37
+ # Public: Print a status-labeled line for the completed test, indented
38
+ # under its group headers.
39
+ def on_test_complete(result:, worker_id:)
40
+ path = Array(result.test.group_path)
41
+ emit_group_headers(path)
42
+ indent = " " * (path.size + 1)
43
+ case result.status
44
+ when :pass
45
+ @io.puts "#{indent}#{paint("pass", PASS_STYLE)} #{result.test.description}"
46
+ when :fail
47
+ @io.puts "#{indent}#{paint("FAIL", FAIL_STYLE)} #{result.test.description}\n #{result.error.message}"
48
+ when :error
49
+ msg = "#{result.error.class}: #{result.error.message}"
50
+ @io.puts "#{indent}#{paint("ERROR", ERROR_STYLE)} #{result.test.description}\n #{msg}"
51
+ when :skip
52
+ @io.puts "#{indent}#{paint("skip", SKIP_STYLE)} #{result.test.description}"
53
+ end
54
+ end
55
+
56
+ # Public: Print the final summary line with counts and optional color.
57
+ def on_run_finish(summary:, worker_id:)
58
+ t = summary[:total]
59
+ p = summary[:passed]
60
+ f = summary[:failed]
61
+ e = summary[:errored]
62
+ sk = summary[:skipped].to_i
63
+ if color?
64
+ parts = [
65
+ "\n#{t} tests:",
66
+ "#{paint("#{p} passed", PASS_STYLE)},",
67
+ "#{paint("#{f} failed", FAIL_STYLE)},",
68
+ "#{paint("#{e} errored", ERROR_STYLE)}"
69
+ ]
70
+ parts << ", #{paint("#{sk} skipped", SKIP_STYLE)}" if sk.positive?
71
+ line = "#{parts.join(" ")}#{RESET}"
72
+ else
73
+ line = "\n#{t} tests: #{p} passed, #{f} failed, #{e} errored"
74
+ line += ", #{sk} skipped" if sk.positive?
75
+ end
76
+ line += " (focus)" if summary[:focus]
77
+ @io.puts line
78
+ end
79
+
80
+ # Public: Delegate to ParallelSummaryReporter for the multi-worker summary.
81
+ def on_parallel_complete(counts:, problem_rows:)
82
+ ParallelSummaryReporter.new(@io).emit(counts:, problem_rows:)
83
+ end
84
+
85
+ private
86
+
87
+ def paint(text, sequence)
88
+ return text unless color?
89
+
90
+ "#{sequence}#{text}#{RESET}"
91
+ end
92
+
93
+ def color?
94
+ return @color_override unless @color_override.nil?
95
+
96
+ return false if ENV.key?("NO_COLOR")
97
+ return true if truthy_env?(ENV["FORCE_COLOR"]) || truthy_env?(ENV["VERITY_FORCE_COLOR"])
98
+
99
+ @io.respond_to?(:tty?) && @io.tty?
100
+ end
101
+
102
+ def truthy_env?(value)
103
+ %w[1 true yes].include?(value&.downcase)
104
+ end
105
+
106
+ def emit_group_headers(path)
107
+ last = @last_group_path || []
108
+ common = 0
109
+ n = [last.size, path.size].min
110
+ while common < n && last[common] == path[common]
111
+ common += 1
112
+ end
113
+
114
+ (common...path.size).each do |i|
115
+ @io.puts "#{" " * i}#{path[i]}"
116
+ end
117
+ @last_group_path = path.dup
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: Minimal reporter that prints a single character per test:
6
+ # "." for pass, "F" for failure, "E" for error. No color.
7
+ class DotsReporter
8
+ include Verity::Reporter
9
+
10
+ # Public: Create a new DotsReporter.
11
+ #
12
+ # io - IO object for output (default $stdout).
13
+ def initialize(io = $stdout)
14
+ @io = io
15
+ end
16
+
17
+ # Public: Print a dot, F, E, or S (skip) for the completed test.
18
+ def on_test_complete(result:, worker_id:)
19
+ char =
20
+ case result.status
21
+ when :pass then "."
22
+ when :fail then "F"
23
+ when :error then "E"
24
+ when :skip then "S"
25
+ end
26
+ @io.print char
27
+ @io.flush
28
+ end
29
+
30
+ # Public: Print the final summary line with counts.
31
+ def on_run_finish(summary:, worker_id:)
32
+ t = summary[:total]
33
+ p = summary[:passed]
34
+ f = summary[:failed]
35
+ e = summary[:errored]
36
+ line = "\n\n#{t} tests: #{p} passed, #{f} failed, #{e} errored"
37
+ line += ", #{summary[:skipped]} skipped" if summary[:skipped].to_i.positive?
38
+ line += " (focus)" if summary[:focus]
39
+ @io.puts line
40
+ end
41
+
42
+ # Public: Delegate to ParallelSummaryReporter for the multi-worker summary.
43
+ def on_parallel_complete(counts:, problem_rows:)
44
+ ParallelSummaryReporter.new(@io).emit(counts:, problem_rows:)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: Silent reporter that discards all output. Used in forked worker
6
+ # processes and in tests where reporter output is unwanted.
7
+ class NullReporter
8
+ include Verity::Reporter
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Internal: Shared helper that emits the multi-worker summary block.
6
+ # Typically called from a reporter's on_parallel_complete to print
7
+ # aggregate counts and list any failures or errors from the manifest.
8
+ class ParallelSummaryReporter
9
+ def initialize(io = $stdout)
10
+ @io = io
11
+ end
12
+
13
+ # Public: Write the parallel-run summary to the IO stream.
14
+ #
15
+ # counts - Hash with String status keys ("passed", "failed", etc.)
16
+ # and Integer counts.
17
+ # problem_rows - Array of Hashes with :fingerprint, :description, :status,
18
+ # and :failure from Manifest#failures_for_report.
19
+ #
20
+ # Returns nothing.
21
+ def emit(counts:, problem_rows:)
22
+ passed = counts.fetch("passed", 0)
23
+ failed = counts.fetch("failed", 0)
24
+ errored = counts.fetch("errored", 0)
25
+ pending = counts.fetch("pending", 0)
26
+ running = counts.fetch("running", 0)
27
+ skipped = counts.fetch("skipped", 0)
28
+ total = passed + failed + errored + pending + running
29
+
30
+ line = "Parallel run finished (#{total} tests in manifest: #{passed} passed, #{failed} failed, #{errored} errored, #{pending} pending, #{running} running"
31
+ line += ", #{skipped} skipped" if skipped > 0
32
+ line += ")"
33
+ @io.puts "\n#{line}"
34
+
35
+ return if problem_rows.empty?
36
+
37
+ @io.puts "\nFailures and errors:"
38
+ problem_rows.each do |row|
39
+ fp = row[:fingerprint]
40
+ desc = row[:description]
41
+ st = row[:status]
42
+ @io.puts " #{st} #{desc} (#{fp})"
43
+ next if row[:failure].nil? || row[:failure].empty?
44
+
45
+ msg = row[:failure]["message"] || row[:failure][:message]
46
+ @io.puts " #{msg}" if msg
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: In-memory reporter that records every callback for later
6
+ # inspection. Produces no I/O — designed for use in Verity's own tests
7
+ # and tooling.
8
+ class TestReporter
9
+ include Verity::Reporter
10
+
11
+ def initialize
12
+ @run_starts = []
13
+ @test_completes = []
14
+ @run_finishes = []
15
+ @parallel_finishes = []
16
+ end
17
+
18
+ # Public: Array of Hashes recorded from on_run_start calls.
19
+ # Each Hash contains :total and :worker_id.
20
+ #
21
+ # Public: Array of Hashes recorded from on_test_complete calls.
22
+ # Each Hash contains :status, :error (exception or nil), and :worker_id.
23
+ #
24
+ # Public: Array of Hashes recorded from on_run_finish calls.
25
+ # Each Hash contains :summary and :worker_id.
26
+ #
27
+ # Public: Array of Hashes recorded from on_parallel_complete calls.
28
+ # Each Hash contains :counts and :problem_rows.
29
+ attr_reader :run_starts, :test_completes, :run_finishes, :parallel_finishes
30
+
31
+ def on_run_start(total:, worker_id:)
32
+ @run_starts << { total: total, worker_id: worker_id }
33
+ end
34
+
35
+ def on_test_complete(result:, worker_id:)
36
+ @test_completes << { status: result.status, error: result.error, worker_id: worker_id }
37
+ end
38
+
39
+ def on_run_finish(summary:, worker_id:)
40
+ @run_finishes << { summary: summary, worker_id: worker_id }
41
+ end
42
+
43
+ def on_parallel_complete(counts:, problem_rows:)
44
+ @parallel_finishes << { counts: counts, problem_rows: problem_rows }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Verity
6
+ # Public: Executes tests, fires reporter hooks, and records results back to
7
+ # the manifest. A single Runner instance services one worker process.
8
+ class Runner
9
+ # Public: Immutable outcome of running a single test.
10
+ #
11
+ # test - The Verity::Test that was executed.
12
+ # status - Symbol :pass, :fail, :error, or :skip.
13
+ # error - Exception instance or nil.
14
+ Result = Data.define(:test, :status, :error)
15
+
16
+ # Public: Create a new Runner.
17
+ #
18
+ # reporter - Object implementing Verity::Reporter (default: from configuration).
19
+ def initialize(reporter: nil)
20
+ @reporter = reporter || Verity.configuration.reporter
21
+ end
22
+
23
+ # Public: Run a list of tests in-process without a manifest. Primarily
24
+ # used for simple single-worker execution.
25
+ #
26
+ # tests - Array of Verity::Test (default: all registered tests).
27
+ #
28
+ # Returns true if every test passed.
29
+ def run(tests = Registry.all)
30
+ run_worker(tests, worker_id: 0)
31
+ end
32
+
33
+ CONFLICT_RETRY_INTERVAL = 0.05
34
+
35
+ # Public: Claim and execute tests from a shared manifest until none remain.
36
+ # Fires before_worker_start hooks, then loops claim_next until exhausted.
37
+ # When resource resolvers are registered, builds a conflict exclusion list
38
+ # before each claim and sleeps briefly when blocked by running tests.
39
+ #
40
+ # manifest - A Verity::Manifest instance.
41
+ # worker_id - Integer identifying this worker.
42
+ #
43
+ # Returns true if every executed test passed.
44
+ def run_manifest(manifest, worker_id:)
45
+ Verity.hooks[:before_worker_start].each(&:call)
46
+
47
+ @reporter.on_run_start(total: manifest.example_count, worker_id: worker_id)
48
+
49
+ results = []
50
+ loop do
51
+ claimed = next_claim(manifest, worker_id)
52
+ if claimed == :blocked
53
+ sleep CONFLICT_RETRY_INTERVAL
54
+ next
55
+ end
56
+ break unless claimed
57
+
58
+ test = Registry.find(claimed.fingerprint)
59
+ unless test
60
+ err = RuntimeError.new(
61
+ "Test fingerprint not in Registry (load files before replace_tests): #{claimed.fingerprint}"
62
+ )
63
+ manifest.record_error(claimed.fingerprint, err)
64
+ result = Result.new(test: synthetic_test_from_claim(claimed), status: :error, error: err)
65
+ results << result
66
+ @reporter.on_test_complete(result: result, worker_id: worker_id)
67
+ next
68
+ end
69
+
70
+ result = run_with_hooks(test)
71
+ results << result
72
+ @reporter.on_test_complete(result: result, worker_id: worker_id)
73
+
74
+ case result.status
75
+ when :pass then manifest.record_pass(test.fingerprint)
76
+ when :fail then manifest.record_failure(test.fingerprint, result.error)
77
+ when :error then manifest.record_error(test.fingerprint, result.error)
78
+ end
79
+ end
80
+
81
+ Registry.all.select { Verity.skipped?(_1) }.each do |t|
82
+ @reporter.on_test_complete(
83
+ result: Result.new(test: t, status: :skip, error: nil),
84
+ worker_id: worker_id
85
+ )
86
+ end
87
+
88
+ without_skip = Registry.all.reject { Verity.skipped?(_1) }
89
+ skipped = Registry.all.count { Verity.skipped?(_1) }
90
+ focus = Verity.focus_filter_active?(without_skip)
91
+
92
+ @reporter.on_run_finish(
93
+ summary: build_summary(results, skipped: skipped, focus: focus),
94
+ worker_id: worker_id
95
+ )
96
+ results.all? { |r| r.status == :pass }
97
+ end
98
+
99
+ private
100
+
101
+ # Returns the next ClaimedRow, nil when the queue is empty, or the symbol
102
+ # :blocked when pending tests exist but all conflict with running tests.
103
+ def next_claim(manifest, worker_id)
104
+ return manifest.claim_next(worker_id) if Verity.resource_resolvers.empty?
105
+
106
+ running_res = manifest.running_resources
107
+ exclude = Verity.conflict_exclusion_list(running_res)
108
+ claimed = manifest.claim_next(worker_id, exclude: exclude)
109
+ return claimed if claimed
110
+ exclude.empty? ? nil : :blocked
111
+ end
112
+
113
+ def run_worker(tests, worker_id:)
114
+ without_skip = tests.reject { Verity.skipped?(_1) }
115
+ list =
116
+ if without_skip.any? { Verity.focus_tag?(_1) }
117
+ without_skip.select { Verity.focus_tag?(_1) }
118
+ else
119
+ without_skip
120
+ end
121
+
122
+ skipped = tests.count { Verity.skipped?(_1) }
123
+ focus = Verity.focus_filter_active?(without_skip)
124
+
125
+ @reporter.on_run_start(total: list.size, worker_id: worker_id)
126
+
127
+ results = []
128
+ list.each do |t|
129
+ r = run_with_hooks(t)
130
+ results << r
131
+ @reporter.on_test_complete(result: r, worker_id: worker_id)
132
+ end
133
+
134
+ tests.select { Verity.skipped?(_1) }.each do |t|
135
+ r = Result.new(test: t, status: :skip, error: nil)
136
+ @reporter.on_test_complete(result: r, worker_id: worker_id)
137
+ end
138
+
139
+ @reporter.on_run_finish(
140
+ summary: build_summary(results, skipped: skipped, focus: focus),
141
+ worker_id: worker_id
142
+ )
143
+ results.all? { |r| r.status == :pass }
144
+ end
145
+
146
+ def build_summary(results, skipped:, focus:)
147
+ {
148
+ total: results.size,
149
+ passed: results.count { |r| r.status == :pass },
150
+ failed: results.count { |r| r.status == :fail },
151
+ errored: results.count { |r| r.status == :error },
152
+ skipped: skipped,
153
+ focus: focus
154
+ }
155
+ end
156
+
157
+ def synthetic_test_from_claim(claimed)
158
+ Test.new(
159
+ fingerprint: claimed.fingerprint,
160
+ description: claimed.description,
161
+ tags: claimed.tags.map(&:to_sym),
162
+ timeout: claimed.timeout,
163
+ requires: claimed.requires.map(&:to_sym),
164
+ resources: claimed.resources.transform_keys(&:to_sym),
165
+ file: claimed.file,
166
+ line: claimed.line,
167
+ fn: -> {},
168
+ group_path: [],
169
+ inherited_group_tags: [],
170
+ group_scopes: []
171
+ )
172
+ end
173
+
174
+ def run_with_hooks(test)
175
+ result = nil
176
+ begin
177
+ Verity.hooks[:before_test].each(&:call)
178
+ result = execute(test)
179
+ rescue => e
180
+ result = Result.new(test:, status: :error, error: e)
181
+ ensure
182
+ Verity.hooks[:after_test].each(&:call)
183
+ end
184
+ result
185
+ end
186
+
187
+ def execute(test)
188
+ body = proc { test.fn.call }
189
+ if (sec = timeout_seconds_for(test))
190
+ Timeout.timeout(sec, TestTimeoutError, &body)
191
+ else
192
+ body.call
193
+ end
194
+ Result.new(test:, status: :pass, error: nil)
195
+ rescue AssertionError => e
196
+ Result.new(test:, status: :fail, error: e)
197
+ rescue TestTimeoutError => e
198
+ Result.new(test:, status: :error, error: e)
199
+ rescue => e
200
+ Result.new(test:, status: :error, error: e)
201
+ end
202
+
203
+ def timeout_seconds_for(test)
204
+ Verity.validate_test_timeout!(test.timeout)
205
+ return nil if test.timeout.nil?
206
+
207
+ test.timeout.to_f
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ VERSION = "0.1.0"
5
+ end