# Usage: `run_tests.py tests_dir` OR `run_tests.py test_file.fc` # from current dir, providing some env (see getenv() calls). # Every .fc file should provide {- testcase description in a comment -}, consider tests/ folder. # # Tests for FunC can be # * positive (compiled to .fif, run with fift, compared output with the one expected) # * negative (compilation fails, and it's expected; patterns in stderr can be specified) # # Note, that there is also run_tests.js to test FunC compiled to WASM. # Don't forget to keep it identical to Python version! import os import os.path import re import shutil import subprocess import sys import tempfile def getenv(name, default=None): if name in os.environ: return os.environ[name] if default is None: print("Environment variable", name, "is not set", file=sys.stderr) exit(1) return default FUNC_EXECUTABLE = getenv("FUNC_EXECUTABLE", "func") FIFT_EXECUTABLE = getenv("FIFT_EXECUTABLE", "fift") FIFT_LIBS_FOLDER = getenv("FIFTPATH") # this env is needed for fift to work properly TMP_DIR = tempfile.mkdtemp() class CmdLineOptions: def __init__(self, argv: list[str]): if len(argv) != 2: print("Usage: run_tests.py tests_dir OR run_tests.py test_file.fc", file=sys.stderr) exit(1) if not os.path.exists(argv[1]): print("Input '%s' doesn't exist" % argv[1], file=sys.stderr) exit(1) if os.path.isdir(argv[1]): self.tests_dir = argv[1] self.test_file = None else: self.tests_dir = os.path.dirname(argv[1]) self.test_file = argv[1] def find_tests(self) -> list[str]: if self.test_file is not None: # an option to run (debug) a single test return [self.test_file] tests = [f for f in os.listdir(self.tests_dir) if f.endswith(".fc") or f.endswith(".func")] tests.sort() return [os.path.join(self.tests_dir, f) for f in tests] class ParseInputError(Exception): pass class FuncCompilationFailedError(Exception): def __init__(self, message: str, stderr: str): super().__init__(message) self.stderr = stderr class FuncCompilationSucceededError(Exception): pass class FiftExecutionFailedError(Exception): def __init__(self, message: str, stderr: str): super().__init__(message) self.stderr = stderr class CompareOutputError(Exception): def __init__(self, message: str, output: str): super().__init__(message) self.output = output class CompareFifCodegenError(Exception): pass class CompareCodeHashError(Exception): pass class FuncTestCaseInputOutput: """ In positive tests, there are several testcases "input X should produce output Y". They are written as a table: TESTCASE | method_id | input (one or several) | output """ reJustNumber = re.compile("[-+]?\d+") reMathExpr = re.compile("[0x123456789()+\-*/<>]+") def __init__(self, method_id_str: str, input_str: str, output_str: str): processed_inputs = [] for in_arg in input_str.split(" "): if len(in_arg) == 0: continue elif in_arg.startswith("x{") or FuncTestCaseInputOutput.reJustNumber.fullmatch(in_arg): processed_inputs.append(in_arg) elif FuncTestCaseInputOutput.reMathExpr.fullmatch(in_arg): processed_inputs.append(str(eval(in_arg))) else: raise ParseInputError("'%s' can't be evaluated" % in_arg) self.method_id = int(method_id_str) self.input = " ".join(processed_inputs) self.expected_output = output_str def check(self, stdout_lines: list[str], line_idx: int): if stdout_lines[line_idx] != self.expected_output: raise CompareOutputError("error on case %d: expected '%s', found '%s'" % (line_idx + 1, self.expected_output, stdout_lines[line_idx]), "\n".join(stdout_lines)) class FuncTestCaseStderr: """ @stderr checks, when compilation fails, that stderr (compilation error) is expected. If it's multiline, all lines must be present in specified order. """ def __init__(self, stderr_pattern: list[str], avoid: bool): self.stderr_pattern = stderr_pattern self.avoid = avoid def check(self, stderr: str): line_match = self.find_pattern_in_stderr(stderr.splitlines()) if line_match == -1 and not self.avoid: raise CompareOutputError("pattern not found in stderr:\n%s" % "\n".join(map(lambda x: " " + x, self.stderr_pattern)), stderr) elif line_match != -1 and self.avoid: raise CompareOutputError("pattern found (line %d), but not expected to be:\n%s" % (line_match + 1, "\n".join(map(lambda x: " " + x, self.stderr_pattern))), stderr) def find_pattern_in_stderr(self, stderr: list[str]) -> int: for line_start in range(len(stderr)): if self.try_match_pattern(0, stderr, line_start): return line_start return -1 def try_match_pattern(self, pattern_offset: int, stderr: list[str], offset: int) -> bool: if pattern_offset >= len(self.stderr_pattern): return True if offset >= len(stderr): return False line_pattern = self.stderr_pattern[pattern_offset] line_output = stderr[offset] return line_output.find(line_pattern) != -1 and self.try_match_pattern(pattern_offset + 1, stderr, offset + 1) class FuncTestCaseFifCodegen: """ @fif_codegen checks that contents of compiled.fif matches the expected pattern. @fif_codegen_avoid checks that is does not match the pattern. The pattern is a multiline piece of fift code, optionally with "..." meaning "any lines here". See tests/codegen_check_demo.fc of how it looks. A notable thing about indentations (spaces at line starts): Taking them into account will complicate the code without reasonable profit, that's why we just trim every string. And one more word about //comments. FunC inserts them into fift output. If a line in the pattern contains a //comment, it's expected to be equal. If a line does not, we just compare a command. """ def __init__(self, fif_pattern: list[str], avoid: bool): self.fif_pattern = [s.strip() for s in fif_pattern] self.avoid = avoid def check(self, fif_output: list[str]): # in case there are no comments at all (-S not set, or from wasm), drop them from fif_pattern has_comments = any("//" in line and "generated from" not in line for line in fif_output) if not has_comments: self.fif_pattern = [FuncTestCaseFifCodegen.split_line_to_cmd_and_comment(s)[0] for s in self.fif_pattern] self.fif_pattern = [s for s in self.fif_pattern if s != ""] line_match = self.find_pattern_in_fif_output(fif_output) if line_match == -1 and not self.avoid: raise CompareFifCodegenError("pattern not found:\n%s" % "\n".join(map(lambda x: " " + x, self.fif_pattern))) elif line_match != -1 and self.avoid: raise CompareFifCodegenError("pattern found (line %d), but not expected to be:\n%s" % (line_match + 1, "\n".join(map(lambda x: " " + x, self.fif_pattern)))) def find_pattern_in_fif_output(self, fif_output: list[str]) -> int: for line_start in range(len(fif_output)): if self.try_match_pattern(0, fif_output, line_start): return line_start return -1 def try_match_pattern(self, pattern_offset: int, fif_output: list[str], offset: int) -> bool: if pattern_offset >= len(self.fif_pattern): return True if offset >= len(fif_output): return False line_pattern = self.fif_pattern[pattern_offset] line_output = fif_output[offset] if line_pattern != "...": if not FuncTestCaseFifCodegen.does_line_match(line_pattern, line_output): return False return self.try_match_pattern(pattern_offset + 1, fif_output, offset + 1) while offset < len(fif_output): if self.try_match_pattern(pattern_offset + 1, fif_output, offset): return True offset = offset + 1 return False @staticmethod def split_line_to_cmd_and_comment(trimmed_line: str) -> tuple[str, str | None]: pos = trimmed_line.find("//") if pos == -1: return trimmed_line, None else: return trimmed_line[:pos].rstrip(), trimmed_line[pos + 2:].lstrip() @staticmethod def does_line_match(line_pattern: str, line_output: str) -> bool: cmd_pattern, comment_pattern = FuncTestCaseFifCodegen.split_line_to_cmd_and_comment(line_pattern) cmd_output, comment_output = FuncTestCaseFifCodegen.split_line_to_cmd_and_comment(line_output.strip()) return cmd_pattern == cmd_output and (comment_pattern is None or comment_pattern == comment_output) class FuncTestCaseExpectedHash: """ @code_hash checks that hash of compiled output.fif matches the provided value. It's used to "record" code boc hash and to check that it remains the same on compiler modifications. Being much less flexible than @fif_codegen, it nevertheless gives a guarantee of bytecode stability. """ def __init__(self, expected_hash: str): self.code_hash = expected_hash def check(self, fif_code_hash: str): if self.code_hash != fif_code_hash: raise CompareCodeHashError("expected %s, actual %s" % (self.code_hash, fif_code_hash)) class FuncTestFile: def __init__(self, func_filename: str, artifacts_folder: str): self.line_idx = 0 self.func_filename = func_filename self.artifacts_folder = artifacts_folder self.compilation_should_fail = False self.stderr_includes: list[FuncTestCaseStderr] = [] self.input_output: list[FuncTestCaseInputOutput] = [] self.fif_codegen: list[FuncTestCaseFifCodegen] = [] self.expected_hash: FuncTestCaseExpectedHash | None = None def parse_input_from_func_file(self): with open(self.func_filename, "r") as fd: lines = fd.read().splitlines() self.line_idx = 0 while self.line_idx < len(lines): line = lines[self.line_idx] if line.startswith("TESTCASE"): s = [x.strip() for x in line.split("|")] if len(s) != 4: raise ParseInputError("incorrect format of TESTCASE: %s" % line) self.input_output.append(FuncTestCaseInputOutput(s[1], s[2], s[3])) elif line.startswith("@compilation_should_fail"): self.compilation_should_fail = True elif line.startswith("@stderr"): self.stderr_includes.append(FuncTestCaseStderr(self.parse_string_value(lines), False)) elif line.startswith("@fif_codegen_avoid"): self.fif_codegen.append(FuncTestCaseFifCodegen(self.parse_string_value(lines), True)) elif line.startswith("@fif_codegen"): self.fif_codegen.append(FuncTestCaseFifCodegen(self.parse_string_value(lines), False)) elif line.startswith("@code_hash"): self.expected_hash = FuncTestCaseExpectedHash(self.parse_string_value(lines, False)[0]) self.line_idx = self.line_idx + 1 if len(self.input_output) == 0 and not self.compilation_should_fail: raise ParseInputError("no TESTCASE present") if len(self.input_output) != 0 and self.compilation_should_fail: raise ParseInputError("TESTCASE present, but compilation_should_fail") def parse_string_value(self, lines: list[str], allow_multiline = True) -> list[str]: # a tag must be followed by a space (single-line), e.g. '@stderr some text' # or be a multi-line value, surrounded by """ line = lines[self.line_idx] pos_sp = line.find(' ') is_multi_line = lines[self.line_idx + 1] == '"""' is_single_line = pos_sp != -1 if not is_single_line and not is_multi_line: raise ParseInputError('%s value is empty (not followed by a string or a multiline """)' % line) if is_single_line and is_multi_line: raise ParseInputError('%s value is both single-line and followed by """' % line[:pos_sp]) if is_multi_line and not allow_multiline: raise ParseInputError("%s value should be single-line" % line) if is_single_line: return [line[pos_sp + 1:].strip()] self.line_idx += 2 s_multiline = [] while self.line_idx < len(lines) and lines[self.line_idx] != '"""': s_multiline.append(lines[self.line_idx]) self.line_idx = self.line_idx + 1 return s_multiline def get_compiled_fif_filename(self): return self.artifacts_folder + "/compiled.fif" def get_runner_fif_filename(self): return self.artifacts_folder + "/runner.fif" def run_and_check(self): res = subprocess.run([FUNC_EXECUTABLE, "-o", self.get_compiled_fif_filename(), "-SPA", self.func_filename], capture_output=True, timeout=10) exit_code = res.returncode stderr = str(res.stderr, "utf-8") stdout = str(res.stdout, "utf-8") if exit_code == 0 and self.compilation_should_fail: raise FuncCompilationSucceededError("compilation succeeded, but it should have failed") if exit_code != 0 and self.compilation_should_fail: for should_include in self.stderr_includes: should_include.check(stderr) return if exit_code != 0 and not self.compilation_should_fail: raise FuncCompilationFailedError("func exit_code = %d" % exit_code, stderr) with open(self.get_runner_fif_filename(), "w") as f: f.write("\"%s\" include