1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123(** Test runner and lifecycle management for Hegel.
This module implements the client-side logic for running property-based
tests against the native libhegel engine (via {!Hegel_ffi.Ffi}). It manages:
- Test lifecycle (run_start, test-case loop, mark_complete, run_result)
- The per-test-case handle threaded through the test function
- Helper functions (assume, note, target, the typed generate_* draws)
- Origin extraction for error reporting *)moduleSexp=Sexplib0.SexpmoduleFfi=Hegel_ffi.Ffi(** A string-keyed hashtable module for the draw-name counters. *)moduleString_table=Stdlib.Hashtbl.Make(structtypet=stringletequal=String.equallethash=Stdlib.Hashtbl.hashend)(** Raised when {!assume} condition is [false]. *)exceptionAssume_rejected(** Raised when the engine runs out of choice budget for the current test case
(StopTest). *)exceptionData_exhausted(** Raised when the engine detects a flaky strategy definition or when
the client side pool diverges from the engine side pool. *)exceptionFlaky_strategy(** Health checks that can be suppressed during test execution. *)typehealth_check=|Filter_too_much|Too_slow|Test_cases_too_large|Large_initial_test_case(** [health_check_to_string hc] returns the canonical name for [hc]. *)lethealth_check_to_string=function|Filter_too_much->"filter_too_much"|Too_slow->"too_slow"|Test_cases_too_large->"test_cases_too_large"|Large_initial_test_case->"large_initial_test_case";;(** Controls how much output Hegel produces during test runs. *)typeverbosity=|Quiet|Normal|Verbose|Debug(** The database setting: unset, disabled, or a path. *)typedatabase=|Unset|Disabled|Pathofstring(** Controls the test execution mode. *)typemode=|Test_run(** Run a full property test: many test cases, shrinking, database
replay, all other phases. This is the default. *)|Single_test_case(** Run the test body exactly once, with no shrinking, replay, or
database. Useful when you want pure data generation without
property-testing overhead. *)(** Phases of the test lifecycle. *)typephase=|Explicit|Reuse|Generate|Target|Shrink(** [phase_to_string p] returns the lowercase name for [p]. *)letphase_to_string=function|Explicit->"explicit"|Reuse->"reuse"|Generate->"generate"|Target->"target"|Shrink->"shrink";;(** Configuration for a Hegel test run. *)typesettings={mode:mode;test_cases:int;stateful_step_count:int;verbosity:verbosity;seed:intoption;derandomize:bool;database:database;suppress_health_check:health_checklist;phases:phaselistoption;print_blob:bool;report_multiple_failures:bool}(** Outcome of replaying a single failure blob. *)typereplay=|Undecodableofstring(** the blob could not be decoded; carries the engine's diagnostic *)|Did_not_reproduce(** the blob replayed cleanly — it is stale *)|Reproducedofexn(** the blob re-triggered the original failure *)(** CI environment variables to check for auto-detection. Each entry is
[(var_name, expected_value)] where [None] means "any value". *)letci_vars=["CI",None;"TF_BUILD",Some"true";"BUILDKITE",Some"true";"CIRCLECI",Some"true";"CIRRUS_CI",Some"true";"CODEBUILD_BUILD_ID",None;"GITHUB_ACTIONS",Some"true";"GITLAB_CI",None;"HEROKU_TEST_RUN_ID",None;"TEAMCITY_VERSION",None];;(** [is_in_ci ()] returns [true] if a CI environment is detected. *)letis_in_ci()=List.exists(fun(key,expected)->matchSys.getenv_optkey,expectedwith|Some_,None->true|Somev,Someexp->String.equalvexp|None,_->false)ci_vars;;(** [default_settings ()] creates settings with defaults. Detects CI
environments automatically. *)letdefault_settings()=letin_ci=is_in_ci()in{mode=Test_run;test_cases=100;stateful_step_count=50;verbosity=Normal;seed=None;derandomize=in_ci;database=(ifin_cithenDisabledelseUnset);suppress_health_check=[];phases=None;print_blob=true;report_multiple_failures=false};;(** [settings ?test_cases ?seed ()] creates settings with the given overrides
applied to {!default_settings}. *)letsettings?(test_cases=100)?seed()=lets=default_settings()inlets={swithtest_cases}inmatchseedwith|Somev->{swithseed=Somev}|None->s;;(** [with_test_cases n s] returns settings [s] with [test_cases] set to [n]. *)letwith_test_casesns={swithtest_cases=n}(** [with_stateful_step_count n s] returns settings [s] with [stateful_step_count]
set to [n]. [n] must be at least 1. *)letwith_stateful_step_countns={swithstateful_step_count=n}(** [with_verbosity v s] returns settings [s] with [verbosity] set to [v]. *)letwith_verbosityvs={swithverbosity=v}(** [with_seed seed s] returns settings [s] with [seed] set. *)letwith_seedseeds={swithseed}(** [with_derandomize b s] returns settings [s] with [derandomize] set to [b].
*)letwith_derandomizebs={swithderandomize=b}(** [with_database db s] returns settings [s] with [database] set to [db]. *)letwith_databasedbs={swithdatabase=db}(** [with_suppress_health_check checks s] returns settings [s] with
[suppress_health_check] set to [checks], replacing any previously suppressed
list. *)letwith_suppress_health_checkcheckss={swithsuppress_health_check=checks}(** [with_phases phases s] returns settings [s] with [phases] set. *)letwith_phasesphasess={swithphases=Somephases}(** [with_mode mode s] returns settings [s] with test [mode] set to [mode]. *)letwith_modemodes={swithmode}(** [with_print_blob b s] returns settings [s] with [print_blob] set to [b]. When
[true] (the default), a failing run's report ends with a copy-pasteable
[rerun with:] line encoding the failure. *)letwith_print_blobbs={swithprint_blob=b}(** [with_report_multiple_failures b s] returns settings [s] with [report_multiple_failures]
set to [b]. When [true], a failing run reports all the failures it found *)letwith_report_multiple_failuresbs={swithreport_multiple_failures=b}(** Draw-name bookkeeping: the per-name occurrence counter that numbers
repeatable draws ([label_1], [label_2], …). Shared across every clone of a
test case (see {!clone}) behind [lock], so concurrent clones number
their draws in sequence. [lock] serializes only this frontend accounting. *)typedraw_state={counts:intString_table.t;lock:Mutex.t}(** [new_draw_state ()] is a fresh, unshared draw-name counter with its own lock,
for a test case at the head of a clone family. *)letnew_draw_state()={counts=String_table.create16;lock=Mutex.create()}(** Per-test-case state passed explicitly to the test function. Holds the
native test-case handle, the final-replay flag, whether verbose output is
on, abort state, the current generation-span depth (used to print only the
outermost drawn value), and the {!draw_state} numbering repeatable draws (the
only field shared across a clone family). [note_indent] is the nesting depth
every {!note}/draw line is indented to (two spaces per level). It starts at 1
on the final replay, so the whole body sits inside the framed failure report,
and at 0 otherwise; a caller bumps it further to group sub-output (e.g. the
draws made within a stateful step nest under its [Step N] header).
[printed_output] records whether any note/draw line printed (the report needs
to know whether to separate the body from the exception, and to print that
separator only once). *)typetest_case={handle:Ffi.test_case;context:Ffi.context;is_final:bool;verbosity:verbosity;mutabletest_aborted:bool;mutableprinted_output:bool;mutabledraw_depth:int;mutablenote_indent:int;draw_state:draw_state}(* Accessors so other library modules can read the internal fields they need
without the record being exposed (the type is abstract in the interface). *)letis_high_verbosity(tc:test_case)=matchtc.verbositywith|Debug|Verbose->true|_->false;;letdraw_depth(tc:test_case)=tc.draw_depthletincr_draw_depth(tc:test_case)=tc.draw_depth<-tc.draw_depth+1letdecr_draw_depth(tc:test_case)=tc.draw_depth<-tc.draw_depth-1letset_test_aborted(tc:test_case)v=tc.test_aborted<-v(** [with_note_indent tc f] runs [f], nesting every {!note}/draw line it prints
one level deeper. The depth is restored when [f] raises, so an aborted or
failing step does not over-indent later output. *)letwith_note_indent(tc:test_case)f=tc.note_indent<-tc.note_indent+1;Fun.protect~finally:(fun()->tc.note_indent<-tc.note_indent-1)f;;(** [clone tc] forks a fresh {!test_case} onto an independent choice stream of the
same underlying native test case (see {!Ffi.test_case_clone}), paired with its
own native context so it can be drawn from on another thread concurrently with
[tc]. The clone shares [tc]'s outcome and budget but generates from its own
stream. The {!draw_state} (repeatable-draw numbering) is {e shared} with [tc]
behind its lock, and the span depth and note indent are copied so draws forked
mid-span stay nested. Only the per-stream abort and print flags start fresh,
and the immutable configuration is copied.
The native handle and context are freed by a GC finaliser once the clone is
unreachable, so a clone may be captured and used freely. *)letclone(tc:test_case)=letcontext=Ffi.context_new()inlethandle=Ffi.test_case_clonetc.contexttc.handleinletc={handle;context;is_final=tc.is_final;verbosity=tc.verbosity;test_aborted=false;printed_output=false;draw_depth=tc.draw_depth;note_indent=tc.note_indent;draw_state=tc.draw_state}inStdlib.Gc.finalise_last(fun()->Ffi.test_case_freecontexthandle;Ffi.context_freecontext)c;c;;type'aworker={thread:Thread.t;result:('a,exn)resultref}letspawn(tc:test_case)f=letc=clonetcinletresult=ref(Error(Failure"hegel: worker thread did not complete"))inletthread=Thread.create(fun()->result:=tryOk(fc)with|exn->Errorexn)()in{thread;result};;letjoin(w:'aworker)=Thread.joinw.thread;match!(w.result)with|Okv->v|Errorexn->raiseexn;;(** Domain-local flag to detect nested test cases. *)letin_test_context:boolStdlib.Domain.DLS.key=Stdlib.Domain.DLS.new_key(fun()->false);;(** [extract_origin exn] extracts an InterestingOrigin string from an exception.
Uses the backtrace if available. The origin is derived from the exception
type plus the {e innermost user frame}, so the shrinker
groups probes for the same bug while keeping failures at distinct source
lines apart (see {!Ffi.mark_complete}).
[failwith] and [invalid_arg] raise from within the runtime ([stdlib.ml]),
and [require]/[require_equal] raise from within this file, so the innermost
backtrace slot is not the assertion's true source. Such frames are skipped
so the origin points at the caller's line; without this, every same-typed
exception in a run would collapse to one origin. *)letextract_originexn=letbt=Stdlib.Printexc.get_raw_backtrace()inletis_runtime_filefile=String.ends_withfile~suffix:"stdlib.ml"||String.ends_withfile~suffix:"lib/internal.ml"inletuser_location=matchStdlib.Printexc.backtrace_slotsbtwith|None->None|Someslots->Array.find_map(funslot->matchStdlib.Printexc.Slot.locationslotwith|Some(loc:Stdlib.Printexc.location)whennot(is_runtime_fileloc.filename)->Some(loc.filename,loc.line_number)|_->None)slotsinmatchuser_locationwith|None->Printf.sprintf"%s at :0"(Stdlib.Printexc.exn_slot_nameexn)|Some(file,line)->Printf.sprintf"%s at %s:%d"(Stdlib.Printexc.exn_slot_nameexn)fileline;;(** [with_stop_guard tc f] runs [f ()], translating the engine's per-case abort
signals into the corresponding OCaml exceptions and marking the test case
aborted: {!Ffi.Stop_test} becomes {!Data_exhausted} (choice budget exhausted)
and {!Ffi.Assume_rejected} becomes {!Assume_rejected} (the engine rejected
the case as invalid, e.g. an unsatisfiable uniqueness constraint). *)letwith_stop_guardtcf=tryf()with|Ffi.Stop_test->tc.test_aborted<-true;raiseData_exhausted|Ffi.Assume_rejected->tc.test_aborted<-true;raiseAssume_rejected;;(** [generate_boolean tc p forced] draws a boolean with probability [p] of
[true]. If [forced] is [Some b] the value is forced to [b]. Raises
{!Data_exhausted} on StopTest. *)letgenerate_booleantcpforced=with_stop_guardtc(fun()->Ffi.generate_booleantc.contexttc.handlepforced);;(** [generate_integer tc ~min_value ~max_value] draws an integer in the inclusive
range. Raises {!Data_exhausted} on StopTest. *)letgenerate_integertc~min_value~max_value=with_stop_guardtc(fun()->Ffi.generate_integertc.contexttc.handle~min_value~max_value);;(** [generate_float tc ...] draws a width-64 float under the given policy. Raises
{!Data_exhausted} on StopTest. *)letgenerate_floattc~min_value~max_value~allow_nan~allow_infinity~exclude_min~exclude_max~smallest_nonzero_magnitude=with_stop_guardtc(fun()->Ffi.generate_floattc.contexttc.handle~min_value~max_value~allow_nan~allow_infinity~exclude_min~exclude_max~smallest_nonzero_magnitude);;(** [generate_bytes tc ~min_size ~max_size] draws a byte string. Raises
{!Data_exhausted} on StopTest. *)letgenerate_bytestc~min_size~max_size=with_stop_guardtc(fun()->Ffi.generate_bytestc.contexttc.handle~min_size~max_size);;(** [with_string_generator tc make draw] builds a string-generator handle with
[make tc.context], draws from it with [draw], and always frees the handle.
Raises {!Data_exhausted} on StopTest and {!Assume_rejected} when the draw
rejects itself. *)letwith_string_generatortcmake=with_stop_guardtc(fun()->letsg=maketc.contextinFun.protect~finally:(fun()->Ffi.string_generator_freetc.contextsg)(fun()->Ffi.generate_stringtc.contexttc.handlesg));;(** [generate_text tc ...] draws a text string over the described alphabet. *)letgenerate_texttc~min_size~max_size~codec~min_codepoint~max_codepoint~categories~exclude_categories~include_characters~exclude_characters=with_string_generatortc(functx->Ffi.string_generator_textctx~min_size~max_size~codec~min_codepoint~max_codepoint~categories~exclude_categories~include_characters~exclude_characters);;(** [generate_regex tc ~pattern ~fullmatch] draws a string matching [pattern]. *)letgenerate_regextc~pattern~fullmatch=with_string_generatortc(functx->Ffi.string_generator_regexctx~pattern~fullmatch);;(** [generate_email tc] draws an RFC 5321/5322 email address. *)letgenerate_emailtc=with_string_generatortcFfi.string_generator_email(** [generate_url tc] draws an RFC 3986 http/https URL. *)letgenerate_urltc=with_string_generatortcFfi.string_generator_url(** [generate_domain tc ~max_length] draws an RFC 1035 domain name. *)letgenerate_domaintc~max_length=with_string_generatortc(functx->Ffi.string_generator_domainctx~max_length);;(** [generate_date tc] draws a Gregorian date as [(year, month, day)]. *)letgenerate_datetc=with_stop_guardtc(fun()->Ffi.generate_datetc.contexttc.handle);;(** [generate_time tc] draws a time as [(hour, minute, second, microsecond)]. *)letgenerate_timetc=with_stop_guardtc(fun()->Ffi.generate_timetc.contexttc.handle);;(** [generate_datetime tc] draws a naive datetime as [(date, time)]. *)letgenerate_datetimetc=with_stop_guardtc(fun()->Ffi.generate_datetimetc.contexttc.handle);;(** [generate_ipv4 tc] draws an IPv4 address as its 4 network-order bytes. *)letgenerate_ipv4tc=with_stop_guardtc(fun()->Ffi.generate_ipv4tc.contexttc.handle);;(** [generate_ipv6 tc] draws an IPv6 address as its 16 network-order bytes. *)letgenerate_ipv6tc=with_stop_guardtc(fun()->Ffi.generate_ipv6tc.contexttc.handle);;(* ------------------------------------------------------------------ *)(* ANSI colors *)(* ------------------------------------------------------------------ *)(** ANSI color codes for {!stderr_color} and the default {!render_diff}. *)letansi_red="31"letansi_green="32"(** [color_enabled ~override ~isatty] decides whether ANSI colors are on: an
[override] of ["1"]/["0"] (the [HEGEL_COLOR] variable) forces it on/off;
otherwise follow [isatty]. *)letcolor_enabled~override~isatty=matchoverridewith|Some"1"->true|Some"0"->false|Some_|None->isatty;;(** [stderr_color_enabled ()] is {!color_enabled} for the failure report's
stream: it reads [HEGEL_COLOR] afresh (tests toggle it) and checks whether
stderr is a terminal. *)letstderr_color_enabled()=color_enabled~override:(Sys.getenv_opt"HEGEL_COLOR")~isatty:(Unix.isattyUnix.stderr);;(** [stderr_color code s] wraps [s] in the ANSI SGR [code] when colors are
enabled for stderr (see {!stderr_color_enabled}), else returns [s]
unchanged. *)letstderr_colorcodes=ifstderr_color_enabled()thenPrintf.sprintf"\027[%sm%s\027[0m"codeselses;;(** [assume tc condition] rejects the current test case if [condition] is
[false]. The [tc] handle is accepted for API symmetry with the other
per-test-case primitives; the rejection is client-side (raising
{!Assume_rejected}) and does not consult [tc]. *)letassume_tccondition=ifnotconditionthenraiseAssume_rejected(** [should_print tc] says whether {!note} output is visible for this test
case under the run's {!type:verbosity}: never under [Quiet], only on the
final (failing) replay under [Normal], and on every test case under
[Verbose] or [Debug]. *)letshould_printtc=matchtc.verbositywith|Quiet->false|Normal->tc.is_final|Verbose|Debug->true;;(** [note tc message] prints [message] to stderr subject to {!should_print}.
Inside the framed failure report (the final replay), every line of a
(possibly multi-line) message prints indented. *)letnotetcmessage=ifshould_printtcthen(iftc.note_indent>0&¬tc.printed_outputthenPrintf.eprintf"\n%!";tc.printed_output<-true;letindent=String.make(2*tc.note_indent)' 'inletbody=String.concat("\n"^indent)(String.split_on_char'\n'message)inPrintf.eprintf"%s%s\n%!"indentbody);;(** [require tc ?msg condition] fails the current test case when [condition] is
[false] by raising [Failure msg]. *)letrequire_tc?(msg="require: condition was false")condition=ifnotconditionthenraise(Failuremsg);;(** sexp_diff can set itself as the renderer when it is a dependency *)letdiff_renderer:(colored:bool->original:Sexp.t->updated:Sexp.t->string)optionref=refNone;;(** [set_diff_renderer renderer] sets the renderer {!render_diff} delegates to. *)letset_diff_rendererrenderer=diff_renderer:=renderer(* [render_values_line ~colored ~code ~prefix sexp] renders one side of the
default diff: the value prefixed with [-]/[+], wrapped in the ANSI SGR
[code] when [colored]. *)letrender_values_line~colored~code~prefixsexp=letline=Printf.sprintf"%s %s"prefix(Sexp.to_string_humsexp)inifcoloredthenPrintf.sprintf"\027[%sm%s\027[0m"codelineelseline;;(** [render_diff ~colored ~original ~updated] renders the two differing values.
By default both values print in full ([-] the original, [+] the updated,
red/green when [colored]). When a structural diff renderer is installed
(see {!set_diff_renderer}, it renders the diff instead. *)letrender_diff~colored~original~updated=match!diff_rendererwith|Somerenderer->renderer~colored~original~updated|None->String.concat"\n"[render_values_line~colored~code:ansi_red~prefix:"-"original;render_values_line~colored~code:ansi_green~prefix:"+"updated];;(** [require_equal tc ?msg sexp_of lhs rhs] fails the current test case when
the two values render to different sexps under [sexp_of]. The failure
report's body shows a structural sexp diff of the two values ([-] lines
only in [lhs], [+] lines only in [rhs]; red/green on a terminal) before
[Failure msg] is raised. The diff is only rendered when notes are visible
(see {!should_print}), so shrink probes don't pay for it. *)letrequire_equaltc?(msg="require_equal: values differ")sexp_oflhsrhs=letoriginal=sexp_oflhsinletupdated=sexp_ofrhsinifnot(Sexp.equaloriginalupdated)then(ifshould_printtcthen(letrendered=render_diff~colored:(stderr_color_enabled())~original~updatedinnotetc(Printf.sprintf"%s (- lhs / + rhs):\n%s"msgrendered));raise(Failuremsg));;(** [draw_display_name tc ~label ~repeatable] returns the display name to print
for a drawn value, bumping the occurrence counter for [label]. A [repeatable]
name is numbered on every occurrence ([label_1], [label_2], …), while a
non-repeatable name is printed bare. The counter is shared across test cases. *)letdraw_display_nametc~label~repeatable=letds=tc.draw_stateinletn=Mutex.protectds.lock(fun()->letn=Option.value(String_table.find_optds.countslabel)~default:0+1inString_table.replaceds.countslabeln;n)inifrepeatablethenPrintf.sprintf"%s_%d"labelnelselabel;;(** [target tc value label] records a targeting observation to guide the search
engine toward higher values. *)lettargettcvaluelabel=with_stop_guardtc(fun()->Ffi.targettc.contexttc.handlevaluelabel);;(** [start_span ?label tc] starts a generation span for better shrinking. *)letstart_span?(label=0)tc=iftc.test_abortedthen()elsewith_stop_guardtc(fun()->Ffi.start_spantc.contexttc.handlelabel);;(** [stop_span ?discard tc] ends the current generation span. *)letstop_span?(discard=false)tc=iftc.test_abortedthen()elsewith_stop_guardtc(fun()->Ffi.stop_spantc.contexttc.handlediscard);;(** [new_collection tc ~min_size ~max_size] starts an engine-managed collection
and returns its id. Raises {!Data_exhausted} on StopTest. *)letnew_collectiontc~min_size~max_size=with_stop_guardtc(fun()->Ffi.new_collectiontc.contexttc.handle~min_size~max_size);;(** [collection_more tc ~collection_id] returns whether the engine wants another
element. Raises {!Data_exhausted} on StopTest. *)letcollection_moretc~collection_id=with_stop_guardtc(fun()->Ffi.collection_moretc.contexttc.handlecollection_id);;(** [collection_reject tc ~collection_id] rejects the collection's last element.
Raises {!Data_exhausted} on StopTest. *)letcollection_rejecttc~collection_id=with_stop_guardtc(fun()->Ffi.collection_rejecttc.contexttc.handlecollection_idNone);;(** [new_pool tc] creates a new engine-managed variable pool and returns its id.
*)letnew_pooltc=with_stop_guardtc(fun()->Ffi.new_pooltc.contexttc.handle)(** [pool_add tc ~pool_id] adds a fresh variable to [pool_id] and returns the
new variable id. *)letpool_addtc~pool_id=with_stop_guardtc(fun()->Ffi.pool_addtc.contexttc.handle~pool_id);;(** [pool_generate tc ~pool_id ?consume ()] draws a variable id from [pool_id].
When [consume] is [true], the variable is also removed from the pool.
Drawing from an empty pool raises {!Assume_rejected}. *)letpool_generatetc~pool_id?(consume=false)()=with_stop_guardtc(fun()->Ffi.pool_generatetc.contexttc.handle~pool_id~consume);;(** [new_state_machine tc ~rule_names ~invariant_names] registers an
engine-owned state machine and returns its id. The engine owns rule
selection (including swarm testing). *)letnew_state_machinetc~rule_names~invariant_names=with_stop_guardtc(fun()->Ffi.new_state_machinetc.contexttc.handle~rule_names~invariant_names);;(** [state_machine_next_rule tc ~state_machine_id] draws the index of the next
rule to run, or [None] when the engine's step budget for the test case is
exhausted and the caller should stop running rules. Raises
{!Data_exhausted} when the engine's choice budget is exhausted. *)letstate_machine_next_ruletc~state_machine_id=with_stop_guardtc(fun()->Ffi.state_machine_next_ruletc.contexttc.handle~state_machine_id);;(* ------------------------------------------------------------------ *)(* Settings translation *)(* ------------------------------------------------------------------ *)letffi_mode=function|Test_run->Ffi.Test_run|Single_test_case->Ffi.Single_test_case;;letffi_verbosity=function|Quiet->Ffi.Quiet|Normal->Ffi.Normal|Verbose->Ffi.Verbose|Debug->Ffi.Debug;;letphase_bit=function|Explicit->Ffi.phase_explicit|Reuse->Ffi.phase_reuse|Generate->Ffi.phase_generate|Target->Ffi.phase_target|Shrink->Ffi.phase_shrink;;lethealth_check_bit=function|Filter_too_much->Ffi.hc_filter_too_much|Too_slow->Ffi.hc_too_slow|Test_cases_too_large->Ffi.hc_test_cases_too_large|Large_initial_test_case->Ffi.hc_large_initial_test_case;;letbitmaskbit_ofitems=List.fold_left(funaccx->acclorbit_ofx)0items(** [build_ffi_settings ctx settings ~database_key] allocates and populates a native
settings handle from the OCaml [settings]. The caller must free it. *)letbuild_ffi_settingsctx(settings:settings)~database_key=lets=Ffi.settings_newctxintryFfi.settings_modectxs(ffi_modesettings.mode);Ffi.settings_test_casesctxssettings.test_cases;Ffi.settings_stateful_step_countctxssettings.stateful_step_count;Ffi.settings_verbosityctxs(ffi_verbositysettings.verbosity);Ffi.settings_seedctxssettings.seed;Ffi.settings_derandomizectxssettings.derandomize;Ffi.settings_report_multiple_failuresctxssettings.report_multiple_failures;(matchsettings.databasewith|Unset->()|Disabled->Ffi.settings_databasectxs(Some"")|Pathp->Ffi.settings_databasectxs(Somep));Option.iter(funk->Ffi.settings_database_keyctxs(Somek))database_key;Option.iter(funphases->Ffi.settings_phasesctxs(bitmaskphase_bitphases))settings.phases;(matchsettings.suppress_health_checkwith|[]->()|checks->Ffi.settings_suppress_health_checkctxs(bitmaskhealth_check_bitchecks));swith|e->Ffi.settings_freectxs;raisee;;typecase_outcome={status:Ffi.status;interesting:(string*exn)option;printed_output:bool}(** [run_test_case ~settings ~test_fn ?note_indent ctx handle is_final] runs
[test_fn] over a single native test-case [handle], maps the outcome to a
{!Ffi.status}, and marks the case complete. [note_indent] is the starting
nesting depth of note/draw lines. Shared by the engine-run and failure-blob
replay paths. *)letrun_test_case~(settings:settings)~test_fn?(note_indent=0)ctxhandleis_final=let(tc:test_case)={handle;context=ctx;is_final;verbosity=settings.verbosity;test_aborted=false;printed_output=false;draw_depth=0;note_indent;draw_state=new_draw_state()}inStdlib.Domain.DLS.setin_test_contexttrue;letstatus,captured=matchtest_fntcwith|()->Ffi.Valid,None|exceptionAssume_rejected->Ffi.Invalid,None|exceptionData_exhausted->Ffi.Overrun,None|exceptionFlaky_strategy->Ffi.Invalid,None|exceptionexn->Ffi.Interesting,Some(extract_originexn,exn)inStdlib.Domain.DLS.setin_test_contextfalse;Ffi.mark_completectxhandlestatus(Option.mapfstcaptured);{status;interesting=captured;printed_output=tc.printed_output};;(** Diagnostic raised when the engine's shrunk counterexample no longer fails on
the client-driven final replay: the test produced a different outcome for the
same generated data and is therefore non-deterministic. *)letflaky_diagnostic="Flaky test detected: Your test produced different outcomes when run with the same \
generated data — it failed when it previously succeeded, or succeeded when it \
previously failed. This usually means your test depends on external state such as \
global variables, system time, or external random number generators.";;(** [final_replay ~settings ~ffi_settings ~test_fn ctx failure] performs the
client-owned {e final replay} of one engine-discovered [failure]: a libhegel
run only explores (generation, shrinking) and never replays a counterexample
itself, so the client reads the counterexample's reproduction blob and
replays it as a standalone final test case — re-running the body so its
notes and drawn values print for the minimal example. Returns the blob, the
test's own exception, and whether the replay printed any note/draw line.
The engine just produced the blob, so it always decodes; a replay that no
longer fails means the test is non-deterministic and raises
{!flaky_diagnostic}. *)letfinal_replay~(settings:settings)~ffi_settings~test_fnctxfailure=letblob=Option.get(Ffi.failure_blobctxfailure)inlettc=Ffi.test_case_from_blobctxffi_settings(Someblob)inletoutcome=Fun.protect~finally:(fun()->Ffi.test_case_freectxtc)(fun()->run_test_case~settings~test_fn~note_indent:1ctxtctrue)inmatchoutcome.interestingwith|Some(_origin,exn)->blob,exn,outcome.printed_output|None->raise(Failureflaky_diagnostic);;(** Width the framed failure report's header rule is padded to. *)letframe_width=72letprint_failure_header~cases_run~cases_discardedtest_location=lettitle=matchtest_locationwith|None->"Failure"|Some(loc:Antithesis.test_location)->Printf.sprintf"Failure: %s (%s:%d)"loc.function_nameloc.fileloc.begin_lineinletprefix=Printf.sprintf"--- %s "titleinletrule=prefix^String.make(max3(frame_width-String.lengthprefix))'-'inPrintf.eprintf"%s\nFalsified after %d test case%s (%d discarded):\n%!"(stderr_coloransi_redrule)cases_run(ifcases_run=1then""else"s")cases_discarded;;letprint_failure_body~(settings:settings)~from_ppx~blob~exn~printed_output=ifprinted_outputthenPrintf.eprintf"\n%!";Printf.eprintf"Exception: %s\n%!"(Stdlib.Printexc.to_stringexn);ifsettings.print_blobtheniffrom_ppxthenPrintf.eprintf"rerun with: [@@failure_blobs [ \"%s\" ]]\n%!"blobelsePrintf.eprintf"rerun with: ~failure_blobs:[ \"%s\" ]\n%!"blob;;lethandle_result~(settings:settings)~ffi_settings~test_fn~test_location~from_ppx~single~single_outcome~cases_run~cases_discardedctxresult=letemit~passed=Option.iter(funloc->Antithesis.emit_assertionloc~passed)test_locationinmatchFfi.result_statusctxresultwith|Run_passed->emit~passed:true|Run_error->emit~passed:false;raise(Failure(Option.value(Ffi.result_errorctxresult)~default:"hegel: run error (no message)"))|Run_failedwhensingle->emit~passed:false;(* The single emitted case already ran as its own final case; re-raise the
test's own exception. An interesting result always carries one. *)let_origin,exn=Option.getsingle_outcomeinraiseexn|Run_failed->emit~passed:false;(* Failures are caller-owned snapshots, independent of the run result. *)letfailures=Ffi.result_failuresctxresultinFun.protect~finally:(fun()->List.iter(funf->Ffi.failure_freectxf)failures)(fun()->matchfailureswith|[failure]->print_failure_header~cases_run~cases_discardedtest_location;letblob,exn,printed_output=final_replay~settings~ffi_settings~test_fnctxfailureinprint_failure_body~settings~from_ppx~blob~exn~printed_output;raiseexn|failures->letcount=List.lengthfailuresinprint_failure_header~cases_run~cases_discardedtest_location;List.iteri(funifailure->Printf.eprintf"\n%s%!"(stderr_coloransi_red(Printf.sprintf"Failure %d of %d:"(i+1)count));letblob,exn,printed_output=final_replay~settings~ffi_settings~test_fnctxfailureinprint_failure_body~settings~from_ppx~blob~exn~printed_output)failures;raise(Failure(Printf.sprintf"%d failures found!"count)));;(** [run_from_engine ctx ~settings ~ffi_settings ~test_fn ~test_location] drives a
full property run: it starts the engine worker and pulls every scheduled test
case. The engine only explores (generation, shrinking), so every pumped case
is non-final — except in {!Single_test_case} mode, where the one emitted case
is the whole run and is run as final, its outcome kept for the report.
Discovered counterexamples are replayed from their blobs by {!handle_result}.
The engine [run] handle is always freed. *)letrun_from_enginectx~(settings:settings)~ffi_settings~test_fn~test_location~from_ppx=letsingle=matchsettings.modewith|Single_test_case->true|Test_run->falseinletsingle_outcome=refNoneinletseen_interesting=reffalseinletcases_run=ref0inletcases_discarded=ref0inletrun=Ffi.run_startctxffi_settingsinFun.protect~finally:(fun()->Ffi.run_freectxrun)(fun()->letrecloop()=matchFfi.next_test_casectxrunwith|None->()|Somehandle->(* Handles from [next_test_case] are caller-owned; free each once its
case has been marked complete by [run_test_case]. In single mode
the one emitted case is the whole run, so it runs as final. *)Fun.protect~finally:(fun()->Ffi.test_case_freectxhandle)(fun()->letoutcome=run_test_case~settings~test_fnctxhandlesingleinifnot!seen_interestingthen(matchoutcome.statuswith|Ffi.Interesting->incrcases_run;seen_interesting:=true|Ffi.Valid->incrcases_run|Ffi.Invalid|Ffi.Overrun->incrcases_discarded);ifsinglethensingle_outcome:=outcome.interesting);loop()inloop();(* The run result is a caller-owned snapshot, independent of the run. *)letresult=Ffi.run_resultctxruninFun.protect~finally:(fun()->Ffi.run_result_freectxresult)(fun()->handle_result~settings~ffi_settings~test_fn~test_location~from_ppx~single~single_outcome:!single_outcome~cases_run:!cases_run~cases_discarded:!cases_discardedctxresult));;(** [replay_from_blob ~settings ~ffi_settings ~test_fn blob] replays a single failure
[blob] as a standalone, deterministic test case (no engine worker, no
shrinking). A corrupt or version-incompatible blob is reported as
{!Undecodable}. The standalone case is always freed. *)letreplay_from_blob~(settings:settings)~ffi_settings~test_fnblobctx=matchFfi.test_case_from_blobctxffi_settings(Someblob)with|exceptionFfi.Backend_errormsg->Undecodablemsg|tc->Fun.protect~finally:(fun()->Ffi.test_case_freectxtc)(fun()->letoutcome=run_test_case~settings~test_fnctxtctrueinmatchoutcome.interestingwith|None->Did_not_reproduce|Some(_,exn)->Reproducedexn);;(** [run_from_blob ~settings ~ffi_settings ~test_fn blob] replays a failure
[blob] (only the first supplied blob is replayed). A reproducing blob re-raises
the original exception; a stale or undecodable blob raises a clear [Failure].
*)letrun_from_blobctx~(settings:settings)~ffi_settings~test_fnblob=matchreplay_from_blob~settings~ffi_settings~test_fnblobctxwith|Undecodablemsg->raise(Failuremsg)|Did_not_reproduce->raise(Failure"The failure blob did not reproduce an error")|Reproducedexn->Printf.eprintf"The failure blob reproduced an error:\n%!";raiseexn;;(** [run_test ~settings ?test_location ?database_key ?failure_blobs test_fn] runs
a property test using the given settings against the native engine.
With an empty [failure_blobs] (the default) it performs a normal engine run —
generation, shrinking, and database replay. With a non-empty [failure_blobs]
it instead replays the first blob as a standalone deterministic case and
reports whether it reproduced the original failure (subsequent blobs are
ignored); no engine worker, shrinking, or database is involved.
@param test_location
source location of the test, used by the Antithesis integration.
Provided automatically by the [let%hegel_test] PPX. When omitted, no
Antithesis assertion is emitted.
@param from_ppx
[true] when the run is driven by the [let%hegel_test] PPX; only set by the
PPX. Selects the [[@@failure_blobs [...]]] attribute form of the [rerun with:]
hint vs. the [~failure_blobs] argument form a plain caller would use.
@param database_key
optional key scoping persisted/replayed failing examples and, under [derandomize],
the per-test seed. Defaults to the test's [test_location] (as
[file:function_name]) so each [let%hegel_test] gets a stable, distinct
key; pass an explicit key to override. When both are absent, the engine
uses its own default key.
@param failure_blobs
a list of base64 encoded strings (blobs), where each string encodes the choices
made in a failing test run. When the list is nonempty, only the first blob
is decoded and run. The blob is only guaranteed to reproduce a failure within
a specific version of Hegel *)letrun_test~(settings:settings)?test_location?(from_ppx=false)?database_key?(failure_blobs=[])test_fn=ifStdlib.Domain.DLS.getin_test_contextthenfailwith"Cannot nest test cases - already inside a test case";(* Default the database key to the test's identity so each [let%hegel_test]
gets a stable, distinct key: this scopes its persisted corpus and, under
[derandomize], its per-test seed. An explicit [database_key] wins. *)letdatabase_key=matchdatabase_keywith|Some_ask->k|None->Option.map(fun(loc:Antithesis.test_location)->Printf.sprintf"%s:%s"loc.fileloc.function_name)test_locationinletctx=Ffi.context_new()inletffi_settings=build_ffi_settingsctxsettings~database_keyinletrun_body()=matchfailure_blobswith|[]->run_from_enginectx~settings~ffi_settings~test_fn~test_location~from_ppx|blob::_->run_from_blobctx~settings~ffi_settings~test_fnblobinFun.protect~finally:(fun()->Ffi.settings_freectxffi_settings;Ffi.context_freectx)run_body;;(** [run_hegel_test ?settings ?test_location ?database_key ?failure_blobs test_fn]
is {!run_test} with [settings] defaulting to {!default_settings}. This is the
public entry point the [let%hegel_test] PPX targets and is re-exported as
[Hegel.run_hegel_test].
@param database_key
overrides the key scoping this test's persisted corpus and [derandomize]
seed. When omitted it defaults to the test's [test_location] (see
{!run_test}), so each [let%hegel_test] is scoped by its own identity. *)letrun_hegel_test?(settings=default_settings())?test_location?from_ppx?database_key?failure_blobstest_fn=run_test~settings?test_location?from_ppx?database_key?failure_blobstest_fn;;