hegeltest 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/CHANGELOG.md +22 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +264 -0
- data/Rakefile +19 -0
- data/docs/README.md +25 -0
- data/docs/adr/0001-bind-libhegel-through-fiddle.md +54 -0
- data/docs/adr/0002-ship-one-prebuilt-engine-per-platform-specific-gem.md +48 -0
- data/docs/adr/0003-publish-as-hegeltest-require-as-hegel.md +39 -0
- data/docs/adr/0004-expose-generators-through-a-mixin-with-keyword-options.md +42 -0
- data/docs/adr/0005-name-drawn-values-from-the-callers-source-with-prism.md +40 -0
- data/docs/adr/0006-verify-the-binding-in-seven-layers-with-full-coverage.md +51 -0
- data/docs/adr/0007-ship-a-thin-ruby-skill-shaped-for-donation.md +56 -0
- data/docs/adr/0008-revisit-the-binding-after-milestone-c-on-measurement.md +81 -0
- data/docs/adr/0009-turn-the-example-database-on-with-a-key.md +89 -0
- data/docs/adr/0010-declare-stateful-rules-with-a-class-macro.md +113 -0
- data/docs/adr/0011-let-the-test-case-own-every-pool-drawn-from-it.md +83 -0
- data/docs/adr/0012-build-a-failure-origin-from-the-callers-own-frame.md +72 -0
- data/docs/adr/0013-bind-libhegel-through-the-ffi-gem.md +102 -0
- data/docs/architecture.md +182 -0
- data/lib/hegel/draw_name.rb +109 -0
- data/lib/hegel/errors.rb +47 -0
- data/lib/hegel/generator.rb +98 -0
- data/lib/hegel/generators.rb +865 -0
- data/lib/hegel/lib_hegel/real.rb +1149 -0
- data/lib/hegel/lib_hegel.rb +269 -0
- data/lib/hegel/libhegel_version.rb +9 -0
- data/lib/hegel/locate.rb +188 -0
- data/lib/hegel/report.rb +87 -0
- data/lib/hegel/runner.rb +464 -0
- data/lib/hegel/settings.rb +164 -0
- data/lib/hegel/state_machine.rb +89 -0
- data/lib/hegel/stateful/pool.rb +111 -0
- data/lib/hegel/stateful.rb +120 -0
- data/lib/hegel/syntax/methods.rb +173 -0
- data/lib/hegel/test_case.rb +523 -0
- data/lib/hegel/version.rb +5 -0
- data/lib/hegel.rb +92 -0
- data/lib/hegeltest.rb +7 -0
- data/lib/tasks/libhegel.rake +112 -0
- data/lib/tasks/platform_gems.rake +111 -0
- data/sig/hegel.rbs +563 -0
- data/skills/hegel-ruby/SKILL.md +30 -0
- data/skills/hegel-ruby/references/ruby/reference.md +1210 -0
- metadata +113 -0
data/lib/hegel/runner.rb
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "lib_hegel"
|
|
5
|
+
require_relative "report"
|
|
6
|
+
require_relative "settings"
|
|
7
|
+
require_relative "test_case"
|
|
8
|
+
|
|
9
|
+
module Hegel
|
|
10
|
+
# Drives one Hegel.test run: this is the Ruby side of the per-test-case
|
|
11
|
+
# lifecycle hegel-rust's src/run_lifecycle.rs calls `drive`. Ruby owns the
|
|
12
|
+
# loop; libhegel owns generation, shrinking, and (on failure) database
|
|
13
|
+
# replay, so a user's test body never has to cross into native code itself.
|
|
14
|
+
module Runner
|
|
15
|
+
# #origin_for's fallback when an exception's backtrace has no first
|
|
16
|
+
# location to build a real origin from. Named the way hegel-rust names
|
|
17
|
+
# its own equivalent constant, "Panic at <unknown>", for a panic with no
|
|
18
|
+
# location.
|
|
19
|
+
UNKNOWN_ORIGIN = "Raised at <unknown>"
|
|
20
|
+
|
|
21
|
+
# #infrastructure? treats a backtrace path as this library's own when it
|
|
22
|
+
# falls under this directory. It is lib/, one above runner.rb's own
|
|
23
|
+
# __dir__, so that hegel.rb and hegeltest.rb count too: Hegel.test lives
|
|
24
|
+
# in hegel.rb, and its frame sits below every frame a property body
|
|
25
|
+
# raises through. A property can be defined inside some other gem, such
|
|
26
|
+
# as a shared test-helper gem calling Hegel.test on its caller's
|
|
27
|
+
# behalf. That would otherwise leave hegel.rb as the first frame
|
|
28
|
+
# belonging to nobody, and name this library as the origin of the
|
|
29
|
+
# caller's own bug.
|
|
30
|
+
#
|
|
31
|
+
# A trailing separator makes the prefix match a directory boundary
|
|
32
|
+
# rather than a string prefix, so a sibling directory that merely
|
|
33
|
+
# starts with the same characters (lib/hegelx/) cannot match.
|
|
34
|
+
LIBRARY_DIR = File.expand_path("..", __dir__) + File::SEPARATOR
|
|
35
|
+
|
|
36
|
+
# Every directory an installed gem's own files live under. Gem.path lists
|
|
37
|
+
# each root RubyGems searches (the default gem home plus $GEM_PATH), and
|
|
38
|
+
# each root keeps installed gems under a "gems/" subdirectory -- the
|
|
39
|
+
# structure a test framework such as minitest or rspec-expectations is
|
|
40
|
+
# installed into. A caller's own code loaded via a Gemfile `path:` or
|
|
41
|
+
# `git:` entry lives in the working copy instead, so this rule cannot
|
|
42
|
+
# mistake it for a framework's own frame (see docs/adr/0012). Read once,
|
|
43
|
+
# when this file is first required: under Bundler, that require happens
|
|
44
|
+
# after Bundler has already set the gem paths this process uses, so the
|
|
45
|
+
# list this constant freezes is the one every later frame is checked
|
|
46
|
+
# against.
|
|
47
|
+
INSTALLED_GEM_DIRS = Gem.path.map { |path| File.join(path, "gems") + File::SEPARATOR }.freeze
|
|
48
|
+
|
|
49
|
+
# Ruby's own standard library, e.g. the minitest release Ruby itself
|
|
50
|
+
# bundles rather than one RubyGems installed -- a second way a test
|
|
51
|
+
# framework's own frame can appear that INSTALLED_GEM_DIRS alone would
|
|
52
|
+
# not catch.
|
|
53
|
+
STDLIB_DIR = RbConfig::CONFIG["rubylibdir"] + File::SEPARATOR
|
|
54
|
+
|
|
55
|
+
# hegel_run_result_error's fallback: the header documents a NULL message
|
|
56
|
+
# as possible for an ERROR run, and Hegel::Error still needs *some*
|
|
57
|
+
# message text in that case.
|
|
58
|
+
UNKNOWN_RUN_ERROR_MESSAGE = "hegel: the run failed without an error message"
|
|
59
|
+
|
|
60
|
+
# Counts test cases as #drive receives them from the live run loop, and
|
|
61
|
+
# snapshots, per distinct origin, how many had been returned (and how
|
|
62
|
+
# many of those were discarded) the first time that origin's exception
|
|
63
|
+
# was classified INTERESTING. That snapshot is the failure report's own
|
|
64
|
+
# "Falsified after N test cases (M discarded)" line: the generation
|
|
65
|
+
# phase's counts, not the shrink phase's -- #drive's own comment
|
|
66
|
+
# measured the shrink phase at roughly 50x more iterations for a
|
|
67
|
+
# similarly sized run, and counting those into N would answer a
|
|
68
|
+
# different question than the report claims to.
|
|
69
|
+
class GenerationStats
|
|
70
|
+
def initialize
|
|
71
|
+
@test_cases = 0
|
|
72
|
+
@discarded = 0
|
|
73
|
+
@snapshots = {}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Called once per case #drive receives (or, from #reproduce, exactly
|
|
77
|
+
# once for its own single case).
|
|
78
|
+
def record(status, origin)
|
|
79
|
+
@test_cases += 1
|
|
80
|
+
@discarded += 1 if status == LibHegel::HEGEL_STATUS_INVALID
|
|
81
|
+
@snapshots[origin] ||= [@test_cases, @discarded] if status == LibHegel::HEGEL_STATUS_INTERESTING
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# [test_cases, discarded] as of +origin+'s first INTERESTING
|
|
85
|
+
# appearance. No fallback: every origin #replay_failure asks for here
|
|
86
|
+
# came from a failure hegel_run_result reported, and that failure
|
|
87
|
+
# exists only because this same #record already saw it live.
|
|
88
|
+
def for(origin)
|
|
89
|
+
@snapshots.fetch(origin)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
module_function
|
|
94
|
+
|
|
95
|
+
# Runs +block+ as a Hegel property against +impl+, applying +test_cases+,
|
|
96
|
+
# +seed+, +derandomize+, +verbosity+, +database+, +database_key+,
|
|
97
|
+
# +phases+, +suppress_health_check+, +report_multiple_failures+, and
|
|
98
|
+
# +stateful_step_count+ (see Hegel::Settings) to a fresh settings handle
|
|
99
|
+
# first. Returns nil on a passing run, re-raises the exception the
|
|
100
|
+
# smallest failing case's body raised on a failing run, and raises
|
|
101
|
+
# Hegel::Error for a run-level failure (ERROR status, a replay that
|
|
102
|
+
# could not reproduce the recorded failure, or more than one distinct
|
|
103
|
+
# failure -- see #replay).
|
|
104
|
+
#
|
|
105
|
+
# +database+ and +database_key+ follow the table
|
|
106
|
+
# Hegel::Settings.apply_database documents; docs/adr/0009 has the
|
|
107
|
+
# decision and the measurements behind it.
|
|
108
|
+
#
|
|
109
|
+
# +report_multiple_failures+ defaults to false, not nil: hegel-c/src/
|
|
110
|
+
# settings.rs itself defaults to true, but hegel-rust's own Rust API
|
|
111
|
+
# (src/runner.rs) and hegel-java both default to false, and hegel-java
|
|
112
|
+
# states the reason: one failure re-raised unaltered keeps the exception
|
|
113
|
+
# class and the stack trace a debugger reads, which a summary replaces
|
|
114
|
+
# with a count.
|
|
115
|
+
# That is this library's own central promise (#classify re-raises a
|
|
116
|
+
# body's exception with its class and backtrace intact), so the same
|
|
117
|
+
# reason applies here, and choosing to depart from the engine's own
|
|
118
|
+
# default is itself a decision worth stating explicitly with `false`
|
|
119
|
+
# rather than leaving it to a nil a reader could mistake for "no
|
|
120
|
+
# opinion".
|
|
121
|
+
#
|
|
122
|
+
# +reproduce_failure+, when given, wins over everything above except
|
|
123
|
+
# +verbosity+: it skips the run loop entirely and replays a single case
|
|
124
|
+
# built from that blob (see #reproduce). +test_cases+ has no run to
|
|
125
|
+
# bound in that case.
|
|
126
|
+
#
|
|
127
|
+
# +output+ (default $stderr) is where a failure report is written,
|
|
128
|
+
# unless +verbosity+ is :quiet, in which case none is written at all.
|
|
129
|
+
#
|
|
130
|
+
# Handles nest context > settings > run > result, each freed in an
|
|
131
|
+
# `ensure` by the code that opened it, innermost first: hegel_context_free
|
|
132
|
+
# requires every other handle taken from the context to be freed first,
|
|
133
|
+
# and Ruby's GC gives finalizers no ordering guarantee to rely on instead.
|
|
134
|
+
#
|
|
135
|
+
# +settings+ stays open through the whole run, not just through
|
|
136
|
+
# hegel_run_start. The header says a caller may free settings as soon as
|
|
137
|
+
# hegel_run_start returns, but that is only true for driving the loop: a
|
|
138
|
+
# failing run's replay calls hegel_test_case_from_blob against this same
|
|
139
|
+
# settings handle, so it must outlive the FAILED-status replay below, not
|
|
140
|
+
# just the loop above it.
|
|
141
|
+
def run(impl:, test_cases: nil, seed: nil, derandomize: nil, verbosity: nil, database: nil, database_key: nil,
|
|
142
|
+
phases: nil, suppress_health_check: nil, report_multiple_failures: false, stateful_step_count: nil,
|
|
143
|
+
output: $stderr, reproduce_failure: nil, &block)
|
|
144
|
+
quiet = verbosity == :quiet
|
|
145
|
+
LibHegel.with_context(impl) do |ctx|
|
|
146
|
+
settings = impl.settings_new(ctx)
|
|
147
|
+
begin
|
|
148
|
+
Settings.apply(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
|
|
149
|
+
verbosity: verbosity, database: database, database_key: database_key, phases: phases,
|
|
150
|
+
suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures,
|
|
151
|
+
stateful_step_count: stateful_step_count)
|
|
152
|
+
|
|
153
|
+
if reproduce_failure
|
|
154
|
+
reproduce(impl, ctx, settings, reproduce_failure, quiet: quiet, output: output, &block)
|
|
155
|
+
else
|
|
156
|
+
run_and_finish(impl, ctx, settings, quiet: quiet, output: output, &block)
|
|
157
|
+
end
|
|
158
|
+
ensure
|
|
159
|
+
impl.settings_free(ctx, settings)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The ordinary (non-reproduce_failure) path: starts a run, drives it,
|
|
165
|
+
# and hands its result to #finish. Split out of #run so that path and
|
|
166
|
+
# #reproduce's are two plain branches there, not one method doing both.
|
|
167
|
+
def run_and_finish(impl, ctx, settings, quiet:, output:, &block)
|
|
168
|
+
run = impl.run_start(ctx, settings)
|
|
169
|
+
begin
|
|
170
|
+
stats = GenerationStats.new
|
|
171
|
+
drive(impl, ctx, run, stats, &block)
|
|
172
|
+
|
|
173
|
+
result = impl.run_result(ctx, run)
|
|
174
|
+
begin
|
|
175
|
+
finish(impl, ctx, settings, result, stats, quiet: quiet, output: output, &block)
|
|
176
|
+
ensure
|
|
177
|
+
impl.run_result_free(ctx, result)
|
|
178
|
+
end
|
|
179
|
+
ensure
|
|
180
|
+
# hegel_run_free only marks an in-progress case complete; per the
|
|
181
|
+
# header it does not free the test-case handle itself. That
|
|
182
|
+
# handle is this loop's own to release, which #run_case does via
|
|
183
|
+
# #with_test_case's own `ensure` on every path, including a fatal
|
|
184
|
+
# exception raised from inside #drive.
|
|
185
|
+
impl.run_free(ctx, run)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Pulls test cases from +run+ until hegel_next_test_case reports none
|
|
190
|
+
# left (a nil out-parameter, not an error). Never counts iterations
|
|
191
|
+
# itself: test_cases bounds generation, not how many times shrinking
|
|
192
|
+
# calls the body afterwards. Measured against libhegel 0.32.5, a run
|
|
193
|
+
# configured for 20 test cases whose body always failed took 1003
|
|
194
|
+
# iterations. +stats+ does its own, different counting -- see
|
|
195
|
+
# GenerationStats above.
|
|
196
|
+
def drive(impl, ctx, run, stats, &block)
|
|
197
|
+
loop do
|
|
198
|
+
tc = impl.next_test_case(ctx, run)
|
|
199
|
+
break if tc.nil?
|
|
200
|
+
|
|
201
|
+
run_case(impl, ctx, tc, stats, &block)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Builds a Hegel::TestCase for +tc+ and yields it, then frees what it
|
|
206
|
+
# opened: every pool it recorded (Hegel::TestCase#free_pools), then the
|
|
207
|
+
# test-case handle itself, in that order, whether the block returns or
|
|
208
|
+
# raises. #run_case, #replay_failure, and #reproduce each need exactly
|
|
209
|
+
# this shape around their own call to #classify -- docs/adr/0011 decides
|
|
210
|
+
# a test case owns every pool built from it, and that pools free before
|
|
211
|
+
# the handle that owns them goes.
|
|
212
|
+
#
|
|
213
|
+
# The nested `ensure` is what keeps those two releases independent. A
|
|
214
|
+
# raise out of #free_pools would otherwise carry past the handle's own
|
|
215
|
+
# release, and hegel_context_free requires every handle taken from the
|
|
216
|
+
# context to be freed first, so one skipped release does not stop at one
|
|
217
|
+
# leak -- it fails the context's release too, at the end of a run that
|
|
218
|
+
# had already gone wrong enough to raise in here.
|
|
219
|
+
def with_test_case(impl, ctx, tc, record: false)
|
|
220
|
+
test_case = TestCase.new(impl, ctx, tc, record: record)
|
|
221
|
+
yield test_case
|
|
222
|
+
ensure
|
|
223
|
+
begin
|
|
224
|
+
test_case.free_pools
|
|
225
|
+
ensure
|
|
226
|
+
impl.test_case_free(ctx, tc)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Runs +block+ against one test-case handle, classifies the outcome,
|
|
231
|
+
# counts it into +stats+, and reports it with hegel_mark_complete. A
|
|
232
|
+
# fatal exception (#classify re-raises those before returning) skips
|
|
233
|
+
# both entirely and still reaches #with_test_case's own `ensure`, so the
|
|
234
|
+
# handle is freed either way; its owner is this loop, not hegel_run_free
|
|
235
|
+
# (see #run_and_finish's comment above).
|
|
236
|
+
def run_case(impl, ctx, tc, stats, &block)
|
|
237
|
+
with_test_case(impl, ctx, tc) do |test_case|
|
|
238
|
+
status, origin = classify(test_case, &block)
|
|
239
|
+
stats.record(status, origin)
|
|
240
|
+
impl.mark_complete(ctx, tc, status, origin)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Reads the finished run's status and acts on it. PASSED returns nil,
|
|
245
|
+
# ERROR raises Hegel::Error from hegel_run_result_error's message, and
|
|
246
|
+
# FAILED hands off to #replay. Any other status would mean this binding
|
|
247
|
+
# does not recognise a hegel_run_status_t value the loaded engine
|
|
248
|
+
# returned, mirroring how LibHegel.check! names an unrecognised result
|
|
249
|
+
# code instead of silently doing nothing with it.
|
|
250
|
+
def finish(impl, ctx, settings, result, stats, quiet:, output:, &block)
|
|
251
|
+
status = impl.run_result_status(ctx, result)
|
|
252
|
+
case status
|
|
253
|
+
when LibHegel::HEGEL_RUN_STATUS_PASSED
|
|
254
|
+
nil
|
|
255
|
+
when LibHegel::HEGEL_RUN_STATUS_ERROR
|
|
256
|
+
raise Hegel::Error, impl.run_result_error(ctx, result) || UNKNOWN_RUN_ERROR_MESSAGE
|
|
257
|
+
when LibHegel::HEGEL_RUN_STATUS_FAILED
|
|
258
|
+
replay(impl, ctx, settings, result, stats, quiet: quiet, output: output, &block)
|
|
259
|
+
else
|
|
260
|
+
raise Hegel::Error, "hegel: run finished with an unrecognized status (#{status})"
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Replays every recorded failure by running +block+ again against a test
|
|
265
|
+
# case rebuilt from its reproduction blob, in the same way #run_case
|
|
266
|
+
# classifies and completes a live one. Writes the report for every
|
|
267
|
+
# failure it collected to +output+ (unless +quiet+), then raises: one
|
|
268
|
+
# failure re-raises its own kept exception, unaltered; two or more raise
|
|
269
|
+
# Hegel::Error with #multiple_failures_message instead, since no single
|
|
270
|
+
# one of several kept exceptions is more the run's own verdict than
|
|
271
|
+
# another. hegel-rust's own src/run_lifecycle.rs (the
|
|
272
|
+
# HEGEL_RUN_STATUS_FAILED branch) makes the same choice: one panic
|
|
273
|
+
# re-raised as itself, several replaced by one panic carrying just the
|
|
274
|
+
# count.
|
|
275
|
+
#
|
|
276
|
+
# #finish only reaches here on a FAILED run, and a FAILED run this
|
|
277
|
+
# binding has actually seen always carries at least one failure to
|
|
278
|
+
# iterate below, so neither raise needs a zero-failures guard: that
|
|
279
|
+
# branch would need a run this binding has never observed to reach it.
|
|
280
|
+
# The report is written before either raise, not after: a host
|
|
281
|
+
# framework that catches the re-raised exception would otherwise read
|
|
282
|
+
# its own output before this one, out of order.
|
|
283
|
+
def replay(impl, ctx, settings, result, stats, quiet:, output:, &block)
|
|
284
|
+
kept_exception = nil
|
|
285
|
+
failures = []
|
|
286
|
+
impl.run_result_failure_count(ctx, result).times do |index|
|
|
287
|
+
failure = impl.run_result_failure(ctx, result, index)
|
|
288
|
+
begin
|
|
289
|
+
kept_exception, report = replay_failure(impl, ctx, settings, failure, index, stats, &block)
|
|
290
|
+
failures << report
|
|
291
|
+
ensure
|
|
292
|
+
impl.failure_free(ctx, failure)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
output.puts(Report.render(failures)) unless quiet
|
|
296
|
+
raise Hegel::Error, multiple_failures_message(failures.size) if failures.size > 1
|
|
297
|
+
|
|
298
|
+
raise kept_exception
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Rebuilds one failure's test case from its reproduction blob and runs
|
|
302
|
+
# +block+ against it, through the same #classify used by the live loop,
|
|
303
|
+
# recording its entries for the report (see Hegel::TestCase). Returns
|
|
304
|
+
# [exception, Hegel::Report::Failure] on success.
|
|
305
|
+
#
|
|
306
|
+
# Flaky means any outcome other than INTERESTING, which is broader than
|
|
307
|
+
# "the body did not raise": #classify's other two non-INTERESTING
|
|
308
|
+
# outcomes both matter here. A body that raises nothing
|
|
309
|
+
# on replay is the textbook flaky case. A blob whose choices no longer
|
|
310
|
+
# match the caller's generators is the other one: the header puts that
|
|
311
|
+
# at "the draw that overruns", so the body raises Hegel::StopTest and
|
|
312
|
+
# #classify turns it into OVERRUN. Re-raising either as its original
|
|
313
|
+
# class would leak a control exception meant only for code driving a
|
|
314
|
+
# test case into the host test framework. This classify-then-check
|
|
315
|
+
# step exists to prevent exactly that.
|
|
316
|
+
def replay_failure(impl, ctx, settings, failure, index, stats, &block)
|
|
317
|
+
blob = impl.failure_reproduction_blob(ctx, failure)
|
|
318
|
+
raise Hegel::Error, "hegel: failure #{index} has no reproduction blob" if blob.nil?
|
|
319
|
+
|
|
320
|
+
tc = build_replay_case(impl, ctx, settings, blob)
|
|
321
|
+
with_test_case(impl, ctx, tc, record: true) do |test_case|
|
|
322
|
+
status, origin, exception, entries = classify(test_case, &block)
|
|
323
|
+
raise Hegel::Error, flaky_message unless status == LibHegel::HEGEL_STATUS_INTERESTING
|
|
324
|
+
|
|
325
|
+
# The libhegel reference documents blob replay as ended by the
|
|
326
|
+
# caller's own hegel_mark_complete. Skipping it might not crash
|
|
327
|
+
# hegel_test_case_free, but not crashing is not the same as correct.
|
|
328
|
+
impl.mark_complete(ctx, tc, status, origin)
|
|
329
|
+
test_cases, discarded = stats.for(origin)
|
|
330
|
+
[exception, Report::Failure.new(test_cases: test_cases, discarded: discarded, entries: entries, blob: blob)]
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Hegel.test(reproduce_failure:)'s own path: starts no run loop, and
|
|
335
|
+
# replays exactly one case built from +blob+, through the same
|
|
336
|
+
# classify-then-check contract #replay_failure uses. That case is
|
|
337
|
+
# already the "final" replay a report needs, so recording is on for it
|
|
338
|
+
# -- unconditionally 1 test case, 0 discarded, since a body that raised
|
|
339
|
+
# Hegel::AssumeFailed here would already have failed the status check
|
|
340
|
+
# below before either count could matter.
|
|
341
|
+
#
|
|
342
|
+
# Builds the replay's test case, converting a control exception raised
|
|
343
|
+
# while building it into an ordinary error.
|
|
344
|
+
#
|
|
345
|
+
# The header attributes HEGEL_E_STOP_TEST to "the draw that overruns"
|
|
346
|
+
# rather than to this call, and replaying a two-draw blob against a
|
|
347
|
+
# five-draw body does build a case and then overrun at a draw, measured
|
|
348
|
+
# against 0.32.5. This guard is therefore defence rather than a
|
|
349
|
+
# documented path: a control exception escaping into the caller's test
|
|
350
|
+
# is the one outcome this whole design exists to prevent, and both
|
|
351
|
+
# replay paths go through here so neither can grow the hole
|
|
352
|
+
# independently.
|
|
353
|
+
def build_replay_case(impl, ctx, settings, blob)
|
|
354
|
+
impl.test_case_from_blob(ctx, settings, blob)
|
|
355
|
+
rescue Hegel::StopTest
|
|
356
|
+
raise Hegel::Error, flaky_message
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def reproduce(impl, ctx, settings, blob, quiet:, output:, &block)
|
|
360
|
+
tc = build_replay_case(impl, ctx, settings, blob)
|
|
361
|
+
with_test_case(impl, ctx, tc, record: true) do |test_case|
|
|
362
|
+
status, origin, exception, entries = classify(test_case, &block)
|
|
363
|
+
raise Hegel::Error, flaky_message unless status == LibHegel::HEGEL_STATUS_INTERESTING
|
|
364
|
+
|
|
365
|
+
impl.mark_complete(ctx, tc, status, origin)
|
|
366
|
+
report = Report::Failure.new(test_cases: 1, discarded: 0, entries: entries, blob: blob)
|
|
367
|
+
output.puts(Report.render([report])) unless quiet
|
|
368
|
+
raise exception
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Runs +block+ against +test_case+ (a Hegel::TestCase #with_test_case
|
|
373
|
+
# already built, at whatever +record:+ it was given) and classifies the
|
|
374
|
+
# outcome into the [hegel_status_t, origin, exception, entries]
|
|
375
|
+
# #run_case, #replay_failure, and #reproduce all need. +entries+ is only
|
|
376
|
+
# ever non-nil when +test_case+ was built to record and the outcome was
|
|
377
|
+
# INTERESTING, since that is the only combination any caller here reads
|
|
378
|
+
# it for. Order matters: a fatal exception must be re-raised before it
|
|
379
|
+
# reaches the library's own control exceptions, which must themselves be
|
|
380
|
+
# told apart from an ordinary exception before the catch-all below.
|
|
381
|
+
#
|
|
382
|
+
# standard:disable Lint/RescueException -- `rescue Exception` is
|
|
383
|
+
# deliberate: Minitest::Assertion and RSpec's ExpectationNotMetError both
|
|
384
|
+
# descend from Exception, not StandardError, so a narrower rescue would
|
|
385
|
+
# let a failing assertion pass through this loop uncaught instead of
|
|
386
|
+
# being reported as a Hegel failure.
|
|
387
|
+
def classify(test_case, &block)
|
|
388
|
+
block.call(test_case)
|
|
389
|
+
[LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
|
|
390
|
+
rescue *Hegel::FATAL_EXCEPTIONS
|
|
391
|
+
raise
|
|
392
|
+
rescue Hegel::AssumeFailed
|
|
393
|
+
[LibHegel::HEGEL_STATUS_INVALID, nil, nil, nil]
|
|
394
|
+
rescue Hegel::StopTest
|
|
395
|
+
[LibHegel::HEGEL_STATUS_OVERRUN, nil, nil, nil]
|
|
396
|
+
rescue Exception => e
|
|
397
|
+
[LibHegel::HEGEL_STATUS_INTERESTING, origin_for(e), e, test_case.entries]
|
|
398
|
+
end
|
|
399
|
+
# standard:enable Lint/RescueException
|
|
400
|
+
|
|
401
|
+
# Builds a stable origin string from where +exception+ was raised, the
|
|
402
|
+
# same information hegel-rust's own "Panic at {location}" origin uses.
|
|
403
|
+
# The exception's class is deliberately left out: two failures raised at
|
|
404
|
+
# the same line are the same bug even if the raised class differs run to
|
|
405
|
+
# run (e.g. a NoMethodError on one nil and a TypeError on another), and
|
|
406
|
+
# hegel_mark_complete's header is explicit that origin is what groups
|
|
407
|
+
# failures for shrinking.
|
|
408
|
+
#
|
|
409
|
+
# The first frame is not always the right one: an assertion library
|
|
410
|
+
# raises from inside itself, so exception.backtrace_locations.first is a
|
|
411
|
+
# line in minitest or rspec-support, identical for every failing
|
|
412
|
+
# assertion in a suite regardless of which line the caller wrote (see
|
|
413
|
+
# docs/adr/0012). #infrastructure? skips such frames in favor of the
|
|
414
|
+
# first one that belongs to the caller's own code. When every frame is
|
|
415
|
+
# infrastructure, the first frame is used after all: a coarse origin
|
|
416
|
+
# still groups failures consistently, where no origin at all would lose
|
|
417
|
+
# the failure's identity entirely.
|
|
418
|
+
def origin_for(exception)
|
|
419
|
+
locations = exception.backtrace_locations
|
|
420
|
+
location = locations&.find { |candidate| !infrastructure?(candidate.path) } || locations&.first
|
|
421
|
+
location ? "Raised at #{location.path}:#{location.lineno}" : UNKNOWN_ORIGIN
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# True when +path+ belongs to this library, an installed gem, or Ruby's
|
|
425
|
+
# own standard library, rather than to the caller whose test #origin_for
|
|
426
|
+
# is trying to identify. Location, not framework name, is what this
|
|
427
|
+
# checks. A name list needs an entry per framework, and this library is
|
|
428
|
+
# driven from frameworks it does not enumerate; location reaches every
|
|
429
|
+
# one of them with a single rule, because Bundler installs a framework
|
|
430
|
+
# under a gem directory and loads a caller's own path:/git: code from
|
|
431
|
+
# the working copy (docs/adr/0012).
|
|
432
|
+
def infrastructure?(path)
|
|
433
|
+
path.start_with?(LIBRARY_DIR) ||
|
|
434
|
+
INSTALLED_GEM_DIRS.any? { |dir| path.start_with?(dir) } ||
|
|
435
|
+
path.start_with?(STDLIB_DIR)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Mirrors the intent of hegel-rust's FLAKY_DIAGNOSTIC in English, not its
|
|
439
|
+
# exact wording: the same generated data produced a different outcome on
|
|
440
|
+
# replay, which usually means the body depends on something outside
|
|
441
|
+
# libhegel's control.
|
|
442
|
+
def flaky_message
|
|
443
|
+
"hegel: this failure did not reproduce against the same generated data. " \
|
|
444
|
+
"The test body likely depends on state outside libhegel's control, " \
|
|
445
|
+
"such as a global variable, wall-clock time, or an external source of randomness."
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# #replay's own multi-failure raise, and Report.render's multi-failure
|
|
449
|
+
# heading, must stay the same sentence: a caller who greps the printed
|
|
450
|
+
# report for this text should find the same string in the exception it
|
|
451
|
+
# catches. Report.render builds that heading itself rather than calling
|
|
452
|
+
# this method, since Hegel::Report only ever formats data handed to it
|
|
453
|
+
# and does not know how a run ended (see its own class comment); the
|
|
454
|
+
# sentence is duplicated here as the coupling between the two, not
|
|
455
|
+
# factored into a shared helper neither module already depends on.
|
|
456
|
+
# Deliberately carries no "hegel: " prefix, unlike #flaky_message and
|
|
457
|
+
# the other messages this module composes itself (UNKNOWN_RUN_ERROR_
|
|
458
|
+
# MESSAGE, the unrecognized-status message, the no-reproduction-blob
|
|
459
|
+
# message), to stay that same sentence.
|
|
460
|
+
def multiple_failures_message(count)
|
|
461
|
+
"Property-based test failed with #{count} distinct failures."
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "lib_hegel"
|
|
5
|
+
|
|
6
|
+
module Hegel
|
|
7
|
+
# Copies Hegel.test's keyword arguments onto a libhegel settings handle.
|
|
8
|
+
# Most keywords follow one rule: nil means "leave libhegel's own default in
|
|
9
|
+
# place", so a caller of Hegel.test who passes none of them gets exactly
|
|
10
|
+
# the engine's untouched defaults (100 test cases, a random seed, no
|
|
11
|
+
# derandomize, every phase, every health check). +database+ and
|
|
12
|
+
# +database_key+ follow the table #apply_database documents instead, and
|
|
13
|
+
# +report_multiple_failures+ has no nil case at all -- see #apply's own
|
|
14
|
+
# comment for why.
|
|
15
|
+
module Settings
|
|
16
|
+
# Hegel.test's verbosity: values, mapped to hegel.h's hegel_verbosity_t.
|
|
17
|
+
VERBOSITY_CODES = {
|
|
18
|
+
quiet: LibHegel::HEGEL_VERBOSITY_QUIET,
|
|
19
|
+
normal: LibHegel::HEGEL_VERBOSITY_NORMAL,
|
|
20
|
+
verbose: LibHegel::HEGEL_VERBOSITY_VERBOSE,
|
|
21
|
+
debug: LibHegel::HEGEL_VERBOSITY_DEBUG
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Hegel.test's phases: Symbols, mapped to hegel.h's hegel_phase_t. The
|
|
25
|
+
# engine ORs these together itself when every phase is passed; this table
|
|
26
|
+
# only needs one bit per Symbol.
|
|
27
|
+
PHASE_CODES = {
|
|
28
|
+
explicit: LibHegel::HEGEL_PHASE_EXPLICIT,
|
|
29
|
+
reuse: LibHegel::HEGEL_PHASE_REUSE,
|
|
30
|
+
generate: LibHegel::HEGEL_PHASE_GENERATE,
|
|
31
|
+
target: LibHegel::HEGEL_PHASE_TARGET,
|
|
32
|
+
shrink: LibHegel::HEGEL_PHASE_SHRINK
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Hegel.test's suppress_health_check: Symbols, mapped to hegel.h's
|
|
36
|
+
# hegel_health_check_t.
|
|
37
|
+
HEALTH_CHECK_CODES = {
|
|
38
|
+
filter_too_much: LibHegel::HEGEL_HC_FILTER_TOO_MUCH,
|
|
39
|
+
too_slow: LibHegel::HEGEL_HC_TOO_SLOW,
|
|
40
|
+
test_cases_too_large: LibHegel::HEGEL_HC_TEST_CASES_TOO_LARGE,
|
|
41
|
+
large_initial_test_case: LibHegel::HEGEL_HC_LARGE_INITIAL_TEST_CASE
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
module_function
|
|
45
|
+
|
|
46
|
+
# Applies every one of Hegel.test's settings keywords to +settings+ via
|
|
47
|
+
# +impl+. +test_cases+, +seed+, +derandomize+, +verbosity+, +phases+, and
|
|
48
|
+
# +suppress_health_check+ all skip their setter when left nil.
|
|
49
|
+
# +database+/+database_key+ follow #apply_database's own table, called
|
|
50
|
+
# unconditionally since even the nil/nil case has a setter to call (see
|
|
51
|
+
# its comment). +report_multiple_failures+ is called unconditionally too,
|
|
52
|
+
# with no nil case: Hegel.test defaults it to false rather than leaving
|
|
53
|
+
# it nil, so this method never sees nil for it -- see Hegel::Runner.run's
|
|
54
|
+
# own comment for why that default departs from every other keyword's
|
|
55
|
+
# nil-means-engine-default rule. +stateful_step_count+ follows the same
|
|
56
|
+
# nil-means-engine-default rule as +test_cases+/+seed+/and so on: the
|
|
57
|
+
# header documents the engine's own default (50) and requires the value
|
|
58
|
+
# be at least 1, and, like +tc.target+'s own label, that requirement is
|
|
59
|
+
# left to the engine rather than re-checked here.
|
|
60
|
+
def apply(impl, ctx, settings, test_cases:, seed:, derandomize:, verbosity:, database:, database_key:, phases:,
|
|
61
|
+
suppress_health_check:, report_multiple_failures:, stateful_step_count:)
|
|
62
|
+
impl.settings_set_test_cases(ctx, settings, test_cases) unless test_cases.nil?
|
|
63
|
+
impl.settings_set_seed(ctx, settings, seed, true) unless seed.nil?
|
|
64
|
+
impl.settings_set_derandomize(ctx, settings, derandomize) unless derandomize.nil?
|
|
65
|
+
apply_verbosity(impl, ctx, settings, verbosity) unless verbosity.nil?
|
|
66
|
+
apply_database(impl, ctx, settings, database: database, database_key: database_key)
|
|
67
|
+
apply_phases(impl, ctx, settings, phases) unless phases.nil?
|
|
68
|
+
apply_suppress_health_check(impl, ctx, settings, suppress_health_check) unless suppress_health_check.nil?
|
|
69
|
+
impl.settings_set_report_multiple_failures(ctx, settings, report_multiple_failures)
|
|
70
|
+
impl.settings_set_stateful_step_count(ctx, settings, stateful_step_count) unless stateful_step_count.nil?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Split from #apply so the VERBOSITY_CODES lookup (one of several
|
|
74
|
+
# keywords that can fail) is not buried inside the top-level sequence.
|
|
75
|
+
def apply_verbosity(impl, ctx, settings, verbosity)
|
|
76
|
+
code = VERBOSITY_CODES.fetch(verbosity) do
|
|
77
|
+
raise Hegel::Error,
|
|
78
|
+
"hegel: unknown verbosity #{verbosity.inspect}; expected one of #{VERBOSITY_CODES.keys.inspect}"
|
|
79
|
+
end
|
|
80
|
+
impl.settings_set_verbosity(ctx, settings, code)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# docs/adr/0009-turn-the-example-database-on-with-a-key.md decides this
|
|
84
|
+
# table and the reasons behind it; read it before changing this method.
|
|
85
|
+
#
|
|
86
|
+
# database_key: | database: | does
|
|
87
|
+
# nil | nil | settings_set_database(ctx, s, "")
|
|
88
|
+
# nil | String | raises Hegel::Error
|
|
89
|
+
# String | nil | settings_set_database_key(ctx, s, key) only
|
|
90
|
+
# String | String | settings_set_database(ctx, s, database), then settings_set_database_key
|
|
91
|
+
#
|
|
92
|
+
# The nil/nil row calls settings_set_database("") explicitly rather than
|
|
93
|
+
# leaving it uncalled, unlike every other nil-means-default keyword here:
|
|
94
|
+
# the ADR measured that an unkeyed run writes nothing even with the
|
|
95
|
+
# engine's own default path left in place, but that is behaviour this
|
|
96
|
+
# project measured against one libhegel build, not a promise the header
|
|
97
|
+
# makes, and the cost of relying on it being wrong is a directory
|
|
98
|
+
# appearing in a caller's working copy that never asked for one.
|
|
99
|
+
def apply_database(impl, ctx, settings, database:, database_key:)
|
|
100
|
+
if database_key.nil?
|
|
101
|
+
unless database.nil?
|
|
102
|
+
raise Hegel::Error,
|
|
103
|
+
"hegel: database: needs database_key: to scope what it stores and replays; " \
|
|
104
|
+
"pass database_key: too, or drop database: and pass neither."
|
|
105
|
+
end
|
|
106
|
+
impl.settings_set_database(ctx, settings, "")
|
|
107
|
+
else
|
|
108
|
+
impl.settings_set_database(ctx, settings, database) unless database.nil?
|
|
109
|
+
impl.settings_set_database_key(ctx, settings, database_key)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Split from #apply so the PHASE_CODES lookup and the OR-together step
|
|
114
|
+
# are not buried inside the top-level sequence, the same reason
|
|
115
|
+
# #apply_verbosity is split out. Raises Hegel::Error for a Symbol not in
|
|
116
|
+
# PHASE_CODES, or for an empty Array: HEGEL_PHASE_* bits are additive
|
|
117
|
+
# (each one turns a phase on), and mask 0 -- what an empty Array would
|
|
118
|
+
# OR together to -- has not been measured against libhegel, unlike
|
|
119
|
+
# dropping a single named phase (see the class-level phases: keyword
|
|
120
|
+
# documentation this backs). Rejecting it here matches
|
|
121
|
+
# #apply_verbosity's own precedent: refuse at the boundary with a
|
|
122
|
+
# message naming the accepted values, rather than pass through a
|
|
123
|
+
# combination nobody has watched the engine handle.
|
|
124
|
+
def apply_phases(impl, ctx, settings, phases)
|
|
125
|
+
mask = mask_for(phases, PHASE_CODES, "phases")
|
|
126
|
+
impl.settings_set_phases(ctx, settings, mask)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Split from #apply for the same reason #apply_phases is. Raises
|
|
130
|
+
# Hegel::Error for a Symbol not in HEALTH_CHECK_CODES, or for an empty
|
|
131
|
+
# Array, aligned with #apply_phases's own empty-Array rule so the two
|
|
132
|
+
# keywords read the same way. The alignment is deliberate even though
|
|
133
|
+
# the two are not symmetric: 0 here is the well-documented default (no
|
|
134
|
+
# suppression), whereas nil already spells that meaning for this
|
|
135
|
+
# keyword -- "no suppression" is nil, and an empty Array is rejected the
|
|
136
|
+
# same way phases: [] is, rather than accepted as a second spelling of
|
|
137
|
+
# nil.
|
|
138
|
+
def apply_suppress_health_check(impl, ctx, settings, checks)
|
|
139
|
+
mask = mask_for(checks, HEALTH_CHECK_CODES, "suppress_health_check")
|
|
140
|
+
impl.settings_set_suppress_health_check(ctx, settings, mask)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Shared by #apply_phases and #apply_suppress_health_check: looks up
|
|
144
|
+
# every Symbol in +values+ against +codes+ and ORs the results together.
|
|
145
|
+
# +keyword+ is the Hegel.test keyword being applied, named in both
|
|
146
|
+
# raised messages so a caller who passes a bad Symbol to either one is
|
|
147
|
+
# told which they got wrong -- the same reason #apply_verbosity's own
|
|
148
|
+
# message says "verbosity". +codes+.keys appears there the same way
|
|
149
|
+
# VERBOSITY_CODES.keys does in that method's.
|
|
150
|
+
def mask_for(values, codes, keyword)
|
|
151
|
+
if values.empty?
|
|
152
|
+
raise Hegel::Error,
|
|
153
|
+
"hegel: #{keyword} expects one or more of #{codes.keys.inspect}, got an empty Array"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
values.reduce(0) do |mask, value|
|
|
157
|
+
code = codes.fetch(value) do
|
|
158
|
+
raise Hegel::Error, "hegel: unknown #{keyword} #{value.inspect}; expected one of #{codes.keys.inspect}"
|
|
159
|
+
end
|
|
160
|
+
mask | code
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|