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
(* src/package_manager/test_discovery.ml *)
(* Test runner for T packages: discovers and runs test-*.t files *)

(** Single test result *)
type test_result = {
  file : string;
  success : bool;
  error_msg : string option;
  duration : float;
}

(** Overall test suite result *)
type suite_result = {
  total : int;
  passed : int;
  failed : int;
  results : test_result list;
  total_duration : float;
}

(** Discover test files in a directory.
    Matches files named test-*.t or *_test.t, recursively. *)
let discover_tests (dir : string) : string list =
  let results = ref [] in
  let rec scan path =
    if Sys.file_exists path && Sys.is_directory path then begin
      let entries = Sys.readdir path in
      Array.sort String.compare entries;
      Array.iter (fun entry ->
        let full_path = Filename.concat path entry in
        if Sys.is_directory full_path then
          scan full_path
        else if Filename.check_suffix entry ".t" then begin
          (* Match test-*.t or *_test.t *)
          let base = Filename.remove_extension entry in
          let is_test_prefix =
            String.length base >= 5 &&
            String.sub base 0 5 = "test-" in
          let is_test_suffix =
            String.length base >= 5 &&
            String.sub base (String.length base - 5) 5 = "_test" in
          if is_test_prefix || is_test_suffix then
            results := full_path :: !results
        end
      ) entries
    end
  in
  scan dir;
  List.rev !results

(** Run a single test file in an isolated environment.
    Returns a test_result indicating pass/fail. *)
let run_test_file (file : string) : test_result =
  let start = Unix.gettimeofday () in
  try
    let ch = open_in file in
    let content = really_input_string ch (in_channel_length ch) in
    close_in ch;
    (* Create fresh isolated environment for each test *)
    let env = Packages.init_env () in

    (* Pre-load all .t files from src/ directory if it exists *)
    let src_dir = Filename.concat (Filename.dirname (Filename.dirname file)) "src" in
    let env =
      if Sys.file_exists src_dir && Sys.is_directory src_dir then begin
        let entries = Sys.readdir src_dir in
        Array.sort String.compare entries;
        Array.fold_left (fun env entry ->
          if Filename.check_suffix entry ".t" then begin
            let src_file = Filename.concat src_dir entry in
            let ch = open_in src_file in
            let src_content = really_input_string ch (in_channel_length ch) in
            close_in ch;
            let lexbuf = Lexing.from_string src_content in
            try
              let program = Parser.program Lexer.token lexbuf in
              let rec eval_imports env = function
                | [] -> env
                | stmt :: rest ->
                    let (_, new_env) = Eval.eval_statement env stmt in
                    eval_imports new_env rest
              in
              eval_imports env program
            with _ -> env (* Ignore errors in src for now, or maybe report? *)
          end else env
        ) env entries
      end else env
    in

    let lexbuf = Lexing.from_string content in
    let program = Parser.program Lexer.token lexbuf in
    (* Evaluate all statements, collecting assertion errors *)
    let rec run_stmts env errs = function
      | [] -> (List.rev errs, env)
      | stmt :: rest ->
        let (v, new_env) = Eval.eval_statement env stmt in
        let errs' = match v with
          | Ast.VError { code = Ast.AssertionError; message; _ } ->
            message :: errs
          | Ast.VError { code; message; _ } ->
            (Printf.sprintf "%s: %s"
              (Ast.Utils.error_code_to_string code) message) :: errs
          | _ -> errs
        in
        run_stmts new_env errs' rest
    in
    let (errors, _) = run_stmts env [] program in
    let duration = Unix.gettimeofday () -. start in
    if errors = [] then
      { file; success = true; error_msg = None; duration }
    else
      { file; success = false;
        error_msg = Some (String.concat "\n  " errors);
        duration }
  with
  | Lexer.SyntaxError msg ->
    let duration = Unix.gettimeofday () -. start in
    { file; success = false;
      error_msg = Some (Printf.sprintf "Syntax Error: %s" msg);
      duration }
  | Parser.Error ->
    let duration = Unix.gettimeofday () -. start in
    { file; success = false;
      error_msg = Some "Parse Error";
      duration }
  | Sys_error msg ->
    let duration = Unix.gettimeofday () -. start in
    { file; success = false;
      error_msg = Some (Printf.sprintf "File Error: %s" msg);
      duration }
  | exn ->
    let duration = Unix.gettimeofday () -. start in
    { file; success = false;
      error_msg = Some (Printf.sprintf "Unexpected: %s" (Printexc.to_string exn));
      duration }

(** Format a duration as a human-readable string *)
let format_duration d =
  if d < 0.001 then Printf.sprintf "<1ms"
  else if d < 1.0 then Printf.sprintf "%.0fms" (d *. 1000.0)
  else Printf.sprintf "%.2fs" d

(** Run a full test suite: discover + execute all tests *)
let run_suite ?(verbose=false) (dir : string) : suite_result =
  let test_dir = Filename.concat dir "tests" in
  if not (Sys.file_exists test_dir && Sys.is_directory test_dir) then begin
    Printf.printf "No tests/ directory found.\n";
    { total = 0; passed = 0; failed = 0; results = []; total_duration = 0.0 }
  end else begin
    let files = discover_tests test_dir in
    if files = [] then begin
      Printf.printf "No test files found (looking for test-*.t or *_test.t).\n";
      { total = 0; passed = 0; failed = 0; results = []; total_duration = 0.0 }
    end else begin
      let start_total = Unix.gettimeofday () in
      Printf.printf "Running %d test file%s...\n\n"
        (List.length files) (if List.length files > 1 then "s" else "");
      let results = List.map (fun file ->
        let r = run_test_file file in
        let short_name = 
          if String.length file > String.length dir + 1 then
            String.sub file (String.length dir + 1) (String.length file - String.length dir - 1)
          else file
        in
        if r.success then
          Printf.printf "  ✓ %s (%s)\n" short_name (format_duration r.duration)
        else begin
          Printf.printf "  ✗ %s (%s)\n" short_name (format_duration r.duration);
          if verbose then
            match r.error_msg with
            | Some msg -> Printf.printf "    → %s\n" msg
            | None -> ()
        end;
        r
      ) files in
      let total_duration = Unix.gettimeofday () -. start_total in
      let passed_results = List.filter (fun r -> r.success) results in
      let passed = List.length passed_results in
      let failed = List.length results - passed in
      Printf.printf "\n";
      if failed = 0 then
        Printf.printf "✓ All %d test%s passed (%s)\n"
          passed (if passed > 1 then "s" else "") (format_duration total_duration)
      else begin
        Printf.printf "✗ %d/%d test%s failed (%s)\n\n"
          failed (List.length results)
          (if List.length results > 1 then "s" else "")
          (format_duration total_duration);
        (* Show failure details *)
        List.iter (fun r ->
          if not r.success then begin
            Printf.printf "FAIL: %s\n" r.file;
            match r.error_msg with
            | Some msg -> Printf.printf "  %s\n" msg
            | None -> ()
          end
        ) results
      end;
      { total = List.length results; passed; failed; results; total_duration }
    end
  end