package testo

  1. Overview
  2. Docs

Source file Store.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
(*
   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?
*)

open Printf
open Testo_util
open Fpath_.Operators (* // / !! *)
open Promise.Operators (* >>= *)
module T = Types
module P = 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.
*)

type capture_kind = Std | File | Log

(* All the data we need to handle the files that contain the captured output
   for a test after applying all defaults and options. *)
type capture_paths = {
  kind : capture_kind;
  (* 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.t option;
  (* Path to the file where the captured output is redirected. *)
  path_to_output : Fpath.t;
}

let list_map f xs = List.rev_map f xs |> List.rev

let list_result_of_result_list (xs : ('a, 'b) Result.t list) :
    ('a list, 'b list) Result.t =
  let oks, errs =
    List.fold_right
      (fun res (oks, errs) ->
        match res with
        | Ok x -> (x :: oks, errs)
        | Error x -> (oks, x :: errs))
      xs ([], [])
  in
  match errs with
  | [] -> Ok oks
  | errs -> Error errs

let with_file_in ?(binary = false) path f =
  if Sys.file_exists !!path then
    let ic = (if binary then open_in_bin else open_in) !!path in
    Fun.protect ~finally:(fun () -> close_in_noerr ic) (fun () -> Ok (f ic))
  else Error path

let read_file ?binary path : (string, Fpath.t (* missing file *)) Result.t =
  with_file_in ?binary path Helpers.input_all

let errmsg_of_missing_file (path : Fpath.t) : string =
  sprintf "Missing or inaccessible file %s" !!path

let errmsg_of_missing_files (T.Missing_files paths) : string =
  match paths with
  | [ path ] -> errmsg_of_missing_file path
  | paths ->
      sprintf "Missing or inaccessible files: %s"
        (paths |> List.map Fpath.to_string |> String.concat ", ")

let read_file_exn ?binary path : string =
  match read_file ?binary path with
  | Ok data -> data
  | Error path -> Error.user_error (errmsg_of_missing_file path)

let with_text_file_out path f =
  let oc = open_out !!path in
  Fun.protect ~finally:(fun () -> close_out_noerr oc) (fun () -> f oc)

let write_text_file path data =
  with_text_file_out path (fun oc -> output_string oc data)

let remove_file path = if Sys.file_exists !!path then Sys.remove !!path

(*
   Keep track of a user-provided relative path and keep it in its original
   form for as a long as the current folder is still the same.

   We use this for the workspace folder paths from which other file paths
   are derived.
*)
type friendly_path = {
  cwd : Fpath.t;
  (* A relative or absolute path *)
  path : Fpath.t;
  (* The absolute path precomputed from the other fields *)
  abs_path : Fpath.t;
}

let create_friendly_path path : friendly_path =
  let cwd = Fpath.v (Sys.getcwd ()) in
  { cwd; path; abs_path = Fpath.(cwd // path) }

(* Return a relative path if the current folder hasn't changed since
   the path was provided. *)
let get_short_path (x : friendly_path) : Fpath.t =
  if Sys.getcwd () = !!(x.cwd) then x.path else x.abs_path

(**************************************************************************)
(* Global settings *)
(**************************************************************************)

(*
   The status workspace is a temporary folder outside of version control.
*)
let default_status_workspace_root = Fpath.v "_build" / "testo" / "status"

(*
   The expectation workspace is under version control.
*)
let default_expectation_workspace_root = Fpath.v "tests" / "snapshots"

let not_initialized () =
  Error.user_error
    "The Testo workspace was not initialized properly or at all. This is \
     probably a bug in Testo."

let already_initialized () =
  Error.user_error
    "Internal error in Testo: there was an attempt to initialize the workspace \
     more than once."

let make_late_init () =
  let var = ref None in
  let get () =
    match !var with
    | None -> not_initialized ()
    | Some x -> x
  in
  let set value =
    match !var with
    | Some _ -> already_initialized ()
    | None -> var := Some value
  in
  (get, set)

let get_full_status_workspace, set_status_workspace = make_late_init ()

let get_full_expectation_workspace, set_expectation_workspace =
  make_late_init ()

(* These functions return the original relative paths when possible
   i.e. if the current folder is the same as when starting the test program. *)
let get_status_workspace () = get_full_status_workspace () |> get_short_path

let get_expectation_workspace () =
  get_full_expectation_workspace () |> get_short_path

let init_settings
    ?(expectation_workspace_root = default_expectation_workspace_root)
    ?(status_workspace_root = default_status_workspace_root) ~project_name () =
  if status_workspace_root = expectation_workspace_root then
    Error.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
    (create_friendly_path (status_workspace_root / project_name));
  set_expectation_workspace
    (create_friendly_path (expectation_workspace_root / project_name))

let init_workspace () =
  Helpers.make_dir_if_not_exists ~recursive:true (get_status_workspace ());
  Helpers.make_dir_if_not_exists ~recursive:true (get_expectation_workspace ())

let get_test_status_workspace (test : T.test) =
  get_status_workspace () / test.id

let get_test_expectation_workspace (test : T.test) =
  get_expectation_workspace () / test.id

let name_file_name = "name"
let get_name_file_path_from_dir dir = dir / name_file_name

let get_name_file_path (test : T.test) =
  get_name_file_path_from_dir (get_test_expectation_workspace test)

(* 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. *)
let write_name_file (test : T.test) =
  let contents = test.internal_full_name ^ "\n" in
  Helpers.write_text_file (get_name_file_path test) contents

let must_create_expectation_workspace_for_test (test : T.test) =
  test.checked_output_files <> []
  ||
  let uses_internal_storage (x : T.checked_output_options) =
    match x.snapshot_path with
    | None -> true
    | Some _user_provided_path -> false
  in
  match test.checked_output with
  | Ignore_output -> false
  | Stdout options
  | Stderr options
  | Stdxxx options ->
      uses_internal_storage options
  | Split_stdout_stderr (options1, options2) ->
      uses_internal_storage options1 || uses_internal_storage options2

let init_expectation_workspace test =
  (* Don't create a folder and a 'name' file if no snapshots are going to
     be stored there. *)
  if must_create_expectation_workspace_for_test test then (
    Helpers.make_dir_if_not_exists (get_test_expectation_workspace test);
    write_name_file test)

let init_status_workspace test =
  Helpers.make_dir_if_not_exists (get_test_status_workspace test)

let init_test_workspace test =
  init_status_workspace test;
  init_expectation_workspace test

(**************************************************************************)
(* Read/write data *)
(**************************************************************************)

let corrupted_file path =
  Error.user_error
    (sprintf
       "Uh oh, the test framework ran into a corrupted file: %S\n\
        Remove it and retry."
       !!path)

let get_completion_status_path (test : T.test) =
  get_test_status_workspace test / "completion_status"

let string_of_completion_status (x : T.completion_status) =
  match x with
  | Test_function_returned -> "Test_function_returned"
  | Test_function_raised_an_exception -> "Test_function_raised_an_exception"
  | Test_timeout -> "Test_timeout"

let completion_status_of_string path data : T.completion_status =
  match data with
  | "Test_function_returned" -> Test_function_returned
  | "Test_function_raised_an_exception" -> Test_function_raised_an_exception
  | "Test_timeout" -> Test_timeout
  | _ -> corrupted_file path

let set_completion_status (test : T.test) completion_status =
  let path = get_completion_status_path test in
  completion_status |> string_of_completion_status
  |> Helpers.write_text_file path

let get_completion_status (test : T.test) :
    (T.completion_status, Fpath.t (* missing file *)) Result.t =
  let path = get_completion_status_path test in
  match read_file path with
  | Ok data -> Ok (completion_status_of_string path data)
  | Error path -> Error path

(* File names used to the test output, possibly after masking the variable
   parts. *)
let stdout_filename = "stdout"
let stderr_filename = "stderr"
let stdxxx_filename = "stdxxx"
let unchecked_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. *)
let orig_suffix = ".orig"

let get_orig_output_suffix (test : T.test) =
  match test.normalize with
  | [] -> None
  | _ -> Some orig_suffix

let get_std_snapshot_path (test : T.test) default_name
    (options : T.checked_output_options) =
  match options.snapshot_path with
  | None -> get_expectation_workspace () / test.id / default_name
  | Some path -> path

let get_file_snapshot_path (test : T.test) (x : T.checked_output_file) =
  match x.options.snapshot_path with
  | None -> get_expectation_workspace () / test.id / ("file-" ^ x.name)
  | Some path -> path

let short_name_of_checked_output_options default_name
    (options : T.checked_output_options) =
  match options.snapshot_path with
  | None -> default_name
  | Some path -> Fpath.basename path

(* This function may be used only for fixed, reserved filenames *)
let get_std_output_path (test : T.test) filename =
  get_status_workspace () / test.id / filename

(* This reserves the "file-" prefix to store all the captured output files
   named by the user *)
let get_output_file_path (test : T.test) (x : T.checked_output_file) =
  get_status_workspace () / test.id / ("file-" ^ x.name)

let get_exception_path (test : T.test) = get_std_output_path test "exception"

let store_exception (test : T.test) opt_msg =
  let path = get_exception_path test in
  match opt_msg with
  | None -> remove_file path
  | Some msg -> write_text_file path msg

let get_exception (test : T.test) =
  let path = get_exception_path test in
  match read_file path with
  | Ok data -> Some data
  | Error _path -> None

(*
   Derive the various file paths related to a given test, but excluding
   unchecked output (logs).
*)
let std_capture_paths_of_test (test : T.test) : capture_paths list =
  let unchecked_paths =
    {
      kind = Log;
      short_name = unchecked_filename;
      path_to_expected_output = None;
      path_to_output = get_std_output_path test unchecked_filename;
    }
  in
  match test.checked_output with
  | Ignore_output -> [ unchecked_paths ]
  | Stdout options ->
      [
        {
          kind = Std;
          short_name =
            short_name_of_checked_output_options stdout_filename options;
          path_to_expected_output =
            Some (get_std_snapshot_path test stdout_filename options);
          path_to_output = get_std_output_path test stdout_filename;
        };
        unchecked_paths;
      ]
  | Stderr options ->
      [
        {
          kind = Std;
          short_name =
            short_name_of_checked_output_options stderr_filename options;
          path_to_expected_output =
            Some (get_std_snapshot_path test stderr_filename options);
          path_to_output = get_std_output_path test stderr_filename;
        };
        unchecked_paths;
      ]
  | Stdxxx options ->
      [
        {
          kind = Std;
          short_name =
            short_name_of_checked_output_options stdxxx_filename options;
          path_to_expected_output =
            Some (get_std_snapshot_path test stdxxx_filename options);
          path_to_output = get_std_output_path test stdxxx_filename;
        };
      ]
  | Split_stdout_stderr (stdout_options, stderr_options) ->
      [
        {
          kind = Std;
          short_name =
            short_name_of_checked_output_options stdout_filename stdout_options;
          path_to_expected_output =
            Some (get_std_snapshot_path test stdout_filename stdout_options);
          path_to_output = get_std_output_path test stdout_filename;
        };
        {
          kind = Std;
          short_name =
            short_name_of_checked_output_options stderr_filename stderr_options;
          path_to_expected_output =
            Some (get_std_snapshot_path test stderr_filename stderr_options);
          path_to_output = get_std_output_path test stderr_filename;
        };
      ]

let file_capture_paths_of_test (test : T.test) : capture_paths list =
  test.checked_output_files
  |> Helpers.list_map (fun (x : T.checked_output_file) ->
         {
           kind = File;
           short_name = x.name;
           path_to_expected_output = Some (get_file_snapshot_path test x);
           path_to_output = get_output_file_path test x;
         })

let all_capture_paths_of_test (test : T.test) =
  std_capture_paths_of_test test @ file_capture_paths_of_test test

let describe_unchecked_output (output : T.checked_output_kind) : string option =
  match output with
  | 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. *)
let get_output_paths (paths : capture_paths list) =
  paths |> list_map (fun x -> x.path_to_output)

(* paths to freshly captured output, excluding unchecked output (logs). *)
let get_checked_output_paths (paths : capture_paths list) =
  paths
  |> List.filter (fun x -> x.path_to_expected_output <> None)
  |> get_output_paths

let get_unchecked_output_path (test : T.test) =
  get_std_output_path test unchecked_filename

let get_output (paths : capture_paths list) =
  paths |> get_output_paths |> list_map read_file

let get_checked_output (paths : capture_paths list) =
  paths |> get_checked_output_paths |> list_map read_file

let get_unchecked_output (test : T.test) =
  match describe_unchecked_output test.checked_output with
  | Some log_description -> (
      let path = get_unchecked_output_path test in
      match read_file path with
      | Ok data -> Some (log_description, data)
      | Error _cant_read_file -> None)
  | None -> None

let get_snapshot_paths (paths : capture_paths list) =
  paths |> List.filter_map (fun x -> x.path_to_expected_output)

let get_expected_output (paths : capture_paths list) =
  paths |> get_snapshot_paths |> list_map read_file

let set_expected_output (test : T.test) (capture_paths : capture_paths list)
    (data : string list) =
  let paths = capture_paths |> get_snapshot_paths in
  if List.length data <> List.length paths then
    Error.invalid_arg ~__LOC__
      (sprintf "Store.set_expected_output: test %s, data:%i, paths:%i" test.name
         (List.length data) (List.length paths))
  else (
    init_expectation_workspace test;
    List.iter2 (fun path data -> Helpers.write_text_file path data) paths data)

let clear_expected_output (test : T.test) =
  test |> std_capture_paths_of_test
  |> List.iter (fun x -> Option.iter remove_file x.path_to_expected_output);
  test.checked_output_files
  |> List.iter (fun (x : T.checked_output_file) ->
         remove_file (get_file_snapshot_path test x))

let read_name_file ~dir =
  let name_file_path = get_name_file_path_from_dir dir in
  if Sys.file_exists !!name_file_path then
    let contents = Helpers.read_text_file name_file_path in
    let len = String.length contents in
    if len > 0 && contents.[len - 1] = '\n' then
      Some (String.sub contents 0 (len - 1))
    else (* malformed contents: must be LF-terminated *)
      None
  else (* missing file *)
    None

type dead_snapshot = { dir_or_junk_file : Fpath.t; test_name : string option }

(*
   Identify snapshot folders (expected output) that don't belong to any
   test in the current test suite.
*)
let find_dead_snapshots tests : dead_snapshot list =
  let folder = get_expectation_workspace () in
  let names = Helpers.list_files folder in
  let names_tbl = Hashtbl.create 1000 in
  List.iter (fun name -> Hashtbl.replace names_tbl name ()) names;
  List.iter (fun (test : T.test) -> Hashtbl.remove names_tbl test.id) tests;
  let unknown_names = List.filter (Hashtbl.mem names_tbl) names in
  List.filter_map
    (fun name ->
      let dir = folder / name in
      let test_name, is_empty =
        match read_name_file ~dir with
        | None -> (None, false)
        | Some _ as test_name ->
            let other_data_files =
              dir |> Helpers.list_files
              |> List.filter (fun fname -> fname <> name_file_name)
            in
            (test_name, other_data_files = [])
      in
      if is_empty then (
        (* remove silently a folder that contains no critical data *)
        Helpers.remove_file_or_dir dir;
        None)
      else Some { dir_or_junk_file = dir; test_name })
    unknown_names

let remove_dead_snapshot (x : dead_snapshot) =
  Helpers.remove_file_or_dir x.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.
*)
let with_redirect_fds ~(from : Unix.file_descr list) ~to_ func () =
  (* keep the original file descriptors (fds) alive *)
  let originals = List.map Unix.dup from in
  P.protect
    ~finally:(fun () ->
      List.iter Unix.close originals;
      P.return ())
    (fun () ->
      (* redirect all fds in [from] to the fd [to_] *)
      List.iter (Unix.dup2 to_) from;
      P.protect
        ~finally:(fun () ->
          (* cancel the redirects by restoring the [from] fds to their
             originals *)
          List.iter2 Unix.dup2 originals from;
          P.return ())
        func)

