123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782(*
Manage the storage of test statuses and test results.
We store the following:
- result for the last run of each test: in a hidden folder
- captured output for the last run of each test: in a hidden folder
- expected output for each test: in a persistent folder
We distinguish two levels of "statuses":
- test result: the test result before comparing it to expectations:
* Did it return or raise an exception?
* What output did we capture?
- test status: the test result confronted to our expectations:
* Did the test run at all?
* Does the test result match our expectations?
*)openPrintfopenTesto_utilopenFpath_.Operators(* // / !! *)openPromise.Operators(* >>= *)moduleT=TypesmoduleP=Promise(**************************************************************************)(* Helpers *)(**************************************************************************)(*
Some of these helpers are provided by nice third-party libraries but we're
not using them to minimize dependencies, this being a test framework
that all library authors should be able to use.
*)(* All the data we need to handle the files that contain the captured output
for a test after applying all defaults and options. *)typecapture_paths={(* Human-friendly name: "stdout", "stderr", or "stdxxx" *)standard_name:string;(* Human-friendly name: "stdout" or the basename of user-specified file
path. *)short_name:string;(* None if this is file that holds the leftover logs that are not
checked against expectations but directed to a file nonetheless. *)path_to_expected_output:Fpath.toption;(* Path to the file where the captured output is redirected. *)path_to_output:Fpath.t;}letlist_mapfxs=List.rev_mapfxs|>List.revletlist_result_of_result_list(xs:('a,'b)Result.tlist):('alist,'blist)Result.t=letoks,errs=List.fold_right(funres(oks,errs)->matchreswith|Okx->(x::oks,errs)|Errorx->(oks,x::errs))xs([],[])inmatcherrswith|[]->Okoks|errs->Errorerrsletwith_file_inpathf=ifSys.file_exists!!paththenletic=open_in_bin!!pathinFun.protect~finally:(fun()->close_in_noerric)(fun()->Ok(fic))elseErrorpathletread_filepath:(string,Fpath.t(* missing file *))Result.t=with_file_inpath(funic->really_input_stringic(in_channel_lengthic))leterrmsg_of_missing_file(path:Fpath.t):string=sprintf"Missing or inaccessible file %s"!!pathleterrmsg_of_missing_files(T.Missing_filespaths):string=matchpathswith|[path]->errmsg_of_missing_filepath|paths->sprintf"Missing or inaccessible files: %s"(paths|>List.mapFpath.to_string|>String.concat", ")letread_file_exnpath:string=matchread_filepathwith|Okdata->data|Errorpath->Error.user_error(errmsg_of_missing_filepath)letwith_file_outpathf=letoc=open_out_bin!!pathinFun.protect~finally:(fun()->close_out_noerroc)(fun()->foc)letwrite_filepathdata=with_file_outpath(funoc->output_stringocdata)letremove_filepath=ifSys.file_exists!!paththenSys.remove!!path(**************************************************************************)(* Global settings *)(**************************************************************************)(*
The status workspace is a temporary folder outside of version control.
*)letdefault_status_workspace_root=Fpath.v"_build"/"testo"/"status"(*
The expectation workspace is under version control.
*)letdefault_expectation_workspace_root=Fpath.v"tests"/"snapshots"letnot_initialized()=Error.user_error"The Testo workspace was not initialized properly or at all. This is \
probably a bug in Testo."letalready_initialized()=Error.user_error"Internal error in Testo: there was an attempt to initialize the workspace \
more than once."letmake_late_init()=letvar=refNoneinletget()=match!varwith|None->not_initialized()|Somex->xinletsetvalue=match!varwith|Some_->already_initialized()|None->var:=Somevaluein(get,set)letget_status_workspace,set_status_workspace=make_late_init()letget_expectation_workspace,set_expectation_workspace=make_late_init()letinit_settings?(expectation_workspace_root=default_expectation_workspace_root)?(status_workspace_root=default_status_workspace_root)~project_name()=ifstatus_workspace_root=expectation_workspace_rootthenError.user_error(sprintf{|status_workspace and expectation_workspace must be different folders
but they are both set to the following path:
%s|}!!status_workspace_root);set_status_workspace(status_workspace_root/project_name);set_expectation_workspace(expectation_workspace_root/project_name)letinit_workspace()=Helpers.make_dir_if_not_exists~recursive:true(get_status_workspace());Helpers.make_dir_if_not_exists~recursive:true(get_expectation_workspace())letget_test_status_workspace(test:T.test)=get_status_workspace()/test.idletget_test_expectation_workspace(test:T.test)=get_expectation_workspace()/test.idletname_file_name="name"letget_name_file_path_from_dirdir=dir/name_file_nameletget_name_file_path(test:T.test)=get_name_file_path_from_dir(get_test_expectation_workspacetest)(* This is for reviewing snapshot folders that are no longer associated
with any test because their ID changed or they were removed from the
test suite. *)letwrite_name_file(test:T.test)=letcontents=test.internal_full_name^"\n"inHelpers.write_file(get_name_file_pathtest)contentsletmust_create_expectation_workspace_for_test(test:T.test)=letuses_internal_storage(x:T.checked_output_options)=matchx.expected_output_pathwith|None->true|Some_user_provided_path->falseinmatchtest.checked_outputwith|Ignore_output->false|Stdoutoptions|Stderroptions|Stdxxxoptions->uses_internal_storageoptions|Split_stdout_stderr(options1,options2)->uses_internal_storageoptions1||uses_internal_storageoptions2letinit_expectation_workspacetest=(* Don't create a folder and a 'name' file if no snapshots are going to
be stored there. *)ifmust_create_expectation_workspace_for_testtestthen(Helpers.make_dir_if_not_exists(get_test_expectation_workspacetest);write_name_filetest)letinit_test_workspacetest=Helpers.make_dir_if_not_exists(get_test_status_workspacetest);init_expectation_workspacetest(**************************************************************************)(* Read/write data *)(**************************************************************************)letcorrupted_filepath=Error.user_error(sprintf"Uh oh, the test framework ran into a corrupted file: %S\n\
Remove it and retry."!!path)letget_completion_status_path(test:T.test)=get_test_status_workspacetest/"completion_status"letstring_of_completion_status(x:T.completion_status)=matchxwith|Test_function_returned->"Test_function_returned"|Test_function_raised_an_exception->"Test_function_raised_an_exception"|Test_timeout->"Test_timeout"letcompletion_status_of_stringpathdata:T.completion_status=matchdatawith|"Test_function_returned"->Test_function_returned|"Test_function_raised_an_exception"->Test_function_raised_an_exception|"Test_timeout"->Test_timeout|_->corrupted_filepathletset_completion_status(test:T.test)completion_status=letpath=get_completion_status_pathtestincompletion_status|>string_of_completion_status|>Helpers.write_filepathletget_completion_status(test:T.test):(T.completion_status,Fpath.t(* missing file *))Result.t=letpath=get_completion_status_pathtestinmatchread_filepathwith|Okdata->Ok(completion_status_of_stringpathdata)|Errorpath->Errorpath(* File names used to the test output, possibly after masking the variable
parts. *)letstdout_filename="stdout"letstderr_filename="stderr"letstdxxx_filename="stdxxx"letunchecked_filename="log"(* stdout.orig, stderr.orig, etc. obtained after masking the variable parts
of the test output as specified by the option 'mask_output' function. *)letorig_suffix=".orig"letget_orig_output_suffix(test:T.test)=matchtest.normalizewith|[]->None|_->Someorig_suffixletget_expected_output_path(test:T.test)default_name(options:T.checked_output_options)=matchoptions.expected_output_pathwith|None->get_expectation_workspace()/test.id/default_name|Somepath->pathletshort_name_of_checked_output_optionsdefault_name(options:T.checked_output_options)=matchoptions.expected_output_pathwith|None->default_name|Somepath->Fpath.basenamepathletget_output_path(test:T.test)filename=get_status_workspace()/test.id/filenameletget_exception_path(test:T.test)=get_output_pathtest"exception"letstore_exception(test:T.test)opt_msg=letpath=get_exception_pathtestinmatchopt_msgwith|None->remove_filepath|Somemsg->write_filepathmsgletget_exception(test:T.test)=letpath=get_exception_pathtestinmatchread_filepathwith|Okdata->Somedata|Error_path->None(*
Derive the various file paths related to a given test, but excluding
unchecked output (logs).
*)letcapture_paths_of_test(test:T.test):capture_pathslist=letunchecked_paths={standard_name=unchecked_filename;short_name=unchecked_filename;path_to_expected_output=None;path_to_output=get_output_pathtestunchecked_filename;}inmatchtest.checked_outputwith|Ignore_output->[unchecked_paths]|Stdoutoptions->[{standard_name=stdout_filename;short_name=short_name_of_checked_output_optionsstdout_filenameoptions;path_to_expected_output=Some(get_expected_output_pathteststdout_filenameoptions);path_to_output=get_output_pathteststdout_filename;};unchecked_paths;]|Stderroptions->[{standard_name=stderr_filename;short_name=short_name_of_checked_output_optionsstderr_filenameoptions;path_to_expected_output=Some(get_expected_output_pathteststderr_filenameoptions);path_to_output=get_output_pathteststderr_filename;};unchecked_paths;]|Stdxxxoptions->[{standard_name=stdxxx_filename;short_name=short_name_of_checked_output_optionsstdxxx_filenameoptions;path_to_expected_output=Some(get_expected_output_pathteststdxxx_filenameoptions);path_to_output=get_output_pathteststdxxx_filename;};]|Split_stdout_stderr(stdout_options,stderr_options)->[{standard_name=stdout_filename;short_name=short_name_of_checked_output_optionsstdout_filenamestdout_options;path_to_expected_output=Some(get_expected_output_pathteststdout_filenamestdout_options);path_to_output=get_output_pathteststdout_filename;};{standard_name=stderr_filename;short_name=short_name_of_checked_output_optionsstderr_filenamestderr_options;path_to_expected_output=Some(get_expected_output_pathteststderr_filenamestderr_options);path_to_output=get_output_pathteststderr_filename;};]letdescribe_unchecked_output(output:T.checked_output_kind):stringoption=matchoutputwith|Ignore_output->Some"stdout, stderr"|Stdout_->Some"stderr"|Stderr_->Some"stdout"|Stdxxx_->None|Split_stdout_stderr_->None(* paths to freshly captured output, both checked and unchecked. *)letget_output_paths(paths:capture_pathslist)=paths|>list_map(funx->x.path_to_output)(* paths to freshly captured output, excluding unchecked output (logs). *)letget_checked_output_paths(paths:capture_pathslist)=paths|>List.filter(funx->x.path_to_expected_output<>None)|>get_output_pathsletget_unchecked_output_path(test:T.test)=get_output_pathtestunchecked_filenameletget_output(paths:capture_pathslist)=paths|>get_output_paths|>list_mapread_fileletget_checked_output(paths:capture_pathslist)=paths|>get_checked_output_paths|>list_mapread_fileletget_unchecked_output(test:T.test)=matchdescribe_unchecked_outputtest.checked_outputwith|Somelog_description->(letpath=get_unchecked_output_pathtestinmatchread_filepathwith|Okdata->Some(log_description,data)|Error_cant_read_file->None)|None->Noneletget_expected_output_paths(paths:capture_pathslist)=paths|>List.filter_map(funx->x.path_to_expected_output)letget_expected_output(paths:capture_pathslist)=paths|>get_expected_output_paths|>list_mapread_fileletset_expected_output(test:T.test)(capture_paths:capture_pathslist)(data:stringlist)=letpaths=capture_paths|>get_expected_output_pathsinifList.lengthdata<>List.lengthpathsthenError.invalid_arg~__LOC__(sprintf"Store.set_expected_output: test %s, data:%i, paths:%i"test.name(List.lengthdata)(List.lengthpaths))else(init_expectation_workspacetest;List.iter2(funpathdata->Helpers.write_filepathdata)pathsdata)letclear_expected_output(test:T.test)=test|>capture_paths_of_test|>List.iter(funx->Option.iterremove_filex.path_to_expected_output)letread_name_file~dir=letname_file_path=get_name_file_path_from_dirdirinifSys.file_exists!!name_file_paththenletcontents=Helpers.read_filename_file_pathinletlen=String.lengthcontentsiniflen>0&&contents.[len-1]='\n'thenSome(String.subcontents0(len-1))else(* malformed contents: must be LF-terminated *)Noneelse(* missing file *)Nonetypedead_snapshot={dir_or_junk_file:Fpath.t;test_name:stringoption}(*
Identify snapshot folders (expected output) that don't belong to any
test in the current test suite.
*)letfind_dead_snapshotstests:dead_snapshotlist=letfolder=get_expectation_workspace()inletnames=Helpers.list_filesfolderinletnames_tbl=Hashtbl.create1000inList.iter(funname->Hashtbl.replacenames_tblname())names;List.iter(fun(test:T.test)->Hashtbl.removenames_tbltest.id)tests;letunknown_names=List.filter(Hashtbl.memnames_tbl)namesinList.filter_map(funname->letdir=folder/nameinlettest_name,is_empty=matchread_name_file~dirwith|None->(None,false)|Some_astest_name->letother_data_files=dir|>Helpers.list_files|>List.filter(funfname->fname<>name_file_name)in(test_name,other_data_files=[])inifis_emptythen((* remove silently a folder that contains no critical data *)Helpers.remove_file_or_dirdir;None)elseSome{dir_or_junk_file=dir;test_name})unknown_namesletremove_dead_snapshot(x:dead_snapshot)=Helpers.remove_file_or_dirx.dir_or_junk_file(**************************************************************************)(* Output redirection *)(**************************************************************************)(* Redirect e.g. stderr to stdout during the execution of the function func.
Usage:
with_redirect [Unix.stderr] Unix.stdout do_something
redirects stderr to stdout while running do_something.
with_redirect [Unix.stderr; Unix.stdout] fd do_something
redirects both stderr and stdout to fd while running do_something.
*)letwith_redirect_fds~(from:Unix.file_descrlist)~to_func()=(* keep the original file descriptors (fds) alive *)letoriginals=List.mapUnix.dupfrominP.protect~finally:(fun()->List.iterUnix.closeoriginals;P.return())(fun()->(* redirect all fds in [from] to the fd [to_] *)List.iter(Unix.dup2to_)from;P.protect~finally:(fun()->(* cancel the redirects by restoring the [from] fds to their
originals *)List.iter2Unix.dup2originalsfrom;P.return())func)(* Redirect file descriptors (e.g., Unix.stdout) to a file *)letwith_redirect_fds_to_filefromfilenamefunc()=letopen_flags:Unix.open_flaglist=[(* Create the file if non-existent *)O_CREAT;(* Truncate the file if it exists *)O_TRUNC;(* Open the file for writing only *)O_WRONLY;(* On Windows, allows deleting the file while it may be open. This matches
POSIX behavior and prevents specious permission errors if users of this
function try to delete [filename]. *)O_SHARE_DELETE;]inletfile=Unix.openfile!!filenameopen_flags0o666inP.protect~finally:(fun()->Unix.closefile;P.return())(with_redirect_fds~from~to_:filefunc)(* Redirect a list of buffered channels to a file. *)letwith_redirects_to_filefromfilenamefunc()=(* Before redirecting, flush all pending writes to the channels *)List.iterflushfrom;letfrom_fds=List.mapUnix.descr_of_out_channelfrominwith_redirect_fds_to_filefrom_fdsfilename(fun()->P.protect~finally:(fun()->(* Before cancelling the redirects, flush all pending writes *)List.iterflushfrom;P.return())func)()(* Redirect a buffered channel to a file. *)letwith_redirect_to_filefromfilenamefunc()=with_redirects_to_file[from]filenamefunc()(* This is offered directly to users. *)letwith_capturefromfunc=Temp_file.with_temp_file~suffix:".out"(funpath->with_redirects_to_file[from]pathfunc()>>=funres->letoutput=read_file_exnpathinP.return(res,output))(* Apply functions to the data as a pipeline, from left to right. *)letcompose_functions_left_to_rightfuncsx=List.fold_left(funxf->fx)xfuncs(* Iff the test is configured to rewrite its output so as to mask the
unpredicable parts, we rewrite the standard output file and we make a
backup of the original. *)letnormalize_output(test:T.test)=matchget_orig_output_suffixtestwith|None->()|Someorig_suffix->letrewrite_string=compose_functions_left_to_righttest.normalizeinletpaths=capture_paths_of_testtestinget_checked_output_pathspaths|>List.iter(funstd_path->letbackup_path=Fpath.v(!!std_path^orig_suffix)inifSys.file_exists!!backup_paththenSys.remove!!backup_path;Sys.rename!!std_path!!backup_path;letorig_data=read_file_exnbackup_pathinletnormalized_data=tryrewrite_stringorig_datawith|e->Error.user_error(sprintf"Exception raised by the test's normalize_output \
function: %s"(Printexc.to_stringe))inHelpers.write_filestd_pathnormalized_data)letwith_redirect_merged_stdout_stderrpathfunc=(* redirect stderr and stdout to a stdxxx file *)with_redirects_to_file[stdout;stderr]pathfuncletwith_output_capture(test:T.test)(func:unit->'unit_promise)=letcapture_paths=capture_paths_of_testtestinletfunc=match(test.checked_output,capture_paths)with|Ignore_output,[log_paths]->with_redirect_merged_stdout_stderrlog_paths.path_to_outputfunc|Stdout_,[paths;log_paths]->with_redirect_to_filestderrlog_paths.path_to_output(with_redirect_to_filestdoutpaths.path_to_outputfunc)|Stderr_,[paths;log_paths]->with_redirect_to_filestdoutlog_paths.path_to_output(with_redirect_to_filestderrpaths.path_to_outputfunc)|Stdxxx_,[paths]->with_redirect_merged_stdout_stderrpaths.path_to_outputfunc|Split_stdout_stderr_,[stdout_paths;stderr_paths]->with_redirect_to_filestdoutstdout_paths.path_to_output(with_redirect_to_filestderrstderr_paths.path_to_outputfunc)|_->(* bug: invalid combination *)Error.assert_false~__LOC__()infun()->P.protectfunc~finally:(fun()->normalize_outputtest;P.return())letwith_completion_status_capture(test:T.test)func:unit->unitPromise.t=fun()->P.catch(fun()->func()>>=funres->set_completion_statustestTest_function_returned;P.returnres)(funetrace->set_completion_statustestTest_function_raised_an_exception;(Printexc.raise_with_backtraceetrace:'unit_promise))(* Subtle: keep this a two-stage invocation:
1. Build the 'func' closures. If there's a bug internal to Testo
such as a missing path, it should be reported at this time
to prevent the error from being caught or swallowed up as part of
the test execution.
2. Run the test by calling the resulting 'func'. This takes place in
a special environment within wrappers. Testo's internal machinery
should run as little as possible at this time to avoid mixing up
Testo's errors with the test's execution.
*)letwith_result_capture(test:T.test)func:unit->unitPromise.t=init_test_workspacetest;letfunc=with_output_capturetestfuncinletfunc=with_completion_status_capturetestfuncinfuncletmark_test_as_timed_out(test:T.test)=set_completion_statustestTest_timeout(**************************************************************************)(* High-level interface *)(**************************************************************************)letcaptured_output_of_data(kind:T.checked_output_kind)(data:stringlist):T.captured_output=match(kind,data)with|Ignore_output,[unchecked]->Ignoredunchecked|Stdout_,[out;unchecked]->Captured_stdout(out,unchecked)|Stderr_,[err;unchecked]->Captured_stderr(err,unchecked)|Stdxxx_,[data]->Captured_mergeddata|Split_stdout_stderr_,[out;err]->Captured_stdout_stderr(out,err)|(Ignore_output|Stdout_|Stderr_|Stdxxx_|Split_stdout_stderr_),_->Error.assert_false~__LOC__()letexpected_output_of_data(kind:T.checked_output_kind)(data:stringlist):T.expected_output=match(kind,data)with|Ignore_output,[]->Ignored|Stdout_,[out]->Expected_stdoutout|Stderr_,[err]->Expected_stderrerr|Stdxxx_,[data]->Expected_mergeddata|Split_stdout_stderr_,[out;err]->Expected_stdout_stderr(out,err)|(Ignore_output|Stdout_|Stderr_|Stdxxx_|Split_stdout_stderr_),_->Error.assert_false~__LOC__()letget_expectation(test:T.test)(paths:capture_pathslist):T.expectation=letexpected_output=paths|>get_expected_output|>list_result_of_result_list|>function|Okx->Ok(expected_output_of_datatest.checked_outputx)|Errormissing_files->Error(T.Missing_filesmissing_files)in{expected_outcome=test.expected_outcome;expected_output}letget_result(test:T.test)(paths:capture_pathslist):(T.result,T.missing_files)Result.t=matchget_completion_statustestwith|Errormissing_file->Error(Missing_files[missing_file])|Okcompletion_status->(letopt_captured_output=paths|>get_output|>list_result_of_result_list|>Result.map(captured_output_of_datatest.checked_output)inmatchopt_captured_outputwith|Errormissing_files->Error(Missing_filesmissing_files)|Okcaptured_output->Ok{completion_status;captured_output})letget_status(test:T.test):T.status=letpaths=capture_paths_of_testtestinletexpectation=get_expectationtestpathsinletresult=get_resulttestpathsin{expectation;result}letoutcome_of_pair(completion_status:T.completion_status)(output_matches:bool):T.outcome=matchcompletion_statuswith|Test_function_raised_an_exception->FailedRaised_exception|Test_function_returned->(matchoutput_matcheswith|false->FailedIncorrect_output|true->Succeeded)|Test_timeout->FailedTimeoutletoutcome_of_expectation_and_result(expect:T.expectation)(result:T.result):T.outcome*bool=lethas_expected_output,output_matches=match(expect.expected_output,result.captured_output)with|Okoutput1,output2whenT.equal_checked_outputoutput1output2->(true,true)|Ok_,_->(true,false)|Error_,_->(false,true)inoutcome_of_pairresult.completion_statusoutput_matches,has_expected_outputletstatus_summary_of_status(status:T.status):T.status_summary=matchstatus.resultwith|Errormissing_files->{passing_status=MISSmissing_files;(* These two fields are meaningless *)outcome=FailedRaised_exception;has_expected_output=false;}|Okresult->letexpect=status.expectationinletoutcome,has_expected_output=outcome_of_expectation_and_resultexpectresultinletpassing_status:T.passing_status=matchexpect.expected_outcome,outcomewith|Should_succeed,Succeeded->PASS|Should_succeed,Failedfail_reason->FAILfail_reason|Should_fail_,Succeeded->XPASS|Should_fail_,Failedfail_reason->XFAILfail_reasonin{passing_status;outcome;has_expected_output}letcheck_status_before_approval(test:T.test)=letstatus=get_statustestinletstatus_summary=status_summary_of_statusstatusinmatchstatus_summary.passing_statuswith|PASS|XPASS|FAILIncorrect_output|XFAILIncorrect_output->Ok()|FAILRaised_exception|XFAILRaised_exception->Error(sprintf"Cannot approve test because it raised an exception: %s '%s'"test.idtest.internal_full_name)|FAILTimeout|XFAILTimeout->Error(sprintf"Cannot approve test because it timed out: %s '%s'"test.idtest.internal_full_name)|MISSmissing_files->Error(errmsg_of_missing_filesmissing_files)typechanged=Changed|UnchangedexceptionLocal_errorofstringletapprove_new_output(test:T.test):(changed,string)Result.t=matchtest.skippedwith|Some_reason->OkUnchanged|None->(letpaths=capture_paths_of_testtestinmatchcheck_status_before_approvaltestwith|Error_asres->res|Ok()->(letold_expectation=get_expectationtestpathsinclear_expected_outputtest;tryletdata=paths|>get_checked_output|>list_map(function|Okdata->data|Errormissing_file->raise(Local_error(errmsg_of_missing_filemissing_file)))inset_expected_outputtestpathsdata;letnew_expectation=get_expectationtestpathsinletchanged=ifold_expectation=new_expectationthenUnchangedelseChangedinOkchangedwith|Local_errormsg->Error(sprintf"Cannot approve output for test %s: %s"test.idmsg)))