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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +227 -0
- data/bin/benchmark +99 -0
- data/bin/test_all +43 -0
- data/bin/verity +82 -0
- data/lib/verity/assertions.rb +316 -0
- data/lib/verity/configuration.rb +129 -0
- data/lib/verity/fingerprint.rb +155 -0
- data/lib/verity/manifest.rb +462 -0
- data/lib/verity/reporter.rb +54 -0
- data/lib/verity/reporters/colored_dots.rb +59 -0
- data/lib/verity/reporters/composite_reporter.rb +44 -0
- data/lib/verity/reporters/documentation_reporter.rb +121 -0
- data/lib/verity/reporters/dots_reporter.rb +48 -0
- data/lib/verity/reporters/null_reporter.rb +11 -0
- data/lib/verity/reporters/parallel_summary_reporter.rb +51 -0
- data/lib/verity/reporters/test_reporter.rb +48 -0
- data/lib/verity/runner.rb +210 -0
- data/lib/verity/version.rb +5 -0
- data/lib/verity.rb +614 -0
- metadata +139 -0
|
@@ -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
|