(* Redirect a list of buffered channels to a file. *)
let with_redirects_to_out_channel from to_oc func () =
  (* Before redirecting, flush all pending writes to the channels *)
  List.iter flush from;
  let from_fds = List.map Unix.descr_of_out_channel from in
  with_redirect_fds ~from:from_fds
    ~to_:(Unix.descr_of_out_channel to_oc)
    (fun () ->
      P.protect
        ~finally:(fun () ->
          (* Before cancelling the redirects, flush all pending writes *)
          List.iter flush from;
          P.return ())
        func)
    ()

let with_open_out path func =
  let oc = open_out !!path in
  Fun.protect (fun () -> func oc) ~finally:(fun () -> close_out_noerr oc)

let with_redirects_to_file from to_file func () =
  with_open_out to_file (fun oc ->
      with_redirects_to_out_channel from oc func ())

(* Redirect a buffered channel to a file. *)
let with_redirect_to_file from filename func () =
  with_redirects_to_file [ from ] filename func ()

(* This is offered directly to users. *)
let with_capture ?(is_binary_mode = fun _ -> false) from func =
  (* The priority is to make this work with stdout or stderr in text
     mode. In particular, nested capture of both stdout and stderr
     is expected to work:

       with_capture stdout (fun () ->
         with_capture stderr (fun () ->
           ...
         )
       )

     Other situations are less important. On Windows, the existence of
     text and binary modes poses difficulties. It doesn't seem possible
     to correctly capture both a text channel and a binary channel.

     It appears that the text vs. binary mode on Windows is tied to
     the file descriptor even though OCaml requires us to
     open a channel to adjust this setting. It's also not clear why
     dup2 doesn't change the mode of the destination file descriptor
     for us but changes the mode of the original.

     1. We arrange to make the writes and reads in the same mode as
        the original channel to minimize the chance of errors.
     2. We make sure to restore the original mode on the original channel
        when we're done.
  *)
  let is_binary_writer = is_binary_mode from in
  Temp_file.with_open_temp_file ~windows_binary:is_binary_writer
    ~windows_file_share_delete:true ~suffix:".out" (fun path oc ->
      with_redirects_to_out_channel [ from ] oc func () >>= fun res ->
      (* Trial and error showed that restoring the mode on the original
         channel is necessary. *)
      set_binary_mode_out from is_binary_writer;
      let output = read_file_exn ~binary:is_binary_writer path in
      P.return (res, output))

