Skip to content

Commit f37caa8

Browse files
authored
Test runner refactor (#508)
* Port test runner script to Python. This allows us to keep the test output in sorted order while still running the tests in parallel. It also now defaults to using the number of available CPU threads for parallel execution, rather than the previously hard-coded default. * Also port decompyle_test.sh script to python within run_tests.py * Fix cmake check target for multi-config generators. Adds testing of release builds on both MSVC and GCC. * Fix diff comparisons on Windows * Ubuntu runners don't have ninja by default
1 parent b939aeb commit f37caa8

File tree

6 files changed

+161
-158
lines changed

6 files changed

+161
-158
lines changed

.github/workflows/linux-ci.yml

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,19 @@ jobs:
1111
- uses: actions/checkout@v1
1212
- name: Configure and Build
1313
run: |
14-
mkdir build && cd build
15-
cmake -DCMAKE_BUILD_TYPE=Debug ..
16-
make -j4
14+
(
15+
mkdir build-debug && cd build-debug
16+
cmake -DCMAKE_BUILD_TYPE=Debug ..
17+
make -j4
18+
)
19+
20+
(
21+
mkdir build-release && cd build-release
22+
cmake -DCMAKE_BUILD_TYPE=Debug ..
23+
make -j4
24+
)
25+
1726
- name: Test
1827
run: |
19-
cd build
20-
make check JOBS=4
28+
cmake --build build-debug --target check
29+
cmake --build build-release --target check

.github/workflows/msvc-ci.yml

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,10 @@ jobs:
1717
cmake --build . --config Debug
1818
cmake --build . --config Release
1919
20-
# This should probably be fixed to work from MSVC without needing to
21-
# use a bash shell and the GNU userland tools... But for now, the
22-
# GH Actions environment provides what we need.
2320
- name: Test
2421
run: |
25-
cd build\Debug
26-
bash.exe ..\..\tests\all_tests.sh
27-
env:
28-
PYTHON_EXE: python.exe
29-
JOBS: 4
22+
cmake --build build --config Debug --target check
23+
cmake --build build --config Release --target check
3024
3125
- name: Upload artifact
3226
uses: actions/upload-artifact@v3

CMakeLists.txt

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@ target_link_libraries(pycdc pycxx)
7676
install(TARGETS pycdc
7777
RUNTIME DESTINATION bin)
7878

79-
add_custom_target(check "${CMAKE_CURRENT_SOURCE_DIR}/tests/all_tests.sh"
80-
WORKING_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}")
81-
add_dependencies(check pycdc)
79+
find_package(Python3 3.6 COMPONENTS Interpreter)
80+
if(Python3_FOUND)
81+
add_custom_target(check
82+
COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_tests.py"
83+
WORKING_DIRECTORY "$<TARGET_FILE_DIR:pycdc>")
84+
add_dependencies(check pycdc)
85+
endif()

tests/all_tests.sh

Lines changed: 0 additions & 10 deletions
This file was deleted.

tests/decompyle_test.sh

Lines changed: 0 additions & 132 deletions
This file was deleted.

tests/run_tests.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
#!/usr/bin/env python3
2+
3+
import os
4+
import sys
5+
import glob
6+
import difflib
7+
import argparse
8+
import subprocess
9+
import multiprocessing
10+
11+
TEST_DIR = os.path.dirname(os.path.realpath(__file__))
12+
SCRIPTS_DIR = os.path.realpath(os.path.join(TEST_DIR, '..', 'scripts'))
13+
14+
def decompyle_one(test_name, pyc_file, outdir, tokenized_expect):
15+
out_base = os.path.join(outdir, os.path.basename(pyc_file))
16+
proc = subprocess.run(
17+
[os.path.join(os.getcwd(), 'pycdc'), pyc_file, '-o', out_base + '.src.py'],
18+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True,
19+
encoding='utf-8', errors='replace')
20+
pycdc_output = proc.stdout
21+
if proc.returncode != 0 or pycdc_output:
22+
with open(out_base + '.err', 'w') as errfile:
23+
errfile.write(pycdc_output)
24+
return False, [pycdc_output]
25+
elif os.path.exists(out_base + '.err'):
26+
os.unlink(out_base + '.err')
27+
28+
proc = subprocess.run(
29+
[sys.executable, os.path.join(SCRIPTS_DIR, 'token_dump'), out_base + '.src.py'],
30+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True,
31+
encoding='utf-8', errors='replace')
32+
tokenized = proc.stdout
33+
token_dump_err = proc.stderr
34+
with open(out_base + '.tok.txt', 'w') as tokfile:
35+
tokfile.write(tokenized)
36+
if proc.returncode != 0 or token_dump_err:
37+
with open(out_base + '.tok.err', 'w') as errfile:
38+
errfile.write(token_dump_err)
39+
return False, [token_dump_err]
40+
elif os.path.exists(out_base + '.tok.err'):
41+
os.unlink(out_base + '.tok.err')
42+
43+
if tokenized != tokenized_expect:
44+
fromfile = 'tokenized/{}.txt'.format(test_name)
45+
tofile = 'tests-out/{}.tok.txt'.format(os.path.basename(pyc_file))
46+
diff = difflib.unified_diff(tokenized_expect.splitlines(True), tokenized.splitlines(True),
47+
fromfile=fromfile, tofile=tofile)
48+
diff = list(diff)
49+
with open(out_base + '.tok.diff', 'w') as diff_file:
50+
diff_file.writelines(diff)
51+
return False, ['Tokenized output does not match expected output:\n'] + diff
52+
53+
return True, []
54+
55+
56+
def run_test(test_file):
57+
"""
58+
Runs a single test, and returns a tuple containing the number of failed
59+
tests and the output of the test. The output is not printed directly
60+
in order to avoid interleaving output from multiple parallel tests.
61+
"""
62+
test_name = os.path.splitext(os.path.basename(test_file))[0]
63+
compiled_files = glob.glob(os.path.join(TEST_DIR, 'compiled', test_name + '.?.*.pyc'))
64+
xfail_files = glob.glob(os.path.join(TEST_DIR, 'xfail', test_name + '.?.*.pyc'))
65+
if not compiled_files and not xfail_files:
66+
return 1, 'No compiled/xfail modules found for {}\n'.format(test_name)
67+
68+
outdir = os.path.join(os.getcwd(), 'tests-out')
69+
os.makedirs(outdir, exist_ok=True)
70+
71+
with open(os.path.join(TEST_DIR, 'tokenized', test_name + '.txt'), 'r',
72+
encoding='utf-8', errors='replace') as tok_file:
73+
tokenized_expect = tok_file.read()
74+
75+
status_line = '\033[1m*** {}:\033[0m '.format(test_name)
76+
errlines = []
77+
fails = 0
78+
xfails = 0
79+
upass = 0
80+
for xpass_file in compiled_files:
81+
ok, errs = decompyle_one(test_name, xpass_file, outdir, tokenized_expect)
82+
if not ok:
83+
fails += 1
84+
errlines.append('\t\033[31m{}\033[0m\n'.format(os.path.basename(xpass_file)))
85+
errlines.extend(errs)
86+
for xfail_file in xfail_files:
87+
ok, _ = decompyle_one(test_name, xfail_file, outdir, tokenized_expect)
88+
if not ok:
89+
xfails += 1
90+
else:
91+
upass += 1
92+
93+
if fails == 0:
94+
if xfails != 0:
95+
if not compiled_files:
96+
status_line += '\033[33mXFAIL ({})\033[0m\n'.format(xfails)
97+
else:
98+
status_line += '\033[32mPASS ({})\033[33m + XFAIL ()\033[0m\n' \
99+
.format(len(compiled_files), xfails)
100+
else:
101+
status_line += '\033[32mPASS ({})\033[0m\n'.format(len(compiled_files))
102+
else:
103+
if xfails != 0:
104+
status_line += '\033[31mFAIL ({} of {})\033[33m + XFAIL ({})\033[0m\n' \
105+
.format(fails, len(compiled_files), xfails)
106+
else:
107+
status_line += '\033[31mFAIL ({} of {})\033[0m\n'.format(fails, len(compiled_files))
108+
109+
return fails, [status_line] + errlines
110+
111+
112+
def main():
113+
# For simpler invocation from CMake's check target, we also support setting
114+
# these parameters via environment variables.
115+
default_jobs = int(os.environ['JOBS']) if 'JOBS' in os.environ else multiprocessing.cpu_count()
116+
default_filter = os.environ['FILTER'] if 'FILTER' in os.environ else ''
117+
118+
parser = argparse.ArgumentParser()
119+
parser.add_argument('--jobs', '-j', type=int, default=default_jobs,
120+
help='Number of tests to run in parallel (default: {})'.format(default_jobs))
121+
parser.add_argument('--filter', type=str, default=default_filter,
122+
help='Run only test(s) matching the supplied filter')
123+
args = parser.parse_args()
124+
125+
glob_pattern = '*{}*.txt'.format(args.filter) if args.filter else '*.txt'
126+
test_files = sorted(glob.iglob(os.path.join(TEST_DIR, 'tokenized', glob_pattern)))
127+
total_fails = 0
128+
with multiprocessing.Pool(args.jobs) as pool:
129+
for fails, output in pool.imap(run_test, test_files):
130+
total_fails += fails
131+
sys.stdout.writelines(output)
132+
133+
if total_fails:
134+
print('{} test(s) failed'.format(total_fails))
135+
sys.exit(1)
136+
137+
if __name__ == '__main__':
138+
main()

0 commit comments

Comments
 (0)