hegeltest 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +103 -0
- data/README.md +72 -26
- data/Rakefile +81 -0
- data/docs/README.md +10 -0
- data/docs/adr/0014-name-a-drawn-value-only-when-the-draw-is-the-whole-assigned-value.md +74 -0
- data/docs/adr/0015-follow-the-hegeldev-interface-and-take-changes-upstream-first.md +101 -0
- data/docs/adr/0016-run-mutation-testing-with-mutineer.md +87 -0
- data/docs/adr/0017-raise-the-ruby-floor-to-3-4.md +43 -0
- data/docs/adr/0018-gate-mutation-testing-on-a-committed-baseline.md +74 -0
- data/docs/adr/0019-report-failures-from-the-cases-the-engine-stamps.md +70 -0
- data/docs/adr/0020-derive-span-labels-from-generator-names.md +64 -0
- data/docs/adr/0021-run-a-state-machine-in-rounds-with-its-own-step-count.md +59 -0
- data/docs/adr/0022-keep-microsecond-times-over-a-nanosecond-engine.md +45 -0
- data/docs/adr/0023-leave-unset-settings-to-the-engines-profile.md +50 -0
- data/docs/architecture.md +11 -6
- data/lib/hegel/draw_name.rb +24 -8
- data/lib/hegel/generator.rb +50 -9
- data/lib/hegel/generators.rb +134 -102
- data/lib/hegel/lib_hegel/real.rb +193 -94
- data/lib/hegel/lib_hegel.rb +30 -56
- data/lib/hegel/libhegel_version.rb +1 -1
- data/lib/hegel/report.rb +17 -8
- data/lib/hegel/runner.rb +153 -201
- data/lib/hegel/settings.rb +9 -14
- data/lib/hegel/state_machine.rb +15 -8
- data/lib/hegel/stateful/pool.rb +0 -2
- data/lib/hegel/stateful.rb +64 -36
- data/lib/hegel/syntax/methods.rb +3 -2
- data/lib/hegel/test_case.rb +29 -9
- data/lib/hegel/version.rb +1 -1
- data/lib/hegel.rb +11 -17
- data/lib/tasks/libhegel.rake +9 -2
- data/sig/hegel.rbs +60 -61
- data/skills/hegel-ruby/references/ruby/reference.md +147 -52
- metadata +12 -2
data/lib/hegel/runner.rb
CHANGED
|
@@ -9,9 +9,15 @@ require_relative "test_case"
|
|
|
9
9
|
module Hegel
|
|
10
10
|
# Drives one Hegel.test run: this is the Ruby side of the per-test-case
|
|
11
11
|
# lifecycle hegel-rust's src/run_lifecycle.rs calls `drive`. Ruby owns the
|
|
12
|
-
# loop; libhegel owns generation, shrinking, and
|
|
13
|
-
# replay, so a user's test body never has to cross into
|
|
12
|
+
# loop; libhegel owns generation, shrinking, database replay, and the final
|
|
13
|
+
# replay of each failure, so a user's test body never has to cross into
|
|
14
|
+
# native code itself.
|
|
14
15
|
module Runner
|
|
16
|
+
# One failure kept for its origin: the exception the body raised, and
|
|
17
|
+
# the entries the case recorded, or nil when the engine did not stamp
|
|
18
|
+
# the case for capture (see #run_case).
|
|
19
|
+
Capture = Struct.new(:exception, :entries)
|
|
20
|
+
|
|
15
21
|
# #origin_for's fallback when an exception's backtrace has no first
|
|
16
22
|
# location to build a real origin from. Named the way hegel-rust names
|
|
17
23
|
# its own equivalent constant, "Panic at <unknown>", for a panic with no
|
|
@@ -62,9 +68,9 @@ module Hegel
|
|
|
62
68
|
# many of those were discarded) the first time that origin's exception
|
|
63
69
|
# was classified INTERESTING. That snapshot is the failure report's own
|
|
64
70
|
# "Falsified after N test cases (M discarded)" line: the generation
|
|
65
|
-
# phase's counts, not the shrink phase's
|
|
66
|
-
#
|
|
67
|
-
#
|
|
71
|
+
# phase's counts, not the shrink phase's. Measured against libhegel
|
|
72
|
+
# 0.45.0, a run that failed after 2 generated cases called the body 60
|
|
73
|
+
# to 75 times in all, and counting those into N would answer a
|
|
68
74
|
# different question than the report claims to.
|
|
69
75
|
class GenerationStats
|
|
70
76
|
def initialize
|
|
@@ -73,8 +79,7 @@ module Hegel
|
|
|
73
79
|
@snapshots = {}
|
|
74
80
|
end
|
|
75
81
|
|
|
76
|
-
# Called once per case #drive receives
|
|
77
|
-
# once for its own single case).
|
|
82
|
+
# Called once per case #drive receives.
|
|
78
83
|
def record(status, origin)
|
|
79
84
|
@test_cases += 1
|
|
80
85
|
@discarded += 1 if status == LibHegel::HEGEL_STATUS_INVALID
|
|
@@ -82,7 +87,7 @@ module Hegel
|
|
|
82
87
|
end
|
|
83
88
|
|
|
84
89
|
# [test_cases, discarded] as of +origin+'s first INTERESTING
|
|
85
|
-
# appearance. No fallback: every origin #
|
|
90
|
+
# appearance. No fallback: every origin #failure_report asks for here
|
|
86
91
|
# came from a failure hegel_run_result reported, and that failure
|
|
87
92
|
# exists only because this same #record already saw it live.
|
|
88
93
|
def for(origin)
|
|
@@ -94,35 +99,29 @@ module Hegel
|
|
|
94
99
|
|
|
95
100
|
# Runs +block+ as a Hegel property against +impl+, applying +test_cases+,
|
|
96
101
|
# +seed+, +derandomize+, +verbosity+, +database+, +database_key+,
|
|
97
|
-
# +phases+, +suppress_health_check+, +report_multiple_failures
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
# failure -- see #replay).
|
|
102
|
+
# +phases+, +suppress_health_check+, and +report_multiple_failures+ (see
|
|
103
|
+
# Hegel::Settings) to a fresh settings handle first. Returns nil on a
|
|
104
|
+
# passing run, re-raises the exception the smallest failing case's body
|
|
105
|
+
# raised on a failing run, and raises Hegel::Error for a run-level
|
|
106
|
+
# failure (ERROR status, a blob that no longer reproduces, or more than
|
|
107
|
+
# one distinct failure -- see #report_failures).
|
|
104
108
|
#
|
|
105
109
|
# +database+ and +database_key+ follow the table
|
|
106
110
|
# Hegel::Settings.apply_database documents; docs/adr/0009 has the
|
|
107
111
|
# decision and the measurements behind it.
|
|
108
112
|
#
|
|
109
|
-
# +report_multiple_failures+ defaults to false,
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
116
|
-
#
|
|
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".
|
|
113
|
+
# +report_multiple_failures+ defaults to false, the default libhegel,
|
|
114
|
+
# hegel-rust, and hegel-java share. It is passed as false rather than
|
|
115
|
+
# left nil, because hegel-java states why false is right: one failure
|
|
116
|
+
# re-raised unaltered keeps the exception class and the stack trace a
|
|
117
|
+
# debugger reads, which a summary replaces with a count. That is this
|
|
118
|
+
# library's own central promise (#classify re-raises a body's exception
|
|
119
|
+
# with its class and backtrace intact), so a hegel.toml profile or a
|
|
120
|
+
# HEGEL_* variable does not get to turn it off by accident.
|
|
121
121
|
#
|
|
122
|
-
# +reproduce_failure+, when given,
|
|
123
|
-
#
|
|
124
|
-
#
|
|
125
|
-
# bound in that case.
|
|
122
|
+
# +reproduce_failure+, when given, starts the run from that blob instead
|
|
123
|
+
# (see LibHegel::Real#run_start_blob): the engine replays it rather than
|
|
124
|
+
# exploring, and the same loop below drives the replay.
|
|
126
125
|
#
|
|
127
126
|
# +output+ (default $stderr) is where a failure report is written,
|
|
128
127
|
# unless +verbosity+ is :quiet, in which case none is written at all.
|
|
@@ -131,84 +130,72 @@ module Hegel
|
|
|
131
130
|
# `ensure` by the code that opened it, innermost first: hegel_context_free
|
|
132
131
|
# requires every other handle taken from the context to be freed first,
|
|
133
132
|
# 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
133
|
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,
|
|
134
|
+
phases: nil, suppress_health_check: nil, report_multiple_failures: false,
|
|
143
135
|
output: $stderr, reproduce_failure: nil, &block)
|
|
144
136
|
quiet = verbosity == :quiet
|
|
145
137
|
LibHegel.with_context(impl) do |ctx|
|
|
146
138
|
settings = impl.settings_new(ctx)
|
|
147
|
-
begin
|
|
139
|
+
run = begin
|
|
148
140
|
Settings.apply(impl, ctx, settings, test_cases: test_cases, seed: seed, derandomize: derandomize,
|
|
149
141
|
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
|
-
|
|
142
|
+
suppress_health_check: suppress_health_check, report_multiple_failures: report_multiple_failures)
|
|
153
143
|
if reproduce_failure
|
|
154
|
-
|
|
144
|
+
impl.run_start_blob(ctx, settings, reproduce_failure)
|
|
155
145
|
else
|
|
156
|
-
|
|
146
|
+
impl.run_start(ctx, settings)
|
|
157
147
|
end
|
|
158
148
|
ensure
|
|
149
|
+
# The header lets a caller free settings once the run has started:
|
|
150
|
+
# hegel_run_start copies them.
|
|
159
151
|
impl.settings_free(ctx, settings)
|
|
160
152
|
end
|
|
153
|
+
run_and_finish(impl, ctx, run, reproducing: !reproduce_failure.nil?, quiet: quiet, output: output, &block)
|
|
161
154
|
end
|
|
162
155
|
end
|
|
163
156
|
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
run
|
|
169
|
-
begin
|
|
170
|
-
stats = GenerationStats.new
|
|
171
|
-
drive(impl, ctx, run, stats, &block)
|
|
157
|
+
# Drives +run+ and hands its result to #finish.
|
|
158
|
+
def run_and_finish(impl, ctx, run, reproducing:, quiet:, output:, &block)
|
|
159
|
+
stats = GenerationStats.new
|
|
160
|
+
captures = {}
|
|
161
|
+
drive(impl, ctx, run, stats, captures, &block)
|
|
172
162
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
ensure
|
|
177
|
-
impl.run_result_free(ctx, result)
|
|
178
|
-
end
|
|
163
|
+
result = impl.run_result(ctx, run)
|
|
164
|
+
begin
|
|
165
|
+
finish(impl, ctx, result, stats, captures, reproducing: reproducing, quiet: quiet, output: output)
|
|
179
166
|
ensure
|
|
180
|
-
|
|
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)
|
|
167
|
+
impl.run_result_free(ctx, result)
|
|
186
168
|
end
|
|
169
|
+
ensure
|
|
170
|
+
# hegel_run_free only marks an in-progress case complete; per the
|
|
171
|
+
# header it does not free the test-case handle itself. That
|
|
172
|
+
# handle is this loop's own to release, which #run_case does via
|
|
173
|
+
# #with_test_case's own `ensure` on every path, including a fatal
|
|
174
|
+
# exception raised from inside #drive.
|
|
175
|
+
impl.run_free(ctx, run)
|
|
187
176
|
end
|
|
188
177
|
|
|
189
178
|
# Pulls test cases from +run+ until hegel_next_test_case reports none
|
|
190
179
|
# left (a nil out-parameter, not an error). Never counts iterations
|
|
191
180
|
# itself: test_cases bounds generation, not how many times shrinking
|
|
192
|
-
#
|
|
193
|
-
# configured for 20 test cases whose body
|
|
194
|
-
# iterations. +stats+ does its own, different counting
|
|
195
|
-
# GenerationStats above.
|
|
196
|
-
def drive(impl, ctx, run, stats, &block)
|
|
181
|
+
# and rejected cases call the body. Measured against libhegel 0.45.0, a
|
|
182
|
+
# run configured for 20 test cases whose body rejected nearly every case
|
|
183
|
+
# took 1040 to 1537 iterations. +stats+ does its own, different counting
|
|
184
|
+
# -- see GenerationStats above.
|
|
185
|
+
def drive(impl, ctx, run, stats, captures, &block)
|
|
197
186
|
loop do
|
|
198
187
|
tc = impl.next_test_case(ctx, run)
|
|
199
188
|
break if tc.nil?
|
|
200
189
|
|
|
201
|
-
run_case(impl, ctx, tc, stats, &block)
|
|
190
|
+
run_case(impl, ctx, tc, stats, captures, &block)
|
|
202
191
|
end
|
|
203
192
|
end
|
|
204
193
|
|
|
205
194
|
# Builds a Hegel::TestCase for +tc+ and yields it, then frees what it
|
|
206
195
|
# opened: every pool it recorded (Hegel::TestCase#free_pools), then the
|
|
207
196
|
# test-case handle itself, in that order, whether the block returns or
|
|
208
|
-
# raises.
|
|
209
|
-
#
|
|
210
|
-
# a test case owns every pool built from it, and that pools free before
|
|
211
|
-
# the handle that owns them goes.
|
|
197
|
+
# raises. docs/adr/0011 decides a test case owns every pool built from
|
|
198
|
+
# it, and that pools free before the handle that owns them goes.
|
|
212
199
|
#
|
|
213
200
|
# The nested `ensure` is what keeps those two releases independent. A
|
|
214
201
|
# raise out of #free_pools would otherwise carry past the handle's own
|
|
@@ -216,77 +203,97 @@ module Hegel
|
|
|
216
203
|
# context to be freed first, so one skipped release does not stop at one
|
|
217
204
|
# leak -- it fails the context's release too, at the end of a run that
|
|
218
205
|
# had already gone wrong enough to raise in here.
|
|
219
|
-
|
|
220
|
-
|
|
206
|
+
#
|
|
207
|
+
# The case records its draws only when the engine stamped it for
|
|
208
|
+
# capture. The stamp is read in here, inside the `ensure`, so the handle
|
|
209
|
+
# is freed even when reading it raises.
|
|
210
|
+
def with_test_case(impl, ctx, tc)
|
|
211
|
+
test_case = TestCase.new(impl, ctx, tc, record: impl.test_case_should_capture(ctx, tc))
|
|
221
212
|
yield test_case
|
|
222
213
|
ensure
|
|
223
214
|
begin
|
|
224
|
-
test_case
|
|
215
|
+
test_case&.free_pools
|
|
225
216
|
ensure
|
|
226
217
|
impl.test_case_free(ctx, tc)
|
|
227
218
|
end
|
|
228
219
|
end
|
|
229
220
|
|
|
230
221
|
# Runs +block+ against one test-case handle, classifies the outcome,
|
|
231
|
-
# counts it into +stats+,
|
|
232
|
-
#
|
|
233
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
|
|
222
|
+
# counts it into +stats+, keeps a failure in +captures+, and reports the
|
|
223
|
+
# outcome with hegel_mark_complete. Only a stamped case records its
|
|
224
|
+
# draws (see #with_test_case): a stamped case's failure is what a report
|
|
225
|
+
# shows, and naming a drawn value costs a read of the caller's source,
|
|
226
|
+
# which every shrink probe would otherwise pay for. A fatal
|
|
227
|
+
# exception (#classify re-raises those before returning) skips the rest
|
|
228
|
+
# and still reaches #with_test_case's own `ensure`, so the handle is
|
|
229
|
+
# freed either way; its owner is this loop, not hegel_run_free (see
|
|
230
|
+
# #run_and_finish's comment above).
|
|
231
|
+
def run_case(impl, ctx, tc, stats, captures, &block)
|
|
237
232
|
with_test_case(impl, ctx, tc) do |test_case|
|
|
238
|
-
status, origin = classify(test_case, &block)
|
|
233
|
+
status, origin, exception, entries = classify(test_case, &block)
|
|
239
234
|
stats.record(status, origin)
|
|
235
|
+
keep_capture(captures, origin, exception, entries) if status == LibHegel::HEGEL_STATUS_INTERESTING
|
|
240
236
|
impl.mark_complete(ctx, tc, status, origin)
|
|
241
237
|
end
|
|
242
238
|
end
|
|
243
239
|
|
|
240
|
+
# Keeps the newest capture for each origin, except that an unstamped
|
|
241
|
+
# failure (+entries+ nil) never replaces a stamped one. The engine runs
|
|
242
|
+
# every failure it reports once more at the end of the run, stamped, so
|
|
243
|
+
# the last stamped capture is that final replay, and an earlier stamped
|
|
244
|
+
# one only stands in when the final replay was dry. An unstamped capture
|
|
245
|
+
# still gives the run an exception to re-raise when nothing was stamped.
|
|
246
|
+
# hegel-rust ranks its captures the same way: a newer capture replaces
|
|
247
|
+
# an older one of the same rank or lower.
|
|
248
|
+
def keep_capture(captures, origin, exception, entries)
|
|
249
|
+
return if entries.nil? && captures[origin]&.entries
|
|
250
|
+
|
|
251
|
+
captures[origin] = Capture.new(exception, entries)
|
|
252
|
+
end
|
|
253
|
+
|
|
244
254
|
# Reads the finished run's status and acts on it. PASSED returns nil,
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
#
|
|
250
|
-
|
|
255
|
+
# unless the run was replaying a blob, where it means the blob no longer
|
|
256
|
+
# reproduces. ERROR raises Hegel::Error from hegel_run_result_error's
|
|
257
|
+
# message, and FAILED hands off to #report_failures. Any other status
|
|
258
|
+
# would mean this binding does not recognise a hegel_run_status_t value
|
|
259
|
+
# the loaded engine returned, mirroring how LibHegel.check! names an
|
|
260
|
+
# unrecognised result code instead of silently doing nothing with it.
|
|
261
|
+
def finish(impl, ctx, result, stats, captures, reproducing:, quiet:, output:)
|
|
251
262
|
status = impl.run_result_status(ctx, result)
|
|
252
263
|
case status
|
|
253
264
|
when LibHegel::HEGEL_RUN_STATUS_PASSED
|
|
254
|
-
|
|
265
|
+
raise Hegel::Error, not_reproduced_message if reproducing
|
|
255
266
|
when LibHegel::HEGEL_RUN_STATUS_ERROR
|
|
256
267
|
raise Hegel::Error, impl.run_result_error(ctx, result) || UNKNOWN_RUN_ERROR_MESSAGE
|
|
257
268
|
when LibHegel::HEGEL_RUN_STATUS_FAILED
|
|
258
|
-
|
|
269
|
+
report_failures(impl, ctx, result, stats, captures, quiet: quiet, output: output)
|
|
259
270
|
else
|
|
260
271
|
raise Hegel::Error, "hegel: run finished with an unrecognized status (#{status})"
|
|
261
272
|
end
|
|
262
273
|
end
|
|
263
274
|
|
|
264
|
-
#
|
|
265
|
-
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
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
|
+
# Reports every failure the run found from what #run_case captured for
|
|
276
|
+
# its origin, then raises: one failure re-raises its own kept exception,
|
|
277
|
+
# unaltered; two or more raise Hegel::Error with
|
|
278
|
+
# #multiple_failures_message instead, since no single one of several
|
|
279
|
+
# kept exceptions is more the run's own verdict than another.
|
|
280
|
+
# hegel-rust's own src/run_lifecycle.rs (the HEGEL_RUN_STATUS_FAILED
|
|
281
|
+
# branch) makes the same choice: one panic re-raised as itself, several
|
|
282
|
+
# replaced by one panic carrying just the count.
|
|
275
283
|
#
|
|
276
|
-
# #finish only reaches here on a FAILED run, and a FAILED run
|
|
277
|
-
#
|
|
278
|
-
#
|
|
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
|
|
284
|
+
# #finish only reaches here on a FAILED run, and a FAILED run always
|
|
285
|
+
# carries at least one failure, so neither raise needs a zero-failures
|
|
286
|
+
# guard. The report is written before either raise, not after: a host
|
|
281
287
|
# framework that catches the re-raised exception would otherwise read
|
|
282
288
|
# its own output before this one, out of order.
|
|
283
|
-
def
|
|
284
|
-
|
|
289
|
+
def report_failures(impl, ctx, result, stats, captures, quiet:, output:)
|
|
290
|
+
exceptions = []
|
|
285
291
|
failures = []
|
|
286
292
|
impl.run_result_failure_count(ctx, result).times do |index|
|
|
287
293
|
failure = impl.run_result_failure(ctx, result, index)
|
|
288
294
|
begin
|
|
289
|
-
|
|
295
|
+
exception, report = failure_report(impl, ctx, failure, stats, captures)
|
|
296
|
+
exceptions << exception
|
|
290
297
|
failures << report
|
|
291
298
|
ensure
|
|
292
299
|
impl.failure_free(ctx, failure)
|
|
@@ -295,89 +302,34 @@ module Hegel
|
|
|
295
302
|
output.puts(Report.render(failures)) unless quiet
|
|
296
303
|
raise Hegel::Error, multiple_failures_message(failures.size) if failures.size > 1
|
|
297
304
|
|
|
298
|
-
raise
|
|
305
|
+
raise exceptions.first
|
|
299
306
|
end
|
|
300
307
|
|
|
301
|
-
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
304
|
-
#
|
|
305
|
-
#
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
|
308
|
+
# [exception, Hegel::Report::Failure] for one failure. The engine groups
|
|
309
|
+
# a failure under the origin #run_case reported, and #run_case saw every
|
|
310
|
+
# case the run produced, so every failure has a capture under its origin.
|
|
311
|
+
# A capture with no entries came from an unstamped case, whose draws
|
|
312
|
+
# went unrecorded, so its report lists no values.
|
|
313
|
+
def failure_report(impl, ctx, failure, stats, captures)
|
|
314
|
+
origin = impl.failure_origin(ctx, failure)
|
|
315
|
+
capture = captures.fetch(origin)
|
|
316
|
+
test_cases, discarded = stats.for(origin)
|
|
317
|
+
report = Report::Failure.new(
|
|
318
|
+
test_cases: test_cases, discarded: discarded, entries: capture.entries || [],
|
|
319
|
+
blob: impl.failure_reproduction_blob(ctx, failure), caveat: impl.failure_caveat(ctx, failure)
|
|
320
|
+
)
|
|
321
|
+
[capture.exception, report]
|
|
370
322
|
end
|
|
371
323
|
|
|
372
324
|
# Runs +block+ against +test_case+ (a Hegel::TestCase #with_test_case
|
|
373
325
|
# already built, at whatever +record:+ it was given) and classifies the
|
|
374
|
-
# outcome into the [hegel_status_t, origin, exception, entries]
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
#
|
|
326
|
+
# outcome into the [hegel_status_t, origin, exception, entries] #run_case
|
|
327
|
+
# needs. +entries+ is only ever non-nil when +test_case+ was built to
|
|
328
|
+
# record and the outcome was INTERESTING, since that is the only
|
|
329
|
+
# combination #run_case reads it for. Order matters: a fatal exception
|
|
330
|
+
# must be re-raised before it reaches the library's own control
|
|
331
|
+
# exceptions, which must themselves be told apart from an ordinary
|
|
332
|
+
# exception before the catch-all below.
|
|
381
333
|
#
|
|
382
334
|
# standard:disable Lint/RescueException -- `rescue Exception` is
|
|
383
335
|
# deliberate: Minitest::Assertion and RSpec's ExpectationNotMetError both
|
|
@@ -386,6 +338,9 @@ module Hegel
|
|
|
386
338
|
# being reported as a Hegel failure.
|
|
387
339
|
def classify(test_case, &block)
|
|
388
340
|
block.call(test_case)
|
|
341
|
+
# No caller reads a VALID, INVALID, or OVERRUN tuple past its first two
|
|
342
|
+
# elements. .mutineer.yml ignores the mutants of the last two by id, since
|
|
343
|
+
# a line marker would also hide the killed mutant of the second.
|
|
389
344
|
[LibHegel::HEGEL_STATUS_VALID, nil, nil, nil]
|
|
390
345
|
rescue *Hegel::FATAL_EXCEPTIONS
|
|
391
346
|
raise
|
|
@@ -435,17 +390,14 @@ module Hegel
|
|
|
435
390
|
path.start_with?(STDLIB_DIR)
|
|
436
391
|
end
|
|
437
392
|
|
|
438
|
-
#
|
|
439
|
-
#
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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."
|
|
393
|
+
# A replayed blob that never failed. Like hegel-rust's own message for
|
|
394
|
+
# the same outcome, it names both explanations.
|
|
395
|
+
def not_reproduced_message
|
|
396
|
+
"hegel: the blob passed to reproduce_failure did not reproduce a failure. " \
|
|
397
|
+
"Either the bug is fixed, or the test body is nondeterministic and the failure did not recur."
|
|
446
398
|
end
|
|
447
399
|
|
|
448
|
-
# #
|
|
400
|
+
# #report_failures' own multi-failure raise, and Report.render's multi-failure
|
|
449
401
|
# heading, must stay the same sentence: a caller who greps the printed
|
|
450
402
|
# report for this text should find the same string in the exception it
|
|
451
403
|
# catches. Report.render builds that heading itself rather than calling
|
|
@@ -453,10 +405,10 @@ module Hegel
|
|
|
453
405
|
# and does not know how a run ended (see its own class comment); the
|
|
454
406
|
# sentence is duplicated here as the coupling between the two, not
|
|
455
407
|
# factored into a shared helper neither module already depends on.
|
|
456
|
-
# Deliberately carries no "hegel: " prefix, unlike #
|
|
408
|
+
# Deliberately carries no "hegel: " prefix, unlike #not_reproduced_message and
|
|
457
409
|
# the other messages this module composes itself (UNKNOWN_RUN_ERROR_
|
|
458
|
-
# MESSAGE
|
|
459
|
-
#
|
|
410
|
+
# MESSAGE and the unrecognized-status message), to stay that same
|
|
411
|
+
# sentence.
|
|
460
412
|
def multiple_failures_message(count)
|
|
461
413
|
"Property-based test failed with #{count} distinct failures."
|
|
462
414
|
end
|
data/lib/hegel/settings.rb
CHANGED
|
@@ -5,13 +5,13 @@ require_relative "lib_hegel"
|
|
|
5
5
|
|
|
6
6
|
module Hegel
|
|
7
7
|
# Copies Hegel.test's keyword arguments onto a libhegel settings handle.
|
|
8
|
-
# Most keywords follow one rule: nil means "
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
# +database_key+ follow the
|
|
13
|
-
#
|
|
14
|
-
# comment for why.
|
|
8
|
+
# Most keywords follow one rule: nil means "call no setter", so a caller of
|
|
9
|
+
# Hegel.test who passes none of them gets the settings profile the engine
|
|
10
|
+
# resolved: libhegel's own defaults, unless a hegel.toml, a HEGEL_*
|
|
11
|
+
# environment variable, or the engine's ci profile on a CI server says
|
|
12
|
+
# otherwise (see docs/adr/0023). +database+ and +database_key+ follow the
|
|
13
|
+
# table #apply_database documents instead, and +report_multiple_failures+
|
|
14
|
+
# has no nil case at all -- see #apply's own comment for why.
|
|
15
15
|
module Settings
|
|
16
16
|
# Hegel.test's verbosity: values, mapped to hegel.h's hegel_verbosity_t.
|
|
17
17
|
VERBOSITY_CODES = {
|
|
@@ -52,13 +52,9 @@ module Hegel
|
|
|
52
52
|
# with no nil case: Hegel.test defaults it to false rather than leaving
|
|
53
53
|
# it nil, so this method never sees nil for it -- see Hegel::Runner.run's
|
|
54
54
|
# own comment for why that default departs from every other keyword's
|
|
55
|
-
# nil-means-engine-default rule.
|
|
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.
|
|
55
|
+
# nil-means-engine-default rule.
|
|
60
56
|
def apply(impl, ctx, settings, test_cases:, seed:, derandomize:, verbosity:, database:, database_key:, phases:,
|
|
61
|
-
suppress_health_check:, report_multiple_failures
|
|
57
|
+
suppress_health_check:, report_multiple_failures:)
|
|
62
58
|
impl.settings_set_test_cases(ctx, settings, test_cases) unless test_cases.nil?
|
|
63
59
|
impl.settings_set_seed(ctx, settings, seed, true) unless seed.nil?
|
|
64
60
|
impl.settings_set_derandomize(ctx, settings, derandomize) unless derandomize.nil?
|
|
@@ -67,7 +63,6 @@ module Hegel
|
|
|
67
63
|
apply_phases(impl, ctx, settings, phases) unless phases.nil?
|
|
68
64
|
apply_suppress_health_check(impl, ctx, settings, suppress_health_check) unless suppress_health_check.nil?
|
|
69
65
|
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
66
|
end
|
|
72
67
|
|
|
73
68
|
# Split from #apply so the VERBOSITY_CODES lookup (one of several
|
data/lib/hegel/state_machine.rb
CHANGED
|
@@ -23,6 +23,10 @@ module Hegel
|
|
|
23
23
|
# class's.
|
|
24
24
|
include Syntax::Methods
|
|
25
25
|
|
|
26
|
+
# One declared invariant: its block, and whether it runs after every
|
|
27
|
+
# round rather than when the engine samples it.
|
|
28
|
+
Invariant = Data.define(:block, :always_run)
|
|
29
|
+
|
|
26
30
|
class << self
|
|
27
31
|
# Declares a rule named +name+: an action the engine may pick to run
|
|
28
32
|
# at any step. +block+ runs via #instance_exec against the machine
|
|
@@ -33,11 +37,14 @@ module Hegel
|
|
|
33
37
|
declare(:@rules, "rule", name, block)
|
|
34
38
|
end
|
|
35
39
|
|
|
36
|
-
# Declares an invariant named +name+, checked
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
# Declares an invariant named +name+, checked before the first rule
|
|
41
|
+
# runs, after the last, and in between at the join points where the
|
|
42
|
+
# engine samples it: each one with probability 1 / step_count, so
|
|
43
|
+
# about once per full-length test case. +always_run+ checks it at
|
|
44
|
+
# every join point instead, the name hegel-rust and hegel-java give
|
|
45
|
+
# the same option. Same block/argument contract as #rule.
|
|
46
|
+
def invariant(name, always_run: false, &block)
|
|
47
|
+
declare(:@invariants, "invariant", name, Invariant.new(block: block, always_run: always_run))
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# name => block, in declaration order, this class's own declarations
|
|
@@ -47,7 +54,7 @@ module Hegel
|
|
|
47
54
|
merged_definitions(:@rules, :rule_definitions)
|
|
48
55
|
end
|
|
49
56
|
|
|
50
|
-
# The invariant analogue of #rule_definitions.
|
|
57
|
+
# The invariant analogue of #rule_definitions: name => Invariant.
|
|
51
58
|
def invariant_definitions
|
|
52
59
|
merged_definitions(:@invariants, :invariant_definitions)
|
|
53
60
|
end
|
|
@@ -62,12 +69,12 @@ module Hegel
|
|
|
62
69
|
# reads an ancestor's table, so a subclass re-declaring an inherited
|
|
63
70
|
# name is the ordinary "redefine a method" case the ADR calls out,
|
|
64
71
|
# not this one.
|
|
65
|
-
def declare(ivar, kind, name,
|
|
72
|
+
def declare(ivar, kind, name, definition)
|
|
66
73
|
table = instance_variable_get(ivar) || instance_variable_set(ivar, {})
|
|
67
74
|
name = name.to_s
|
|
68
75
|
raise Hegel::Error, "hegel: #{kind} #{name.inspect} is already declared on #{self}" if table.key?(name)
|
|
69
76
|
|
|
70
|
-
table[name] =
|
|
77
|
+
table[name] = definition
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
# Shared by #rule_definitions/#invariant_definitions: this class's own
|
data/lib/hegel/stateful/pool.rb
CHANGED
|
@@ -71,7 +71,6 @@ module Hegel
|
|
|
71
71
|
# of this library's own public generator vocabulary.
|
|
72
72
|
class ValuesReusable < Generator
|
|
73
73
|
def initialize(pool, values)
|
|
74
|
-
super()
|
|
75
74
|
@pool = pool
|
|
76
75
|
@values = values
|
|
77
76
|
end
|
|
@@ -94,7 +93,6 @@ module Hegel
|
|
|
94
93
|
# pre-checking emptiness.
|
|
95
94
|
class ValuesConsumed < Generator
|
|
96
95
|
def initialize(pool, values)
|
|
97
|
-
super()
|
|
98
96
|
@pool = pool
|
|
99
97
|
@values = values
|
|
100
98
|
end
|