12 from concurrent
.futures
import ThreadPoolExecutor
13 from dataclasses
import dataclass
14 from typing
import Callable
, Dict
, List
, Optional
, Tuple
16 from hphp
.hack
.test
.parse_errors
import Error
, parse_errors
, sprint_errors
21 dump_on_failure
= False
40 Per-test flags passed to test executable. Expected to be in a file with
41 same name as test, but with .flags extension.
45 def compare_errors_by_line_no(
46 errors_exp
: List
[Error
], errors_out
: List
[Error
]
47 ) -> Tuple
[List
[Error
], List
[Error
]]:
50 len_out
= len(errors_out
)
51 len_exp
= len(errors_exp
)
52 errors_in_out_not_in_exp
= []
53 errors_in_exp_not_in_out
= []
54 while i_out
< len_out
and i_exp
< len_exp
:
55 err_out
= errors_out
[i_out
]
56 err_exp
= errors_exp
[i_exp
]
57 l_out
= err_out
.message
.position
.line
58 l_exp
= err_exp
.message
.position
.line
60 errors_in_out_not_in_exp
.append(err_out
)
63 errors_in_exp_not_in_out
.append(err_exp
)
69 for i
in range(i_exp
, len_exp
):
70 errors_in_exp_not_in_out
.append(errors_exp
[i
])
71 elif i_exp
>= len_exp
:
72 for i
in range(i_out
, len_out
):
73 errors_in_out_not_in_exp
.append(errors_out
[i
])
74 return (errors_in_exp_not_in_out
, errors_in_out_not_in_exp
)
77 def compare_output_files_error_lines_only(
78 file_out
: str, file_exp
: str
79 ) -> Tuple
[bool, str]:
83 errors_out
= parse_errors(file_out
)
84 errors_exp
= parse_errors(file_exp
)
86 errors_in_exp_not_in_out
,
87 errors_in_out_not_in_exp
,
88 ) = compare_errors_by_line_no(errors_out
=errors_out
, errors_exp
=errors_exp
)
90 failed
= bool(errors_in_exp_not_in_out
) or bool(errors_in_out_not_in_exp
)
91 if errors_in_exp_not_in_out
:
92 out
+= f
"""\033[93mExpected errors which were not produced:\033[0m
93 {sprint_errors(errors_in_exp_not_in_out)}
95 if errors_in_out_not_in_exp
:
96 out
+= f
"""\033[93mProduced errors which were not expected:\033[0m
97 {sprint_errors(errors_in_out_not_in_exp)}
100 out
= f
"Warning: {e}"
104 def check_output_error_lines_only(
105 test
: str, out_ext
: str = ".out", exp_ext
: str = ".exp"
106 ) -> Tuple
[bool, str]:
107 file_out
= test
+ out_ext
108 file_exp
= test
+ exp_ext
109 return compare_output_files_error_lines_only(file_out
=file_out
, file_exp
=file_exp
)
112 def get_test_flags(path
: str) -> List
[str]:
113 prefix
, _ext
= os
.path
.splitext(path
)
114 path
= prefix
+ ".flags"
116 if not os
.path
.isfile(path
):
118 with
open(path
) as file:
119 return shlex
.split(file.read().strip())
125 default_expect_regex
: Optional
[str],
126 ignore_error_text
: bool,
127 only_compare_error_lines
: bool,
129 if only_compare_error_lines
:
130 (failed
, out
) = check_output_error_lines_only(case
.file_path
)
131 return Result(test_case
=case
, output
=out
, is_failure
=failed
)
133 out_path
= case
.file_path
+ out_extension
135 with
open(out_path
, "r") as f
:
136 output
: str = f
.read()
137 except FileNotFoundError
:
138 out_path
= os
.path
.realpath(out_path
)
139 output
= "Output file " + out_path
+ " was not found!"
140 return check_result(case
, default_expect_regex
, ignore_error_text
, output
)
143 def debug_cmd(cwd
: str, cmd
: List
[str]) -> None:
145 print("From directory", os
.path
.realpath(cwd
))
146 print("Executing", " ".join(cmd
))
151 test_cases
: List
[TestCase
],
153 default_expect_regex
: Optional
[str],
154 ignore_error_text
: bool,
157 mode_flag
: List
[str],
158 get_flags
: Callable
[[str], List
[str]],
160 only_compare_error_lines
: bool = False,
163 Run the program with batches of files and return a list of results.
165 # Each directory needs to be in a separate batch because flags are different
166 # for each directory.
167 # Compile a list of directories to test cases, and then
168 dirs_to_files
: Dict
[str, List
[TestCase
]] = {}
169 for case
in test_cases
:
170 test_dir
= os
.path
.dirname(case
.file_path
)
171 dirs_to_files
.setdefault(test_dir
, []).append(case
)
173 # run a list of test cases.
174 # The contract here is that the program will write to
175 # filename.out_extension for each file, and we read that
177 def run(test_cases
: List
[TestCase
]) -> List
[Result
]:
179 raise AssertionError()
180 first_test
= test_cases
[0]
181 test_dir
= os
.path
.dirname(first_test
.file_path
)
182 flags
= get_flags(test_dir
)
183 test_flags
= get_test_flags(first_test
.file_path
)
186 cmd
+= ["--batch-files", "--out-extension", out_extension
]
187 cmd
+= flags
+ test_flags
188 cmd
+= [os
.path
.basename(case
.file_path
) for case
in test_cases
]
189 debug_cmd(test_dir
, cmd
)
190 env
= os
.environ
.copy()
191 env
["FORCE_ERROR_COLOR"] = "true" if force_color
else "false"
193 return_code
= subprocess
.call(
195 stderr
=None if no_stderr
else subprocess
.STDOUT
,
197 universal_newlines
=True,
200 except subprocess
.CalledProcessError
:
201 # we don't care about nonzero exit codes... for instance, type
202 # errors cause hh_single_type_check to produce them
204 if return_code
== -11:
206 "Segmentation fault while running the following command "
208 + os
.path
.realpath(test_dir
)
213 for case
in test_cases
:
214 result
= check_output(
216 out_extension
=out_extension
,
217 default_expect_regex
=default_expect_regex
,
218 ignore_error_text
=ignore_error_text
,
219 only_compare_error_lines
=only_compare_error_lines
,
221 results
.append(result
)
224 # Create a list of batched cases.
225 all_batched_cases
: List
[List
[TestCase
]] = []
227 # For each directory, we split all the test cases
228 # into chunks of batch_size. Then each of these lists
229 # is a separate job for each thread in the threadpool.
230 for cases
in dirs_to_files
.values():
231 batched_cases
: List
[List
[TestCase
]] = [
232 cases
[i
: i
+ batch_size
] for i
in range(0, len(cases
), batch_size
)
234 all_batched_cases
+= batched_cases
236 executor
= ThreadPoolExecutor(max_workers
=max_workers
)
237 futures
= [executor
.submit(run
, test_batch
) for test_batch
in all_batched_cases
]
239 results
= [future
.result() for future
in futures
]
241 return [item
for sublist
in results
for item
in sublist
]
244 def run_test_program(
245 test_cases
: List
[TestCase
],
247 default_expect_regex
: Optional
[str],
248 ignore_error_text
: bool,
251 mode_flag
: List
[str],
252 get_flags
: Callable
[[str], List
[str]],
253 timeout
: Optional
[float] = None,
257 Run the program and return a list of results.
260 def run(test_case
: TestCase
) -> Result
:
261 test_dir
, test_name
= os
.path
.split(test_case
.file_path
)
262 flags
= get_flags(test_dir
)
263 test_flags
= get_test_flags(test_case
.file_path
)
266 if test_case
.input is None:
267 cmd
.append(test_name
)
268 cmd
+= flags
+ test_flags
269 debug_cmd(test_dir
, cmd
)
270 env
= os
.environ
.copy()
271 env
["FORCE_ERROR_COLOR"] = "true" if force_color
else "false"
273 output
= subprocess
.check_output(
275 stderr
=None if no_stderr
else subprocess
.STDOUT
,
277 universal_newlines
=True,
279 input=test_case
.input,
284 except subprocess
.TimeoutExpired
as e
:
285 output
= "Timed out. " + str(e
.output
)
286 except subprocess
.CalledProcessError
as e
:
287 # we don't care about nonzero exit codes... for instance, type
288 # errors cause hh_single_type_check to produce them
289 output
= str(e
.output
)
290 return check_result(test_case
, default_expect_regex
, ignore_error_text
, output
)
292 executor
= ThreadPoolExecutor(max_workers
=max_workers
)
293 futures
= [executor
.submit(run
, test_case
) for test_case
in test_cases
]
295 return [future
.result() for future
in futures
]
298 def filter_ocaml_stacktrace(text
: str) -> str:
299 """take a string and remove all the lines that look like
300 they're part of an OCaml stacktrace"""
301 assert isinstance(text
, str)
302 it
= text
.splitlines()
305 drop_line
= x
.lstrip().startswith("Called") or x
.lstrip().startswith("Raised")
310 return "\n".join(out
)
313 def filter_version_field(text
: str) -> str:
314 """given a string, remove the part that looks like the schema version"""
315 assert isinstance(text
, str)
317 r
'"version":"\d{4}-\d{2}-\d{2}-\d{4}"', r
'"version":"sanitised"', text
, count
=1
321 def compare_expected(expected
: str, out
: str) -> bool:
322 if expected
== "No errors\n" or out
== "No errors\n":
323 return expected
== out
328 # Strip leading and trailing whitespace from every line
329 def strip_lines(text
: str) -> str:
330 return "\n".join(line
.strip() for line
in text
.splitlines())
335 default_expect_regex
: Optional
[str],
336 ignore_error_messages
: bool,
340 Check that the output of the test in :out corresponds to the expected
341 output, or if a :default_expect_regex is provided,
342 check that the output in :out contains the provided regex.
344 expected
= filter_version_field(strip_lines(test_case
.expected
))
345 normalized_out
= filter_version_field(strip_lines(out
))
347 expected
== normalized_out
348 or (ignore_error_messages
and compare_expected(expected
, normalized_out
))
349 or expected
== filter_ocaml_stacktrace(normalized_out
)
351 default_expect_regex
is not None
352 and re
.search(default_expect_regex
, normalized_out
) is not None
356 return Result(test_case
=test_case
, output
=out
, is_failure
=not is_ok
)
359 def record_results(results
: List
[Result
], out_ext
: str) -> None:
360 for result
in results
:
361 outfile
= result
.test_case
.file_path
+ out_ext
362 with
open(outfile
, "wb") as f
:
363 f
.write(bytes(result
.output
, "UTF-8"))
366 def find_in_ancestors_rec(dir: str, path
: str) -> str:
367 if path
== "" or os
.path
.dirname(path
) == path
:
368 raise Exception("Could not find directory %s in ancestors." % dir)
369 if os
.path
.basename(path
) == dir:
371 return find_in_ancestors_rec(dir, os
.path
.dirname(path
))
374 def find_in_ancestors(dir: str, path
: str) -> str:
376 return find_in_ancestors_rec(dir, path
)
378 raise Exception("Could not find directory %s in ancestors of %s." % (dir, path
))
381 def get_exp_out_dirs(test_file
: str) -> Tuple
[str, str]:
383 os
.environ
.get("HACK_BUILD_ROOT") is not None
384 and os
.environ
.get("HACK_SOURCE_ROOT") is not None
386 exp_dir
= os
.environ
["HACK_SOURCE_ROOT"]
387 out_dir
= os
.environ
["HACK_BUILD_ROOT"]
389 fbcode
= find_in_ancestors("fbcode", test_file
)
390 exp_dir
= os
.path
.join(fbcode
, "hphp", "hack")
391 out_dir
= os
.path
.dirname(find_in_ancestors("test", test_file
))
392 return exp_dir
, out_dir
397 failures
: List
[Result
],
399 expect_extension
: str,
400 fallback_expect_extension
: Optional
[str],
401 no_copy
: bool = False,
402 only_compare_error_lines
: bool = False,
404 if only_compare_error_lines
:
405 for failure
in failures
:
406 print(f
"\033[95m{failure.test_case.file_path}\033[0m")
407 print(failure
.output
)
410 record_results(failures
, out_extension
)
412 dump_failures(failures
)
413 fnames
= [failure
.test_case
.file_path
for failure
in failures
]
414 print("To review the failures, use the following command: ")
415 fallback_expect_ext_var
= ""
416 if fallback_expect_extension
is not None:
417 fallback_expect_ext_var
= "FALLBACK_EXP_EXT=%s " % fallback_expect_extension
418 first_test_file
= os
.path
.realpath(failures
[0].test_case
.file_path
)
419 out_dir
: str # for Pyre
420 (exp_dir
, out_dir
) = get_exp_out_dirs(first_test_file
)
421 output_dir_var
= "SOURCE_ROOT=%s OUTPUT_ROOT=%s " % (exp_dir
, out_dir
)
423 # Get a full path to 'review.sh' so this command be run
424 # regardless of your current directory.
425 review_script
= os
.path
.join(
426 os
.path
.dirname(os
.path
.realpath(__file__
)), "review.sh"
428 if not os
.path
.isfile(review_script
):
429 review_script
= "./hphp/hack/test/review.sh"
431 def fname_map_var(f
: str) -> str:
432 return "hphp/hack/" + os
.path
.relpath(f
, out_dir
)
435 "OUT_EXT=%s EXP_EXT=%s %s%sNO_COPY=%s %s %s"
439 fallback_expect_ext_var
,
441 "true" if no_copy
else "false",
443 " ".join(map(fname_map_var
, fnames
)),
448 def dump_failures(failures
: List
[Result
]) -> None:
450 expected
= f
.test_case
.expected
452 diff
= difflib
.ndiff(expected
.splitlines(True), actual
.splitlines(True))
453 print("Details for the failed test %s:" % f
.test_case
.file_path
)
454 print("\n>>>>> Expected output >>>>>>\n")
456 print("\n===== Actual output ======\n")
458 print("\n<<<<< End Actual output <<<<<<<\n")
459 print("\n>>>>> Diff >>>>>>>\n")
461 print("\n<<<<< End Diff <<<<<<<\n")
464 def get_hh_flags(test_dir
: str) -> List
[str]:
465 path
= os
.path
.join(test_dir
, "HH_FLAGS")
466 if not os
.path
.isfile(path
):
468 print("No HH_FLAGS file found")
470 with
open(path
) as f
:
471 return shlex
.split(f
.read())
474 def files_with_ext(files
: List
[str], ext
: str) -> List
[str]:
476 Returns the set of filenames in :files that end in :ext
478 filtered_files
: List
[str] = []
480 prefix
, suffix
= os
.path
.splitext(file)
482 filtered_files
.append(prefix
)
483 return filtered_files
486 def list_test_files(root
: str, disabled_ext
: str, test_ext
: str) -> List
[str]:
487 if os
.path
.isfile(root
):
488 if root
.endswith(test_ext
):
492 elif os
.path
.isdir(root
):
493 result
: List
[str] = []
494 children
= os
.listdir(root
)
495 disabled
= files_with_ext(children
, disabled_ext
)
496 for child
in children
:
497 if child
!= "disabled" and child
not in disabled
:
499 list_test_files(os
.path
.join(root
, child
), disabled_ext
, test_ext
)
502 elif os
.path
.islink(root
):
503 # Some editors create broken symlinks as part of their locking scheme,
507 raise Exception("Could not find test file or directory at %s" % root
)
510 def get_content_(file_path
: str, ext
: str) -> str:
511 with
open(file_path
+ ext
, "r") as fexp
:
516 file_path
: str, ext
: str = "", fallback_ext
: Optional
[str] = None
519 return get_content_(file_path
, ext
)
520 except FileNotFoundError
:
521 if fallback_ext
is not None:
523 return get_content_(file_path
, fallback_ext
)
524 except FileNotFoundError
:
532 expected_extension
: str,
533 fallback_expect_extension
: Optional
[str],
537 default_expect_regex
: Optional
[str],
539 ignore_error_text
: bool,
542 mode_flag
: List
[str],
543 get_flags
: Callable
[[str], List
[str]],
544 timeout
: Optional
[float] = None,
545 only_compare_error_lines
: bool = False,
548 # for each file, create a test case
552 expected
=get_content(file, expected_extension
, fallback_expect_extension
),
553 input=get_content(file) if use_stdin
else None,
558 results
= run_batch_tests(
561 default_expect_regex
,
568 only_compare_error_lines
,
571 results
= run_test_program(
574 default_expect_regex
,
583 failures
= [result
for result
in results
if result
.is_failure
]
585 num_results
= len(results
)
588 "All tests in the suite passed! "
589 "The number of tests that ran: %d\n" % num_results
592 print("The number of tests that failed: %d/%d\n" % (len(failures
), num_results
))
597 args
.expect_extension
,
598 args
.fallback_expect_extension
,
599 only_compare_error_lines
=only_compare_error_lines
,
601 sys
.exit(1) # this exit code fails the suite and lets Buck know
606 def run_idempotence_tests(
607 results
: List
[Result
],
608 expected_extension
: str,
609 fallback_expect_extension
: Optional
[str],
612 default_expect_regex
: Optional
[str],
613 mode_flag
: List
[str],
614 get_flags
: Callable
[[str], List
[str]],
616 idempotence_test_cases
= [
618 file_path
=result
.test_case
.file_path
,
619 expected
=result
.test_case
.expected
,
622 for result
in results
625 idempotence_results
= run_test_program(
626 idempotence_test_cases
,
628 default_expect_regex
,
636 num_idempotence_results
= len(idempotence_results
)
638 idempotence_failures
= [
639 result
for result
in idempotence_results
if result
.is_failure
642 if idempotence_failures
== []:
644 "All idempotence tests in the suite passed! The number of "
645 "idempotence tests that ran: %d\n" % num_idempotence_results
649 "The number of idempotence tests that failed: %d/%d\n"
650 % (len(idempotence_failures
), num_idempotence_results
)
653 num_idempotence_results
,
654 idempotence_failures
,
655 out_extension
+ out_extension
, # e.g., *.out.out
657 fallback_expect_extension
,
660 sys
.exit(1) # this exit code fails the suite and lets Buck know
663 def get_flags_cache(args_flags
: List
[str]) -> Callable
[[str], List
[str]]:
664 flags_cache
: Dict
[str, List
[str]] = {}
666 def get_flags(test_dir
: str) -> List
[str]:
667 if test_dir
not in flags_cache
:
668 flags_cache
[test_dir
] = get_hh_flags(test_dir
)
669 flags
= flags_cache
[test_dir
]
670 if args_flags
is not None:
671 flags
= flags
+ args_flags
677 if __name__
== "__main__":
678 parser
= argparse
.ArgumentParser()
679 parser
.add_argument("test_path", help="A file or a directory. ")
680 parser
.add_argument("--program", type=os
.path
.abspath
)
681 parser
.add_argument("--out-extension", type=str, default
=".out")
682 parser
.add_argument("--expect-extension", type=str, default
=".exp")
683 parser
.add_argument("--fallback-expect-extension", type=str)
684 parser
.add_argument("--default-expect-regex", type=str)
685 parser
.add_argument("--in-extension", type=str, default
=".php")
686 parser
.add_argument("--disabled-extension", type=str, default
=".no_typecheck")
687 parser
.add_argument("--verbose", action
="store_true")
691 help="Verify that the output passed to the program "
692 "as input results in the same output.",
694 parser
.add_argument("--max-workers", type=int, default
="48")
698 help="On test failure, show the content of " "the files and a diff",
700 parser
.add_argument("--mode-flag", type=str)
701 parser
.add_argument("--flags", nargs
=argparse
.REMAINDER
)
703 "--stdin", action
="store_true", help="Pass test input file via stdin"
708 help="Do not include stderr output in the output file",
711 "--batch", action
="store_true", help="Run tests in batches to the test program"
714 "--ignore-error-text",
716 help="Do not compare error text when verifying output",
719 "--only-compare-error-lines",
721 help="Does not care about exact expected error message, "
722 "but only compare the error line numbers.",
727 help="Timeout in seconds for each test, in non-batch mode.",
732 help="Set the FORCE_ERROR_COLOR environment variable, "
733 "which causes the test output to retain terminal escape codes.",
736 "%s looks for a file named HH_FLAGS in the same directory"
737 " as the test files it is executing. If found, the "
738 "contents will be passed as arguments to "
739 "<program> in addition to any arguments "
740 "specified by --flags" % parser
.prog
742 args
: argparse
.Namespace
= parser
.parse_args()
744 max_workers
= args
.max_workers
745 verbose
= args
.verbose
747 dump_on_failure
= args
.diff
748 if os
.getenv("SANDCASTLE") is not None:
749 dump_on_failure
= True
751 if not os
.path
.isfile(args
.program
):
752 raise Exception("Could not find program at %s" % args
.program
)
754 files
: List
[str] = list_test_files(
755 args
.test_path
, args
.disabled_extension
, args
.in_extension
759 raise Exception("Could not find any files to test in " + args
.test_path
)
761 mode_flag
: List
[str] = [] if args
.mode_flag
is None else [args
.mode_flag
]
762 get_flags
: Callable
[[str], List
[str]] = get_flags_cache(args
.flags
)
764 results
: List
[Result
] = run_tests(
766 args
.expect_extension
,
767 args
.fallback_expect_extension
,
771 args
.default_expect_regex
,
773 args
.ignore_error_text
,
778 timeout
=args
.timeout
,
779 only_compare_error_lines
=args
.only_compare_error_lines
,
782 # Doesn't make sense to check failures for idempotence
783 successes
: List
[Result
] = [result
for result
in results
if not result
.is_failure
]
785 if args
.idempotence
and successes
:
786 run_idempotence_tests(
788 args
.expect_extension
,
789 args
.fallback_expect_extension
,
792 args
.default_expect_regex
,