{format_summary_for_html(result.get('summary'))}
72 |├── requirements.txt
├── .gitignore
├── compile_and_run.sh
├── utils
├── matam_types.py
├── loading_bar.py
├── config.py
├── matam_parsing.py
└── matam_html.py
├── example
└── custom_tests.json
├── .github
└── workflows
│ └── main.yml
├── README.md
└── run_tests.py
/requirements.txt:
--------------------------------------------------------------------------------
1 | pyinstaller
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /dist/
2 | .idea/
3 | build/
4 | **.spec
--------------------------------------------------------------------------------
/compile_and_run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | g++ -DNDEBUG -std=c++17 -Wall -pedantic-errors -Werror -g -o mtm_blockchain *.cpp
4 | python3 ./tests/run_tests.py ./mtm_blockchain ./tests/custom_tests.json
--------------------------------------------------------------------------------
/utils/matam_types.py:
--------------------------------------------------------------------------------
1 | import sys
2 | if sys.version_info < (3, 10):
3 | sys.exit("Python %s.%s or later is required.\n" % (3, 10))
4 | else:
5 | from typing import TypedDict, TypeAlias, List
6 |
7 | TestTemplates: TypeAlias = dict[str, str]
8 | TestParams: TypeAlias = dict[str, str]
9 |
10 | class TestParamRange(TypedDict):
11 | first: int
12 | last: int
13 |
14 |
15 | class TestCase(TypedDict):
16 | name: str
17 | template: str
18 | params: TestParams
19 | output_file: str
20 | expected_output_file: str
21 | expected_output_is_substring: bool | None
22 | run_leaks: bool | None
23 | params_range: TestParamRange | List[str] | None
24 |
25 |
26 | class TestFile(TypedDict):
27 | templates: TestTemplates
28 | tests: list[TestCase]
29 |
30 |
31 | class Summary(TypedDict):
32 | title: str
33 | actual: str | None
34 | expected: str | None
35 | error: str | None
36 | diff_html: str | None
37 |
38 |
39 | class TestResult(TypedDict):
40 | name: str
41 | summary: Summary
42 | passed: bool
43 | command: str | None
44 |
45 |
--------------------------------------------------------------------------------
/utils/loading_bar.py:
--------------------------------------------------------------------------------
1 | # Taken from https://stackoverflow.com/a/34325723
2 |
3 |
4 | # Print iterations progress
5 | def print_progress_bar(iteration, total, prefix='', suffix='', decimals=1, length=100, fill='█',
6 | print_end="\r"):
7 | """
8 | Call in a loop to create terminal progress bar
9 | @params:
10 | iteration - Required : current iteration (Int)
11 | total - Required : total iterations (Int)
12 | prefix - Optional : prefix string (Str)
13 | suffix - Optional : suffix string (Str)
14 | decimals - Optional : positive number of decimals in percent complete (Int)
15 | length - Optional : character length of bar (Int)
16 | fill - Optional : bar fill character (Str)
17 | printEnd - Optional : end character (e.g. "\r", "\r\n") (Str)
18 | """
19 | percent = ("{0:." + str(decimals) + "f}").format(100 * (iteration / float(total)))
20 | filled_length = int(length * iteration // total)
21 | bar = fill * filled_length + '-' * (length - filled_length)
22 | print(f'\r{prefix} |{bar}| {percent}% {suffix} ({iteration}/{total})', end=print_end,
23 | flush=True)
24 | # Print New Line on Complete
25 | if iteration == total:
26 | print(flush=True)
27 |
--------------------------------------------------------------------------------
/example/custom_tests.json:
--------------------------------------------------------------------------------
1 | {
2 | "templates": {
3 | "verify": "verify :::source::: :::target::: > :::out:::",
4 | "hash": "hash :::source::: :::target:::",
5 | "compress": "compress :::source::: :::target:::",
6 | "format": "format :::source::: :::target:::"
7 | },
8 | "tests": [
9 | {
10 | "name": "Verify Example Test",
11 | "template": "verify",
12 | "params": {
13 | "source": "./tests/verify.source",
14 | "target": "./tests/verify.target",
15 | "out": "./tests/verify.out"
16 | },
17 | "output_file": "./tests/verify.out",
18 | "expected_output_file": "./tests/verify.expected"
19 | },
20 | {
21 | "name": "Hash Example Test",
22 | "template": "hash",
23 | "params": {
24 | "source": "./tests/hash.source",
25 | "target": "./tests/hash.target.out"
26 | },
27 | "output_file": "./tests/hash.target.out",
28 | "expected_output_file": "./tests/hash.target.expected"
29 | },
30 | {
31 | "name": "Compress Example Test",
32 | "template": "compress",
33 | "params": {
34 | "source": "./tests/compress.source",
35 | "target": "./tests/compress.target.out"
36 | },
37 | "output_file": "./tests/compress.target.out",
38 | "expected_output_file": "./tests/compress.target.expected"
39 | },
40 | {
41 | "name": "Format Example Test",
42 | "template": "format",
43 | "params": {
44 | "source": "./tests/format.source",
45 | "target": "./tests/format.target.out"
46 | },
47 | "output_file": "./tests/format.target.out",
48 | "expected_output_file": "./tests/format.target.expected"
49 | }
50 | ]
51 | }
--------------------------------------------------------------------------------
/utils/config.py:
--------------------------------------------------------------------------------
1 | from platform import system
2 | from os import environ
3 |
4 | IS_MAC_OS = system() == 'Darwin'
5 |
6 | LEAKS_CHECKER_NAME = 'leaks' if IS_MAC_OS else 'Valgrind'
7 | LEAKS_CHECKER_COMMAND = 'export MallocStackLogging=1 && leaks --atExit --' \
8 | if IS_MAC_OS else 'valgrind --leak-check=full'
9 |
10 | NO_LEAKS_FOUND_TEXT = '0 leaks for 0 total leaked bytes.' if IS_MAC_OS else 'no leaks are possible'
11 |
12 |
13 | # Define constants
14 | STDOUT = 0
15 | STDERR = 1
16 |
17 | TESTS_JSON_FILE_INDEX = 1
18 | EXECUTABLE_INDEX = 2
19 | EXPECTED_ARGS_AMOUNT = 3
20 |
21 | HTML_COLORED_NEWLINE = '\\n
'
22 | HTML_COLORED_WHITESPACE = ' ' # burnt orange
23 | NORMAL_HTML_NEWLINE = '
'
24 |
25 | TEST_NAME = 'name'
26 | TEMPLATE_NAME = 'template'
27 | PARAMS = 'params'
28 | OUTPUT_FILE = 'output_file'
29 | EXPECTED_OUTPUT_FILE = 'expected_output_file'
30 | EXPECTED_OUTPUT_IS_SUBSTR = 'expected_output_is_substring'
31 | TEMP_REPORT = 'test_results_current.html'
32 | FINAL_REPORT = 'test_results.html'
33 |
34 | TIMEOUT = int(environ.get('MATAM_TESTER_TEST_TIMEOUT', '1')) # 1 second
35 | VALGRIND_TIMEOUT = int(environ.get('MATAM_TESTER_VALGRIND_TIMEOUT', '2')) # 2 seconds
36 |
37 | COMPARISON_TRIM_END_SPACES = int(environ.get('MATAM_TESTER_TRIMR_SPACES', '0'))
38 | COMPARISON_IGNORE_BLANK_LINES = int(environ.get('MATAM_TESTER_IGNORE_EMPTY_LINES', '0'))
39 |
40 | RUN_MULTI_THREAD = int(environ.get('MATAM_TESTER_RUN_MULTI_THREADED', '0')) == 1
41 | EXPORT_TEMP_REPORT = int(environ.get('MATAM_TESTER_EXPORT_TEMP_REPORT', '0')) == 1
42 |
43 | USE_OLD_DIFF_STYLE = int(environ.get('MATAM_TESTER_USE_OLD_DIFF_STYLE', '0')) == 1
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Build and Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | env:
9 | GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }}
10 |
11 | jobs:
12 | build:
13 | runs-on: ${{ matrix.os }}
14 | strategy:
15 | matrix:
16 | os: [ windows-latest, ubuntu-latest, macos-latest ]
17 | python-version: [3.x]
18 | steps:
19 | - name: Checkout repository
20 | uses: actions/checkout@v4
21 | - name: Set up Python ${{ matrix.python-version }}
22 | uses: actions/setup-python@v5
23 | with:
24 | python-version: ${{ matrix.python-version }}
25 |
26 | - name: Install dependencies
27 | run: |
28 | python -m pip install --upgrade pip
29 | pip install -r requirements.txt
30 |
31 | - name: Compile Python code to executable
32 | run: pyinstaller --paths=$(python -c "import site; print(site.getsitepackages())") --onefile run_tests.py -n student_test --distpath ./LocalGradescope
33 |
34 | - name: Upload artifact
35 | uses: actions/upload-artifact@v4
36 | with:
37 | name: ${{ runner.os }} Local Gradescope
38 | path: dist
39 | - name: Zip up output
40 | if: runner.os == 'Windows'
41 | run: |
42 | cd LocalGradescope
43 | Compress-Archive -Path . -DestinationPath ../LocalGradescope-${{ runner.os }}-${{ github.ref_name }}.zip
44 | - name: Zip up output
45 | if: runner.os != 'Windows'
46 | run: |
47 | cd LocalGradescope
48 | zip -r ../LocalGradescope-${{ runner.os }}-${{ github.ref_name }}.zip *
49 | - name: Release
50 | uses: softprops/action-gh-release@v2
51 | with:
52 | token: ${{ env.GIT_ACCESS_TOKEN }}
53 | files: LocalGradescope-${{ runner.os }}-${{ github.ref_name }}.zip
54 |
--------------------------------------------------------------------------------
/utils/matam_parsing.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | from utils.config import IS_MAC_OS, EXPECTED_OUTPUT_FILE, EXPECTED_OUTPUT_IS_SUBSTR, NORMAL_HTML_NEWLINE, \
4 | LEAKS_CHECKER_NAME
5 | from utils.matam_types import Summary, TestCase, TestParamRange, TestParams
6 |
7 | if sys.version_info < (3, 10):
8 | sys.exit("Python %s.%s or later is required.\n" % (3, 10))
9 | else:
10 | from typing import List, Any, Iterable
11 |
12 | def normalize_newlines(txt: str) -> str:
13 | return txt.replace('\r\n', '\n').replace('\r', '\n')
14 |
15 |
16 | def test_exception_to_error_text(exception: Exception) -> str:
17 | if exception.stderr:
18 | return str(exception.stderr)
19 | if exception.stdout:
20 | return str(exception.stdout)
21 | return str(exception)
22 |
23 |
24 | def remove_error_pipes_from_command(command: str) -> str:
25 | """
26 | Avoid pipe of stderr to output file to allow leaks test to work as normal
27 | :param command:
28 | :return:
29 | """
30 | command_without_err_pipe: str = command
31 | # macOs uses leaks which uses stdout, thus we need to undo the piping of it
32 | if IS_MAC_OS:
33 | index_of_out_pipe = command_without_err_pipe.rfind('>')
34 | if index_of_out_pipe != -1:
35 | command_without_err_pipe = command_without_err_pipe[:index_of_out_pipe]
36 | index_of_err_out_pipe = command_without_err_pipe.rfind('&>')
37 | if index_of_err_out_pipe != -1:
38 | command_without_err_pipe = command_without_err_pipe[:index_of_err_out_pipe]
39 | index_of_err_pipe = command_without_err_pipe.rfind('2>')
40 | if index_of_err_pipe != -1:
41 | command_without_err_pipe = command_without_err_pipe[:index_of_err_pipe]
42 | # If stderr is piped to stdout, remove said piping
43 | return command_without_err_pipe.replace('&>1', '>').replace('2>&1', '')
44 | else:
45 | index_of_err_pipe = command_without_err_pipe.rfind('2>')
46 | index_of_out_pipe = command_without_err_pipe.rfind('>')
47 |
48 | if index_of_err_pipe != -1:
49 | if index_of_out_pipe > index_of_err_pipe:
50 | command_without_err_pipe = command_without_err_pipe[
51 | :index_of_err_pipe] + command_without_err_pipe[
52 | index_of_out_pipe:]
53 | else:
54 | command_without_err_pipe = command_without_err_pipe[:index_of_err_pipe]
55 |
56 | # If stderr is piped to stdout, remove said piping
57 | return command_without_err_pipe.replace('&>1', '>').replace('2>&1', '')
58 |
59 |
60 | def summarize_failed_test(test_name: str, expected_output: str, actual_output: str, diff_html: str) -> Summary:
61 | return Summary(
62 | title=f"{test_name} - Failed!",
63 | expected=expected_output,
64 | actual=actual_output,
65 | error=None,
66 | diff_html=diff_html
67 | )
68 |
69 |
70 | def summarize_failed_test_due_to_exception(test_name: str, expected_output: str,
71 | exception: str) -> Summary:
72 | return Summary(
73 | title=f"{test_name} - Failed due to an error in the tester!",
74 | expected=expected_output,
75 | error=exception
76 | )
77 |
78 |
79 | def summarize_failed_to_check_for_leaks(test_name: str, exception: str) -> Summary:
80 | return Summary(
81 | title=f'{test_name} has leaks!{NORMAL_HTML_NEWLINE}Failed due to an error raised by {LEAKS_CHECKER_NAME}!',
82 | error=exception
83 | )
84 |
85 |
86 | def parse_test_placeholders(field: str, ranged_value: Any) -> str:
87 | return field.replace(':::placeholder:::', str(ranged_value))
88 |
89 |
90 | def parse_ranged_tests(tests: List[TestCase]) -> List[TestCase]:
91 | for index, test in enumerate(tests):
92 | test_range: TestParamRange | List[str] | None = test.get('params_range', None)
93 | if test_range:
94 | ranged_values: Iterable[Any]
95 | if type(test_range) == dict:
96 | ranged_values = range(test_range['first'], test_range['last'] + 1)
97 | # In this case it will be a list of strs
98 | else:
99 | ranged_values = test_range
100 | for range_item in ranged_values:
101 | parsed_params: TestParams = dict()
102 | for name, value in test['params'].items():
103 | parsed_value = parse_test_placeholders(value, range_item)
104 | parsed_params[name] = parsed_value
105 |
106 | parsed_test = {
107 | 'name': parse_test_placeholders(test['name'], range_item),
108 | 'template': test['template'],
109 | 'params': parsed_params,
110 | 'output_file': parse_test_placeholders(test['output_file'], range_item),
111 | EXPECTED_OUTPUT_FILE: parse_test_placeholders(test[EXPECTED_OUTPUT_FILE], range_item),
112 | 'run_leaks': test.get('run_leaks', None),
113 | EXPECTED_OUTPUT_IS_SUBSTR: test.get(EXPECTED_OUTPUT_IS_SUBSTR, False)
114 | }
115 |
116 | tests.append(parsed_test)
117 |
118 | # Remove unparsed tests
119 | return [test for test in tests if 'params_range' not in test]
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MatamGenericTester
2 |
3 | # Configuration
4 | - templates
5 | Test command templates, in which parameters are spedified by being wrapped in ':::' (on both sides)
6 | each template has a name as a key, and the test command template as its value.
7 | example:
8 | `
9 | "templates": {
10 | "some_cmd_template": "foo :::param1::: :::param2::: > :::param3:::",
11 | "another_cmd_template": "bar :::a_param::: :::another_param::: > :::lol_param:::"
12 | }
13 | `
14 | - tests
15 | A lish of tests to be ran by the tester, each one has its own configuration
16 |
17 | ## Test Object configuration:
18 | - name
19 | Test name, will be used by the report generated by the tester
20 | - template
21 | Name of the command template to be used for the test
22 | - params
23 | Key-Value pairs of test params, that will be used (will replace the placeholders in the template)
24 | Key is param name (without the ':::' wrappers) and value is param value.
25 | - output_file
26 | Path For the program to output the test's result
27 | - expected_output_file
28 | Path for a file containing the expected output of the test
29 | # Optional Test Object Config:
30 | - expected_output_is_substring:
31 | Boolean. If set to true, tester will consider test as successful if the test's output contains the expected output, instead of checking if they are outright the same
32 | - run_leaks
33 | Boolean, default is true.
34 | If set to false, will not run leak analysis on the test.
35 | - params_range
36 | Can be used to generate multiple test objects based on the same structure.
37 | Useful in case where user wants to create multiple tests that only vary by output/input file's name, but otherwise use the same templates, params, etc.
38 | Put the string ':::placeholder:::' in each place where the genreated test should get a generated value.
39 | Placeholder can be used in fields: 'name', 'output_file', 'expected_output_file' and parameters' values.
40 | Can be a dictionary (with int values) or a list of strings:
41 | ### Dictionary:
42 | -- first: lower numerical value
43 | -- last: upper numerical value
44 | This option is useful to generate multiple tests varying only by a number, where said number is a range starting with 'first' and ending with 'last'
45 | Example:
46 | `
47 | [{
48 | "name": "Example Test :::placeholder:::",
49 | "template": "game",
50 | "params": {
51 | "events": "example:::placeholder:::.events",
52 | "players": "example:::placeholder:::.players",
53 | "out": "example:::placeholder:::.out"
54 | },
55 | "params_range": {
56 | "first": 1,
57 | "last": 3
58 | },
59 | "run_leaks": true,
60 | "output_file": "example:::placeholder:::.out",
61 | "expected_output_file": "example:::placeholder:::.expected"
62 | }]
63 | `
64 | What will be generated:
65 | `
66 | [
67 | {
68 | "name": "Example Test 1",
69 | "template": "game",
70 | "params": {
71 | "events": "example1.events",
72 | "players": "example1.players",
73 | "out": "example1.out"
74 | },
75 | "run_leaks": true,
76 | "output_file": "example1.out",
77 | "expected_output_file": "example1.expected"
78 | },
79 | {
80 | "name": "Example Test 2",
81 | "template": "game",
82 | "params": {
83 | "events": "example2.events",
84 | "players": "example2.players",
85 | "out": "example2.out"
86 | },
87 | "run_leaks": true,
88 | "output_file": "example2.out",
89 | "expected_output_file": "example2.expected"
90 | },
91 | {
92 | "name": "Example Test 3",
93 | "template": "game",
94 | "params": {
95 | "events": "example3.events",
96 | "players": "example3.players",
97 | "out": "example3.out"
98 | },
99 | "run_leaks": true,
100 | "output_file": "example3.out",
101 | "expected_output_file": "example3.expected"
102 | },
103 | ]
104 | `
105 |
106 |
107 | ### List:
108 | Each string in the list is used to replace the placeholder in one of the tests.
109 | Example:
110 | `
111 | [{
112 | "name": ":::placeholder:::",
113 | "template": "game",
114 | "params": {
115 | "events": ":::placeholder:::.events",
116 | "players": ":::placeholder:::.players",
117 | "out": ":::placeholder:::.out"
118 | },
119 | "params_range": [
120 | "test4-event-error-first",
121 | "test5-generic-game",
122 | ],
123 | "run_leaks": true,
124 | "output_file": ":::placeholder:::.out",
125 | "expected_output_file": ":::placeholder:::.expected"
126 | }]
127 | `
128 | What will be generated:
129 | `
130 | [{
131 | "name": "test4-event-error-first",
132 | "template": "game",
133 | "params": {
134 | "events": "test4-event-error-first.events",
135 | "players": "test4-event-error-first.players",
136 | "out": "test4-event-error-first.out"
137 | },
138 | "run_leaks": true,
139 | "output_file": "test4-event-error-first.out",
140 | "expected_output_file": "test4-event-error-first.expected"
141 | },
142 | {
143 | "name": "test5-generic-game",
144 | "template": "game",
145 | "params": {
146 | "events": "test5-generic-game.events",
147 | "players": "test5-generic-game.players",
148 | "out": "test5-generic-game.out"
149 | },
150 | "run_leaks": true,
151 | "output_file": "test5-generic-game.out",
152 | "expected_output_file": "test5-generic-game.expected"
153 | }
154 | ]
155 | `
156 |
157 | ## Environment Variables
158 | - MATAM_TESTER_TEST_TIMEOUT
159 | Time (in seconds) until each test should be killed. Default is 1.
160 | - MATAM_TESTER_VALGRIND_TIMEOUT
161 | Time (in seconds) until each leak test should be killed. Default is 2.
162 | - MATAM_TESTER_TRIMR_SPACES
163 | Should ignore whitespaces in the start/end of lines when comparing results. Default is 0.
164 | - MATAM_TESTER_IGNORE_EMPTY_LINES
165 | Should ignore empty lines when comparing results. Default is 0.
166 | - MATAM_TESTER_RUN_MULTI_THREADED
167 | Should run tests using multiple threads. Default is 0.
168 | - MATAM_TESTER_EXPORT_TEMP_REPORT
169 | Should create a temporary report while before all tests are done, that is updated after every test. Useful when all tests combined take a long time to run. Can only be used when running in single thread mode.
170 | Default is 0.
171 |
--------------------------------------------------------------------------------
/utils/matam_html.py:
--------------------------------------------------------------------------------
1 | from utils.config import NORMAL_HTML_NEWLINE, HTML_COLORED_NEWLINE, HTML_COLORED_WHITESPACE, USE_OLD_DIFF_STYLE
2 | from utils.matam_types import Summary, TestResult
3 | from os import getcwd, chdir
4 | if not USE_OLD_DIFF_STYLE:
5 | import html
6 | import difflib
7 |
8 |
9 | def simple_html_format(text: str) -> str:
10 | return text.replace('<', '<').replace('>', '>').replace('\n', '
')
11 |
12 |
13 | def format_test_string_for_html(field: str, field_title: str) -> str:
14 | less_than = '<'
15 | greater_than = '>'
16 | field = field.replace(less_than, '<').replace(greater_than, '>')
17 | field = field.replace(' \n', f'{HTML_COLORED_WHITESPACE}\n')
18 | field = field.replace('\n\n', f'{HTML_COLORED_NEWLINE}{NORMAL_HTML_NEWLINE}')
19 | if field.endswith('\n'):
20 | field = field[:-1] + HTML_COLORED_NEWLINE
21 | if field.endswith(' '):
22 | field = field[:-1] + HTML_COLORED_WHITESPACE
23 | field = field.replace('\n', NORMAL_HTML_NEWLINE)
24 | return f'
{field_title}
{field}
{summary.get("title")}
' 29 | 30 | # If there’s a pre-rendered diff HTML, include it directly (no escaping) 31 | diff_html: str = summary.get("diff_html") 32 | error: str = summary.get("error") 33 | if not USE_OLD_DIFF_STYLE and diff_html is not None and error is None: 34 | report += f'Test Command:
{simple_html_format(result['command'])}" \
65 | if result.get('command', None) else ''
66 | html += f'''
67 |
69 | {format_summary_for_html(result.get('summary'))}
72 |