(* Apply functions to the data as a pipeline, from left to right. *)
let compose_functions_left_to_right funcs x =
  List.fold_left (fun x f -> f x) x funcs

(* 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.

   We normalize only the captured stdout/stderr output, not checked output
   files (because the user can do it themselves from within the test
   function).
*)
let normalize_output (test : T.test) =
  match get_orig_output_suffix test with
  | None -> ()
  | Some orig_suffix ->
      let rewrite_string = compose_functions_left_to_right test.normalize in
      let paths = std_capture_paths_of_test test in
      get_checked_output_paths paths
      |> List.iter (fun std_path ->
             let backup_path = Fpath.v (!!std_path ^ orig_suffix) in
             if Sys.file_exists !!backup_path then Sys.remove !!backup_path;
             Sys.rename !!std_path !!backup_path;
             let orig_data = read_file_exn backup_path in
             let normalized_data =
               try rewrite_string orig_data with
               | e ->
                   Error.user_error
                     (sprintf
                        "Exception raised by the test's normalize_output \
                         function: %s"
                        (Printexc.to_string e))
             in
             Helpers.write_text_file std_path normalized_data)

let with_redirect_merged_stdout_stderr path func =
  (* redirect stderr and stdout to a stdxxx file *)
  with_redirects_to_file [ stdout; stderr ] path func

