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,462 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "sqlite3"
6
+
7
+ module Verity
8
+ # Public: SQLite-backed manifest that coordinates test distribution across
9
+ # workers. Each row tracks a single test's fingerprint, metadata, and
10
+ # execution status. Workers atomically claim pending rows to run.
11
+ class Manifest
12
+ SCHEMA_VERSION = 3
13
+
14
+ # Public: Immutable value object returned by claim_next representing a
15
+ # single test row from the manifest with all its stored metadata.
16
+ #
17
+ # fingerprint - String content-based test identifier.
18
+ # file - String source file path.
19
+ # line - Integer source line number.
20
+ # description - String human-readable test name.
21
+ # method - String derived test method name.
22
+ # tags - Array of tag Strings.
23
+ # requires - Array of precondition Strings.
24
+ # resources - Hash of resource metadata.
25
+ # timeout - Float seconds or nil.
26
+ # status - Symbol (:pending, :running, :passed, :failed, :errored).
27
+ # worker_id - Integer or nil.
28
+ # failure - Hash with "class", "message", "backtrace" keys, or nil.
29
+ # queue_index - Integer coordinator dispatch order for this run.
30
+ ClaimedRow = Data.define(
31
+ :fingerprint, :file, :line, :description, :method,
32
+ :tags, :requires, :resources, :timeout,
33
+ :status, :worker_id, :failure, :queue_index
34
+ )
35
+
36
+ # Public: Open (or create) a manifest database at the given path.
37
+ #
38
+ # path - String file path, or ":memory:" for an in-process database.
39
+ #
40
+ # Returns a new Manifest instance.
41
+ def self.open(path, **)
42
+ new(path, **)
43
+ end
44
+
45
+ def initialize(path, busy_timeout_ms: 5000)
46
+ @memory = (path == ":memory:")
47
+ @busy_timeout_ms = busy_timeout_ms
48
+ FileUtils.mkdir_p(File.dirname(File.expand_path(path))) unless @memory
49
+ @db = SQLite3::Database.new(path)
50
+ configure_connection!
51
+ end
52
+
53
+ # Public: Close the underlying SQLite connection.
54
+ #
55
+ # Returns nothing.
56
+ def close = @db.close
57
+
58
+ # Internal: Raw SQLite3::Database handle for tests and introspection.
59
+ attr_reader :db
60
+
61
+ # Public: Create or upgrade the tests table to the current schema version.
62
+ # Safe to call multiple times; no-ops when already at SCHEMA_VERSION.
63
+ #
64
+ # Returns nothing.
65
+ def migrate!
66
+ loop do
67
+ version = @db.get_first_value("PRAGMA user_version").to_i
68
+ break if version >= SCHEMA_VERSION
69
+
70
+ if version.zero?
71
+ @db.transaction do
72
+ @db.execute_batch(<<~SQL)
73
+ CREATE TABLE IF NOT EXISTS tests (
74
+ fingerprint TEXT PRIMARY KEY,
75
+ file TEXT NOT NULL,
76
+ line INTEGER NOT NULL,
77
+ description TEXT,
78
+ method TEXT NOT NULL,
79
+ tags TEXT,
80
+ requires TEXT,
81
+ resources TEXT,
82
+ timeout REAL,
83
+ queue_index INTEGER NOT NULL DEFAULT 0,
84
+ status TEXT NOT NULL DEFAULT 'pending',
85
+ worker_id INTEGER,
86
+ failure TEXT,
87
+ CHECK (status IN ('pending', 'running', 'passed', 'failed', 'errored'))
88
+ );
89
+ CREATE INDEX IF NOT EXISTS idx_tests_pending
90
+ ON tests (status, fingerprint);
91
+ SQL
92
+ @db.execute("PRAGMA user_version = #{SCHEMA_VERSION}")
93
+ end
94
+ elsif version == 1
95
+ @db.transaction do
96
+ @db.execute("DROP INDEX IF EXISTS idx_tests_pending_duration")
97
+ @db.execute("ALTER TABLE tests DROP COLUMN duration_p50")
98
+ @db.execute(<<~SQL)
99
+ CREATE INDEX IF NOT EXISTS idx_tests_pending
100
+ ON tests (status, fingerprint);
101
+ SQL
102
+ @db.execute("PRAGMA user_version = 2")
103
+ end
104
+ elsif version == 2
105
+ @db.transaction do
106
+ @db.execute("ALTER TABLE tests ADD COLUMN queue_index INTEGER NOT NULL DEFAULT 0")
107
+ @db.execute("PRAGMA user_version = #{SCHEMA_VERSION}")
108
+ end
109
+ else
110
+ raise ArgumentError, "unsupported manifest schema user_version #{version}"
111
+ end
112
+ end
113
+ end
114
+
115
+ # Public: Atomically clear the tests table and insert the given tests as
116
+ # pending rows. Called once per run before workers begin claiming.
117
+ #
118
+ # tests - Array of Verity::Test instances in coordinator dispatch order.
119
+ # Each element's index becomes queue_index (claim order).
120
+ #
121
+ # Returns nothing.
122
+ def replace_tests(tests)
123
+ @db.transaction do
124
+ @db.execute("DELETE FROM tests")
125
+ stmt = @db.prepare(<<~SQL)
126
+ INSERT INTO tests (
127
+ fingerprint, file, line, description, method,
128
+ tags, requires, resources, timeout, queue_index,
129
+ status, worker_id, failure
130
+ ) VALUES (
131
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
132
+ 'pending', NULL, NULL
133
+ )
134
+ SQL
135
+ tests.each_with_index do |t, queue_index|
136
+ stmt.execute!(
137
+ t.fingerprint,
138
+ t.file,
139
+ t.line,
140
+ t.description,
141
+ derive_method(t.fingerprint),
142
+ dump_json(t.tags),
143
+ dump_json(t.requires),
144
+ dump_json(t.resources),
145
+ t.timeout,
146
+ queue_index
147
+ )
148
+ end
149
+ stmt.close
150
+ end
151
+ end
152
+
153
+ # Public: Atomically claim the next pending test for a worker. Marks the
154
+ # row as "running" and returns its data.
155
+ #
156
+ # worker_id - Integer identifying the claiming worker.
157
+ # exclude - Array of fingerprint Strings to skip (default []).
158
+ #
159
+ # Returns a ClaimedRow, or nil when no claimable pending tests remain.
160
+ def claim_next(worker_id, exclude: [])
161
+ if exclude.empty?
162
+ rows = @db.execute2(<<~SQL, worker_id)
163
+ UPDATE tests
164
+ SET status = 'running', worker_id = ?
165
+ WHERE fingerprint = (
166
+ SELECT fingerprint FROM tests
167
+ WHERE status = 'pending'
168
+ ORDER BY queue_index ASC, fingerprint ASC
169
+ LIMIT 1
170
+ )
171
+ RETURNING
172
+ fingerprint, file, line, description, method,
173
+ tags, requires, resources, timeout,
174
+ status, worker_id, failure, queue_index
175
+ SQL
176
+ else
177
+ placeholders = (["?"] * exclude.size).join(", ")
178
+ rows = @db.execute2(<<~SQL, [worker_id] + exclude)
179
+ UPDATE tests
180
+ SET status = 'running', worker_id = ?
181
+ WHERE fingerprint = (
182
+ SELECT fingerprint FROM tests
183
+ WHERE status = 'pending'
184
+ AND fingerprint NOT IN (#{placeholders})
185
+ ORDER BY queue_index ASC, fingerprint ASC
186
+ LIMIT 1
187
+ )
188
+ RETURNING
189
+ fingerprint, file, line, description, method,
190
+ tags, requires, resources, timeout,
191
+ status, worker_id, failure, queue_index
192
+ SQL
193
+ end
194
+ return nil if rows.size < 2
195
+
196
+ headers = rows[0]
197
+ values = rows[1]
198
+ hash = headers.zip(values).to_h
199
+ hydrate_row(hash)
200
+ end
201
+
202
+ # Public: Mark a test as passed.
203
+ #
204
+ # fingerprint - String test fingerprint.
205
+ #
206
+ # Returns nothing.
207
+ def record_pass(fingerprint)
208
+ @db.execute(<<~SQL, [fingerprint])
209
+ UPDATE tests
210
+ SET status = 'passed', failure = NULL, worker_id = NULL
211
+ WHERE fingerprint = ?
212
+ SQL
213
+ end
214
+
215
+ # Public: Mark a test as failed and store the failure details.
216
+ #
217
+ # fingerprint - String test fingerprint.
218
+ # error - Exception that caused the failure.
219
+ #
220
+ # Returns nothing.
221
+ def record_failure(fingerprint, error)
222
+ @db.execute(<<~SQL, [encode_failure(error), fingerprint])
223
+ UPDATE tests
224
+ SET status = 'failed', failure = ?, worker_id = NULL
225
+ WHERE fingerprint = ?
226
+ SQL
227
+ end
228
+
229
+ # Public: Mark a test as errored (unexpected exception) and store details.
230
+ #
231
+ # fingerprint - String test fingerprint.
232
+ # error - Exception that caused the error.
233
+ #
234
+ # Returns nothing.
235
+ def record_error(fingerprint, error)
236
+ @db.execute(<<~SQL, [encode_failure(error), fingerprint])
237
+ UPDATE tests
238
+ SET status = 'errored', failure = ?, worker_id = NULL
239
+ WHERE fingerprint = ?
240
+ SQL
241
+ end
242
+
243
+ # Public: Mark every row still in +running+ status as +errored+ with a
244
+ # coordinator-level message. Call this after worker processes exit when
245
+ # a worker may have terminated without recording a result (crash, kill),
246
+ # so replay and status counts stay consistent.
247
+ #
248
+ # Returns the number of rows updated.
249
+ def reclaim_abandoned_running!
250
+ n = 0
251
+ @db.transaction do
252
+ n = @db.get_first_value("SELECT COUNT(*) FROM tests WHERE status = 'running'").to_i
253
+ if n > 0
254
+ err = RuntimeError.new("test abandoned: worker exited before recording a result")
255
+ payload = encode_failure(err)
256
+ @db.execute(<<~SQL, [payload])
257
+ UPDATE tests
258
+ SET status = 'errored', failure = ?, worker_id = NULL
259
+ WHERE status = 'running'
260
+ SQL
261
+ end
262
+ end
263
+ n
264
+ end
265
+
266
+ # Public: Return the resources hash for every test currently marked running.
267
+ # Used by Runner to build a conflict exclusion list before claiming.
268
+ #
269
+ # Returns an Array of Hashes (string keys, string values via JSON).
270
+ def running_resources
271
+ @db.execute("SELECT resources FROM tests WHERE status = 'running'")
272
+ .map { |r| parse_json_object(r[0]) }
273
+ end
274
+
275
+ # Public: Total number of test rows in the manifest.
276
+ #
277
+ # Returns an Integer.
278
+ def example_count
279
+ @db.get_first_value("SELECT COUNT(*) FROM tests").to_i
280
+ end
281
+
282
+ # Public: Aggregate test counts grouped by status. Used by
283
+ # ParallelSummaryReporter after all workers finish.
284
+ #
285
+ # Returns a Hash with String status keys and Integer counts.
286
+ def count_by_status
287
+ @db.execute("SELECT status, COUNT(*) FROM tests GROUP BY status").to_h.transform_values(&:to_i)
288
+ end
289
+
290
+ # Public: Fetch details for all failed and errored tests, ordered by
291
+ # fingerprint, for the final summary report.
292
+ #
293
+ # Returns an Array of Hashes with :fingerprint, :description, :status,
294
+ # and :failure keys.
295
+ def failures_for_report
296
+ @db.execute(<<~SQL).map do |fingerprint, description, status, failure|
297
+ SELECT fingerprint, description, status, failure
298
+ FROM tests
299
+ WHERE status IN ('failed', 'errored')
300
+ ORDER BY fingerprint ASC
301
+ SQL
302
+ {
303
+ fingerprint: fingerprint,
304
+ description: description,
305
+ status: status.to_sym,
306
+ failure: parse_failure(failure)
307
+ }
308
+ end
309
+ end
310
+
311
+ # Public: After parallel workers finish, yield Verity::Runner::Result once per
312
+ # finished row (passed, failed, or errored) in dispatch (queue_index) order so
313
+ # the parent can replay reporter output (dots, documentation lines, etc.).
314
+ # Workers use NullReporter during execution; child processes do not invoke the user's
315
+ # reporter.
316
+ #
317
+ # Yields Verity::Runner::Result.
318
+ #
319
+ # Returns Enumerator when no block is given.
320
+ def each_parallel_replay_result
321
+ return enum_for(:each_parallel_replay_result) unless block_given?
322
+
323
+ data = @db.execute2(<<~SQL)
324
+ SELECT fingerprint, file, line, description, method,
325
+ tags, requires, resources, timeout,
326
+ status, failure, queue_index
327
+ FROM tests
328
+ WHERE status IN ('passed', 'failed', 'errored')
329
+ ORDER BY queue_index ASC, fingerprint ASC
330
+ SQL
331
+ headers = data[0]
332
+ Array(data[1..]).each do |vals|
333
+ hash = headers.zip(vals).to_h
334
+ row = hydrate_row(hash)
335
+ result_status =
336
+ case row.status
337
+ when :passed then :pass
338
+ when :failed then :fail
339
+ when :errored then :error
340
+ else row.status
341
+ end
342
+ err = replay_exception(result_status, row.failure)
343
+ resources = normalize_resource_keys(row.resources)
344
+ test = Verity::Test.new(
345
+ fingerprint: row.fingerprint,
346
+ description: row.description.to_s,
347
+ tags: Array(row.tags).map(&:to_sym),
348
+ timeout: row.timeout,
349
+ requires: Array(row.requires).map(&:to_sym),
350
+ resources: resources,
351
+ file: row.file,
352
+ line: row.line,
353
+ fn: -> { raise "parallel replay stub" },
354
+ group_path: [].freeze,
355
+ inherited_group_tags: [].freeze,
356
+ group_scopes: [].freeze
357
+ )
358
+ yield Verity::Runner::Result.new(test: test, status: result_status, error: err)
359
+ end
360
+ end
361
+
362
+ private
363
+
364
+ def normalize_resource_keys(resources)
365
+ case resources
366
+ when Hash
367
+ resources.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
368
+ else
369
+ {}
370
+ end
371
+ end
372
+
373
+ def replay_exception(status, failure_h)
374
+ return nil if failure_h.nil? || failure_h.empty?
375
+
376
+ msg = failure_h["message"].to_s
377
+ case status
378
+ when :fail
379
+ Verity::AssertionError.new(msg)
380
+ when :error
381
+ cname = failure_h["class"].to_s
382
+ replay_error_for_parallel_report(cname, msg)
383
+ end
384
+ end
385
+
386
+ # Parallel replay must not +const_get+ arbitrary class names from the DB.
387
+ def replay_error_for_parallel_report(cname, msg)
388
+ if cname.empty?
389
+ RuntimeError.new(msg.empty? ? "error" : msg)
390
+ else
391
+ RuntimeError.new("#{cname}: #{msg}")
392
+ end
393
+ end
394
+
395
+ def configure_connection!
396
+ @db.busy_timeout = @busy_timeout_ms
397
+ return if @memory
398
+
399
+ @db.execute("PRAGMA journal_mode=WAL")
400
+ rescue SQLite3::SQLException
401
+ # Unavailable for some URI modes; ignore.
402
+ end
403
+
404
+ def derive_method(fingerprint)
405
+ "test_#{Verity::Fingerprint.derive_method_suffix(fingerprint)}"
406
+ end
407
+
408
+ def dump_json(obj)
409
+ JSON.generate(normalize_json_tree(obj))
410
+ end
411
+
412
+ def normalize_json_tree(obj)
413
+ case obj
414
+ when Hash then obj.transform_values { |v| normalize_json_tree(v) }
415
+ when Array then obj.map { |v| normalize_json_tree(v) }
416
+ when Symbol then obj.to_s
417
+ else obj
418
+ end
419
+ end
420
+
421
+ def encode_failure(error)
422
+ JSON.generate(
423
+ "class" => error.class.name,
424
+ "message" => error.message.to_s,
425
+ "backtrace" => Array(error.backtrace).take(50)
426
+ )
427
+ end
428
+
429
+ def hydrate_row(hash)
430
+ ClaimedRow.new(
431
+ fingerprint: hash["fingerprint"],
432
+ file: hash["file"],
433
+ line: Integer(hash["line"]),
434
+ description: hash["description"],
435
+ method: hash["method"],
436
+ tags: parse_json_array(hash["tags"]),
437
+ requires: parse_json_array(hash["requires"]),
438
+ resources: parse_json_object(hash["resources"]),
439
+ timeout: hash["timeout"]&.to_f,
440
+ status: hash["status"].to_sym,
441
+ worker_id: hash["worker_id"]&.to_i,
442
+ failure: parse_failure(hash["failure"]),
443
+ queue_index: hash["queue_index"].nil? ? 0 : Integer(hash["queue_index"])
444
+ )
445
+ end
446
+
447
+ def parse_json_array(str)
448
+ return [] if str.nil? || str.empty?
449
+ JSON.parse(str)
450
+ end
451
+
452
+ def parse_json_object(str)
453
+ return {} if str.nil? || str.empty?
454
+ JSON.parse(str)
455
+ end
456
+
457
+ def parse_failure(str)
458
+ return nil if str.nil? || str.empty?
459
+ JSON.parse(str)
460
+ end
461
+ end
462
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ # Public: Interface module for test-run lifecycle hooks. Include this module
5
+ # and override the methods you need. All methods are no-ops by default.
6
+ #
7
+ # Examples
8
+ #
9
+ # class MyReporter
10
+ # include Verity::Reporter
11
+ #
12
+ # def on_test_complete(result:, worker_id:)
13
+ # puts result.test.description
14
+ # end
15
+ # end
16
+ #
17
+ # Verity.configure { |c| c.reporter = MyReporter.new }
18
+ module Reporter
19
+ # Public: Called once when a worker begins its test run.
20
+ #
21
+ # total - Integer expected number of examples, or nil if unknown.
22
+ # worker_id - Integer manifest worker id.
23
+ #
24
+ # Returns nothing.
25
+ def on_run_start(total:, worker_id:); end
26
+
27
+ # Public: Called after each individual test finishes.
28
+ #
29
+ # result - Verity::Runner::Result with test, status, and error.
30
+ # worker_id - Integer manifest worker id.
31
+ #
32
+ # Returns nothing.
33
+ def on_test_complete(result:, worker_id:); end
34
+
35
+ # Public: Called once after all tests in a worker have completed.
36
+ #
37
+ # summary - Hash with :total, :passed, :failed, :errored, :skipped
38
+ # (Integers) and :focus (Boolean).
39
+ # worker_id - Integer manifest worker id.
40
+ #
41
+ # Returns nothing.
42
+ def on_run_finish(summary:, worker_id:); end
43
+
44
+ # Public: Called from the parent process after all forked workers exit.
45
+ # Only invoked during parallel runs via Verity.run.
46
+ #
47
+ # counts - Hash with String status keys and Integer counts from
48
+ # Manifest#count_by_status.
49
+ # problem_rows - Array of Hashes from Manifest#failures_for_report.
50
+ #
51
+ # Returns nothing.
52
+ def on_parallel_complete(counts:, problem_rows:); end
53
+ end
54
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: Like DotsReporter, but uses ANSI colors when outputting to a TTY.
6
+ # Green for pass, red for failure, yellow for error, cyan for skip.
7
+ # Respects NO_COLOR,
8
+ # FORCE_COLOR, and VERITY_FORCE_COLOR environment variables.
9
+ class ColoredDotsReporter < DotsReporter
10
+ ESC = "\e["
11
+ RESET = "#{ESC}0m"
12
+ PASS = "#{ESC}32m"
13
+ FAIL = "#{ESC}31m"
14
+ ERR = "#{ESC}33m"
15
+ SKIP = "#{ESC}36m"
16
+
17
+ # Public: Create a new ColoredDotsReporter.
18
+ #
19
+ # io - IO object for output (default $stdout).
20
+ # color - Boolean to force color on/off, or nil for auto-detect.
21
+ def initialize(io = $stdout, color: nil)
22
+ super(io)
23
+ @color_override = color
24
+ end
25
+
26
+ # Public: Print a colored dot, F, E, or S (skip) for the completed test.
27
+ def on_test_complete(result:, worker_id:)
28
+ char, sequence =
29
+ case result.status
30
+ when :pass then [".", PASS]
31
+ when :fail then ["F", FAIL]
32
+ when :error then ["E", ERR]
33
+ when :skip then ["S", SKIP]
34
+ end
35
+ if color?
36
+ @io.print "#{sequence}#{char}#{RESET}"
37
+ else
38
+ @io.print char
39
+ end
40
+ @io.flush
41
+ end
42
+
43
+ private
44
+
45
+ def color?
46
+ return @color_override unless @color_override.nil?
47
+
48
+ return false if ENV.key?("NO_COLOR")
49
+ return true if truthy_env?(ENV["FORCE_COLOR"]) || truthy_env?(ENV["VERITY_FORCE_COLOR"])
50
+
51
+ @io.respond_to?(:tty?) && @io.tty?
52
+ end
53
+
54
+ def truthy_env?(value)
55
+ %w[1 true yes].include?(value&.downcase)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Verity
4
+ module Reporters
5
+ # Public: Multiplexer that forwards every Reporter callback to multiple
6
+ # child reporters. Useful when you need both console output and
7
+ # machine-readable logging from the same run.
8
+ #
9
+ # Examples
10
+ #
11
+ # Verity.configure do |c|
12
+ # c.reporter = Verity::Reporters::CompositeReporter.new(
13
+ # Verity::Reporters::DotsReporter.new($stdout),
14
+ # Verity::Reporters::TestReporter.new
15
+ # )
16
+ # end
17
+ class CompositeReporter
18
+ include Verity::Reporter
19
+
20
+ # Public: Create a new CompositeReporter wrapping one or more reporters.
21
+ #
22
+ # reporters - One or more objects implementing Verity::Reporter.
23
+ def initialize(*reporters)
24
+ @reporters = reporters
25
+ end
26
+
27
+ def on_run_start(total:, worker_id:)
28
+ @reporters.each { _1.on_run_start(total:, worker_id:) }
29
+ end
30
+
31
+ def on_test_complete(result:, worker_id:)
32
+ @reporters.each { _1.on_test_complete(result:, worker_id:) }
33
+ end
34
+
35
+ def on_run_finish(summary:, worker_id:)
36
+ @reporters.each { _1.on_run_finish(summary:, worker_id:) }
37
+ end
38
+
39
+ def on_parallel_complete(counts:, problem_rows:)
40
+ @reporters.each { _1.on_parallel_complete(counts:, problem_rows:) }
41
+ end
42
+ end
43
+ end
44
+ end