let with_output_capture (test : T.test) (func : unit -> 'unit_promise) =
  let capture_paths = std_capture_paths_of_test test in
  let func =
    match (test.checked_output, capture_paths) with
    | Ignore_output, [ log_paths ] ->
        with_redirect_merged_stdout_stderr log_paths.path_to_output func
    | Stdout _, [ paths; log_paths ] ->
        with_redirect_to_file stderr log_paths.path_to_output
          (with_redirect_to_file stdout paths.path_to_output func)
    | Stderr _, [ paths; log_paths ] ->
        with_redirect_to_file stdout log_paths.path_to_output
          (with_redirect_to_file stderr paths.path_to_output func)
    | Stdxxx _, [ paths ] ->
        with_redirect_merged_stdout_stderr paths.path_to_output func
    | Split_stdout_stderr _, [ stdout_paths; stderr_paths ] ->
        with_redirect_to_file stdout stdout_paths.path_to_output
          (with_redirect_to_file stderr stderr_paths.path_to_output func)
    | _ -> (* bug: invalid combination *) Error.assert_false ~__LOC__ ()
  in
  fun () ->
    P.protect func ~finally:(fun () ->
        normalize_output test;
        P.return ())

let with_completion_status_capture (test : T.test) func : unit -> unit Promise.t
    =
 fun () ->
  P.catch
    (fun () ->
      func () >>= fun res ->
      set_completion_status test Test_function_returned;
      P.return res)
    (fun e trace ->
      set_completion_status test Test_function_raised_an_exception;
      (Printexc.raise_with_backtrace e trace : '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.
*)
let with_result_capture (test : T.test) func : unit -> unit Promise.t =
  let func = with_output_capture test func in
  let func = with_completion_status_capture test func in
  func

let mark_test_as_timed_out (test : T.test) =
  set_completion_status test Test_timeout

let stash_output_file (test : T.test) src_path (x : T.checked_output_file) :
    unit =
  let dst_path = get_output_file_path test x in
  Helpers.copy_text_file src_path dst_path

let remove_stashed_output_files (test : T.test) =
  test.checked_output_files
  |> List.iter (fun (x : T.checked_output_file) ->
         remove_file (get_output_file_path test x))

(**************************************************************************)
(* High-level interface *)
(**************************************************************************)

let captured_output_of_data (kind : T.checked_output_kind) (data : string list)
    : T.captured_output =
  match (kind, data) with
  | Ignore_output, [ unchecked ] -> Ignored unchecked
  | Stdout _, [ out; unchecked ] -> Captured_stdout (out, unchecked)
  | Stderr _, [ err; unchecked ] -> Captured_stderr (err, unchecked)
  | Stdxxx _, [ data ] -> Captured_merged data
  | Split_stdout_stderr _, [ out; err ] -> Captured_stdout_stderr (out, err)
  | (Ignore_output | Stdout _ | Stderr _ | Stdxxx _ | Split_stdout_stderr _), _
    ->
      Error.assert_false ~__LOC__ ()

(* This is rather horrible. Simplify? *)
let expected_output_of_data (kind : T.checked_output_kind) (data : string list)
    : T.expected_output =
  match (kind, data) with
  | Ignore_output, [] -> Ignored
  | Stdout _, [ out ] -> Expected_stdout out
  | Stderr _, [ err ] -> Expected_stderr err
  | Stdxxx _, [ data ] -> Expected_merged data
  | Split_stdout_stderr _, [ out; err ] -> Expected_stdout_stderr (out, err)
  | (Ignore_output | Stdout _ | Stderr _ | Stdxxx _ | Split_stdout_stderr _), _
    ->
      Error.assert_false ~__LOC__ ()

let get_expectation (test : T.test) ~(std_capture_paths : capture_paths list) :
    T.expectation =
  let expected_output =
    std_capture_paths |> get_expected_output |> list_result_of_result_list
    |> function
    | Ok x -> Ok (expected_output_of_data test.checked_output x)
    | Error missing_files -> Error (T.Missing_files missing_files)
  in
  let expected_output_files =
    test.checked_output_files
    |> Helpers.list_map (fun (checked_file : T.checked_output_file) ->
           match read_file (get_file_snapshot_path test checked_file) with
           | Ok contents ->
               Ok
                 ({ checked_file; contents }
                   : T.checked_output_file_with_contents)
           | Error path -> Error path)
  in
  {
    expected_outcome = test.expected_outcome;
    expected_output;
    expected_output_files;
  }

(* Fail if any capture file is missing *)
let get_result (test : T.test) (paths : capture_paths list) :
    (T.result, T.missing_files) Result.t =
  match get_completion_status test with
  | Error missing_file -> Error (Missing_files [ missing_file ])
  | Ok completion_status -> (
      (* captured output files *)
      let captured_output_files, missing_output_files =
        test.checked_output_files
        |> Helpers.list_map (fun (checked_file : T.checked_output_file) ->
               match read_file (get_output_file_path test checked_file) with
               | Ok contents ->
                   Ok
                     ({ checked_file; contents }
                       : T.checked_output_file_with_contents)
               | Error missing_file -> Error missing_file)
        |> Helpers.split_result_list
      in
      (* captured standard output *)
      let opt_captured_output =
        paths |> get_output |> list_result_of_result_list
        |> Result.map (captured_output_of_data test.checked_output)
      in
      (*
         - If the test ran previously and nobody messed with Testo's
           workspace, missing output files are due to a test function
           that didn't make all the calls to 'stash_output_file' it should
           have. It could be because the test raised an exception because
           it could happen or it could be that programmer forgot or
           didn't realize they should have called 'stash_output_file'.
         - Missing files for captured stdout or stderr on the other hand
           are restricted to the former cases i.e. not an error in the test
           function.
      *)
      match (opt_captured_output, missing_output_files) with
      | Ok captured_output, missing_output_files ->
          (* This is most likely a test failure (see above).
             It's more important to report the exception if there was one
             than a missing output file. This is why we don't return an Error
             here even if some checked output files are missing. *)
          Ok
            {
              completion_status;
              captured_output;
              captured_output_files;
              missing_output_files;
            }
      | Error missing_std_files, missing_output_files ->
          Error (Missing_files (missing_std_files @ missing_output_files)))

let get_status (test : T.test) : T.status =
  let paths = std_capture_paths_of_test test in
  let expectation = get_expectation test ~std_capture_paths:paths in
  let result = get_result test paths in
  { expectation; result }

let create_outcome (completion_status : T.completion_status)
    ~(has_all_output_files : bool) ~(output_matches : bool) : T.outcome =
  match completion_status with
  | Test_function_raised_an_exception ->
      (* If the test raised an exception, there's a good chance we don't
         have some of the output files. In this case, we report the cause
         of failure as the exception, not as the missing output files. *)
      Failed Raised_exception
  | Test_function_returned ->
      if has_all_output_files then
        match output_matches with
        | false -> Failed Incorrect_output
        | true -> Succeeded
      else Failed Missing_output_file
  | Test_timeout -> Failed Timeout

let outcome_of_expectation_and_result (expect : T.expectation)
    (result : T.result) : T.outcome * bool =
  let has_expected_std_output, output_matches =
    match (expect.expected_output, result.captured_output) with
    | Ok output1, output2 when T.equal_checked_output output1 output2 ->
        (true, true)
    | Ok _, _ -> (true, false)
    | Error _, _ -> (false, true)
  in
  let has_expected_output_files, output_files_match =
    match result.missing_output_files with
    | [] ->
        (* Assume the lists of expected files and captured files are complete
           and in the same order. *)
        List.fold_left2
          (fun (has_expected_output_files, output_files_match)
               (expect :
                 (T.checked_output_file_with_contents, Fpath.t) Result.t)
               (result : T.checked_output_file_with_contents) ->
            match expect with
            | Ok expected ->
                assert (expected.checked_file = result.checked_file);
                if expected.contents = result.contents then
                  (has_expected_output_files, output_files_match)
                else (has_expected_output_files, false)
            | Error _missing_expected_file -> (false, false))
          (true, true) expect.expected_output_files result.captured_output_files
    | _ -> (false, false)
  in
  let outcome =
    create_outcome result.completion_status
      ~has_all_output_files:has_expected_output_files
      ~output_matches:(output_matches && output_files_match)
  in
  (outcome, has_expected_std_output && has_expected_output_files)

let status_summary_of_status (status : T.status) : T.status_summary =
  match status.result with
  | Error missing_files ->
      {
        passing_status = MISS missing_files;
        (* These two fields are meaningless *)
        outcome = Failed Raised_exception;
        has_expected_output = false;
      }
  | Ok result ->
      let expect = status.expectation in
      let outcome, has_expected_output =
        outcome_of_expectation_and_result expect result
      in
      let passing_status : T.passing_status =
        match (expect.expected_outcome, outcome) with
        | Should_succeed, Succeeded -> PASS
        | Should_succeed, Failed fail_reason -> FAIL fail_reason
        | Should_fail _, Succeeded -> XPASS
        | Should_fail _, Failed fail_reason -> XFAIL fail_reason
      in
      { passing_status; outcome; has_expected_output }

let check_status_before_approval (test : T.test) : (unit, Error.msg) result =
  let status = get_status test in
  let status_summary = status_summary_of_status status in
  match status_summary.passing_status with
  | PASS
  | XPASS ->
      Ok ()
  | FAIL Raised_exception
  | XFAIL Raised_exception ->
      Error
        (Error.Warning
           (sprintf
              "Cannot approve test because it raised an exception: %s '%s'"
              test.id test.internal_full_name))
  | FAIL Missing_output_file
  | XFAIL Missing_output_file ->
      (* Approving the test will create the missing snapshot file *)
      Ok ()
  | FAIL Incorrect_output
  | XFAIL Incorrect_output ->
      (* The user is approving the new output reported as incorrect *)
      Ok ()
  | FAIL Timeout
  | XFAIL Timeout ->
      Error
        (Error.Error
           (sprintf "Cannot approve test because it timed out: %s '%s'" test.id
              test.internal_full_name))
  | MISS missing_files ->
      Error (Error.Error (errmsg_of_missing_files missing_files))

type changed = Changed | Unchanged

exception Local_error of string

let approve_new_output (test : T.test) : (changed, Error.msg) Result.t =
  match test.skipped with
  | Some _reason -> Ok Unchanged
  | None -> (
      let std_capture_paths = std_capture_paths_of_test test in
      let file_capture_paths = file_capture_paths_of_test test in
      let all_capture_paths = std_capture_paths @ file_capture_paths in
      match check_status_before_approval test with
      | Error _ as res -> res
      | Ok () -> (
          let old_expectation = get_expectation test ~std_capture_paths in
          clear_expected_output test;
          try
            let data =
              all_capture_paths |> get_checked_output
              |> list_map (function
                   | Ok data -> data
                   | Error missing_file ->
                       raise (Local_error (errmsg_of_missing_file missing_file)))
            in
            set_expected_output test all_capture_paths data;
            let new_expectation = get_expectation test ~std_capture_paths in
            let changed =
              if old_expectation = new_expectation then Unchanged else Changed
            in
            Ok changed
          with
          | Local_error msg ->
              Error
                (Error.Error
                   (sprintf "Cannot approve output for test %s: %s" test.id msg))
          ))