In ./scripts/test.py, readded external commands, tweaked subprocesses

- Added --exec for wrapping the test-runner with external commands, such as
  Qemu or Valgrind.

- Added --valgrind, which just aliases --exec=valgrind with a few extra
  flags useful during testing.

- Dropped the "valgrind" type for tests. These aren't separate tests
  that run in the test-runner, and I don't see a need for disabling
  Valgrind for any tests. This can be added back later if needed.

- Readded support for dropping directly into gdb after a test failure,
  either at the assert failure, entry point of test case, or entry point
  of the test runner with --gdb, --gdb-case, or --gdb-main.

- Added --isolate for running each test permutation in its own process,
  this is required for associating Valgrind errors with the right test
  case.

- Fixed an issue where explicit test identifier conflicted with
  per-stage test identifiers generated as a part of --by-suite and
  --by-case.
This commit is contained in:
Christopher Haster
2022-05-08 09:51:14 -05:00
parent 5a572ced3c
commit d679fbb389
3 changed files with 103 additions and 53 deletions
+6 -15
View File
@@ -235,10 +235,9 @@ static void summary(void) {
char perm_buf[64]; char perm_buf[64];
sprintf(perm_buf, "%zu/%zu", filtered, perms); sprintf(perm_buf, "%zu/%zu", filtered, perms);
char type_buf[64]; char type_buf[64];
sprintf(type_buf, "%s%s%s", sprintf(type_buf, "%s%s",
(types & TEST_NORMAL) ? "n" : "", (types & TEST_NORMAL) ? "n" : "",
(types & TEST_REENTRANT) ? "r" : "", (types & TEST_REENTRANT) ? "r" : "");
(types & TEST_VALGRIND) ? "V" : "");
printf("%-36s %7s %7zu %7zu %11s\n", printf("%-36s %7s %7zu %7zu %11s\n",
"TOTAL", "TOTAL",
type_buf, type_buf,
@@ -270,10 +269,9 @@ static void list_suites(void) {
char perm_buf[64]; char perm_buf[64];
sprintf(perm_buf, "%zu/%zu", filtered, perms); sprintf(perm_buf, "%zu/%zu", filtered, perms);
char type_buf[64]; char type_buf[64];
sprintf(type_buf, "%s%s%s", sprintf(type_buf, "%s%s",
(test_suites[i]->types & TEST_NORMAL) ? "n" : "", (test_suites[i]->types & TEST_NORMAL) ? "n" : "",
(test_suites[i]->types & TEST_REENTRANT) ? "r" : "", (test_suites[i]->types & TEST_REENTRANT) ? "r" : "");
(test_suites[i]->types & TEST_VALGRIND) ? "V" : "");
printf("%-36s %7s %7zu %11s\n", printf("%-36s %7s %7zu %11s\n",
test_suites[i]->id, test_suites[i]->id,
type_buf, type_buf,
@@ -305,10 +303,9 @@ static void list_cases(void) {
char perm_buf[64]; char perm_buf[64];
sprintf(perm_buf, "%zu/%zu", filtered, perms); sprintf(perm_buf, "%zu/%zu", filtered, perms);
char type_buf[64]; char type_buf[64];
sprintf(type_buf, "%s%s%s", sprintf(type_buf, "%s%s",
(types & TEST_NORMAL) ? "n" : "", (types & TEST_NORMAL) ? "n" : "",
(types & TEST_REENTRANT) ? "r" : "", (types & TEST_REENTRANT) ? "r" : "");
(types & TEST_VALGRIND) ? "V" : "");
printf("%-36s %7s %11s\n", printf("%-36s %7s %11s\n",
test_suites[i]->cases[j]->id, test_suites[i]->cases[j]->id,
type_buf, type_buf,
@@ -532,7 +529,6 @@ enum opt_flags {
OPT_GEOMETRY = 'G', OPT_GEOMETRY = 'G',
OPT_NORMAL = 'n', OPT_NORMAL = 'n',
OPT_REENTRANT = 'r', OPT_REENTRANT = 'r',
OPT_VALGRIND = 'V',
OPT_START = 5, OPT_START = 5,
OPT_STEP = 6, OPT_STEP = 6,
OPT_STOP = 7, OPT_STOP = 7,
@@ -555,7 +551,6 @@ const struct option long_opts[] = {
{"geometry", required_argument, NULL, OPT_GEOMETRY}, {"geometry", required_argument, NULL, OPT_GEOMETRY},
{"normal", no_argument, NULL, OPT_NORMAL}, {"normal", no_argument, NULL, OPT_NORMAL},
{"reentrant", no_argument, NULL, OPT_REENTRANT}, {"reentrant", no_argument, NULL, OPT_REENTRANT},
{"valgrind", no_argument, NULL, OPT_VALGRIND},
{"start", required_argument, NULL, OPT_START}, {"start", required_argument, NULL, OPT_START},
{"stop", required_argument, NULL, OPT_STOP}, {"stop", required_argument, NULL, OPT_STOP},
{"step", required_argument, NULL, OPT_STEP}, {"step", required_argument, NULL, OPT_STEP},
@@ -577,7 +572,6 @@ const char *const help_text[] = {
"Filter by geometry.", "Filter by geometry.",
"Filter for normal tests. Can be combined.", "Filter for normal tests. Can be combined.",
"Filter for reentrant tests. Can be combined.", "Filter for reentrant tests. Can be combined.",
"Filter for Valgrind tests. Can be combined.",
"Start at the nth test.", "Start at the nth test.",
"Stop before the nth test.", "Stop before the nth test.",
"Only run every n tests, calculated after --start and --stop.", "Only run every n tests, calculated after --start and --stop.",
@@ -724,9 +718,6 @@ invalid_define:
case OPT_REENTRANT: case OPT_REENTRANT:
test_types |= TEST_REENTRANT; test_types |= TEST_REENTRANT;
break; break;
case OPT_VALGRIND:
test_types |= TEST_VALGRIND;
break;
case OPT_START: { case OPT_START: {
char *parsed = NULL; char *parsed = NULL;
test_start = strtoumax(optarg, &parsed, 0); test_start = strtoumax(optarg, &parsed, 0);
-1
View File
@@ -8,7 +8,6 @@
enum test_types { enum test_types {
TEST_NORMAL = 0x1, TEST_NORMAL = 0x1,
TEST_REENTRANT = 0x2, TEST_REENTRANT = 0x2,
TEST_VALGRIND = 0x4,
}; };
typedef uint8_t test_types_t; typedef uint8_t test_types_t;
+97 -37
View File
@@ -13,6 +13,7 @@ import pty
import re import re
import shlex import shlex
import shutil import shutil
import signal
import subprocess as sp import subprocess as sp
import threading as th import threading as th
import time import time
@@ -77,8 +78,6 @@ class TestCase:
config.pop('suite_normal', True)) config.pop('suite_normal', True))
self.reentrant = config.pop('reentrant', self.reentrant = config.pop('reentrant',
config.pop('suite_reentrant', False)) config.pop('suite_reentrant', False))
self.valgrind = config.pop('valgrind',
config.pop('suite_valgrind', True))
# figure out defines and build possible permutations # figure out defines and build possible permutations
self.defines = set() self.defines = set()
@@ -163,7 +162,6 @@ class TestSuite:
in_ = config.pop('in', None) in_ = config.pop('in', None)
normal = config.pop('normal', True) normal = config.pop('normal', True)
reentrant = config.pop('reentrant', False) reentrant = config.pop('reentrant', False)
valgrind = config.pop('valgrind', True)
self.cases = [] self.cases = []
for name, case in sorted(cases.items(), for name, case in sorted(cases.items(),
@@ -177,7 +175,6 @@ class TestSuite:
'suite_in': in_, 'suite_in': in_,
'suite_normal': normal, 'suite_normal': normal,
'suite_reentrant': reentrant, 'suite_reentrant': reentrant,
'suite_valgrind': valgrind,
**case})) **case}))
# combine per-case defines # combine per-case defines
@@ -187,7 +184,6 @@ class TestSuite:
# combine other per-case things # combine other per-case things
self.normal = any(case.normal for case in self.cases) self.normal = any(case.normal for case in self.cases)
self.reentrant = any(case.reentrant for case in self.cases) self.reentrant = any(case.reentrant for case in self.cases)
self.valgrind = any(case.valgrind for case in self.cases)
for k in config.keys(): for k in config.keys():
print('\x1b[01;33mwarning:\x1b[m in %s, found unused key %r' print('\x1b[01;33mwarning:\x1b[m in %s, found unused key %r'
@@ -202,7 +198,7 @@ class TestSuite:
def compile(**args): def compile(**args):
# find .toml files # find .toml files
paths = [] paths = []
for path in args.get('test_paths', TEST_PATHS): for path in args.get('test_ids', TEST_PATHS):
if os.path.isdir(path): if os.path.isdir(path):
path = path + '/*.toml' path = path + '/*.toml'
@@ -210,13 +206,13 @@ def compile(**args):
paths.append(path) paths.append(path)
if not paths: if not paths:
print('no test suites found in %r?' % args['test_paths']) print('no test suites found in %r?' % args['test_ids'])
sys.exit(-1) sys.exit(-1)
if not args.get('source'): if not args.get('source'):
if len(paths) > 1: if len(paths) > 1:
print('more than one test suite for compilation? (%r)' print('more than one test suite for compilation? (%r)'
% args['test_paths']) % args['test_ids'])
sys.exit(-1) sys.exit(-1)
# load our suite # load our suite
@@ -373,8 +369,7 @@ def compile(**args):
f.writeln(4*' '+'.types = %s,' f.writeln(4*' '+'.types = %s,'
% ' | '.join(filter(None, [ % ' | '.join(filter(None, [
'TEST_NORMAL' if case.normal else None, 'TEST_NORMAL' if case.normal else None,
'TEST_REENTRANT' if case.reentrant else None, 'TEST_REENTRANT' if case.reentrant else None])))
'TEST_VALGRIND' if case.valgrind else None])))
f.writeln(4*' '+'.permutations = %d,' f.writeln(4*' '+'.permutations = %d,'
% len(case.permutations)) % len(case.permutations))
if case.defines: if case.defines:
@@ -406,8 +401,7 @@ def compile(**args):
f.writeln(4*' '+'.types = %s,' f.writeln(4*' '+'.types = %s,'
% ' | '.join(filter(None, [ % ' | '.join(filter(None, [
'TEST_NORMAL' if suite.normal else None, 'TEST_NORMAL' if suite.normal else None,
'TEST_REENTRANT' if suite.reentrant else None, 'TEST_REENTRANT' if suite.reentrant else None])))
'TEST_VALGRIND' if suite.valgrind else None])))
if suite.defines: if suite.defines:
f.writeln(4*' '+'.define_names = __test__%s__define_names,' f.writeln(4*' '+'.define_names = __test__%s__define_names,'
% suite.name) % suite.name)
@@ -434,7 +428,9 @@ def compile(**args):
# write any internal tests # write any internal tests
for suite in suites: for suite in suites:
for case in suite.cases: for case in suite.cases:
if case.in_ == args.get('source'): if (case.in_ is not None
and os.path.normpath(case.in_)
== os.path.normpath(args['source'])):
# write defines, but note we need to undef any # write defines, but note we need to undef any
# new defines since we're in someone else's file # new defines since we're in someone else's file
if suite.defines: if suite.defines:
@@ -475,15 +471,27 @@ def compile(**args):
def runner(**args): def runner(**args):
cmd = args['runner'].copy() cmd = args['runner'].copy()
# TODO multiple paths? cmd.extend(args.get('test_ids'))
if 'test_paths' in args:
cmd.extend(args.get('test_paths'))
# run under some external command?
cmd[:0] = args.get('exec', [])
# run under valgrind?
if args.get('valgrind'):
cmd[:0] = filter(None, [
'valgrind',
'--leak-check=full',
'--track-origins=yes',
'--error-exitcode=4',
'-q'])
# filter tests?
if args.get('normal'): cmd.append('-n') if args.get('normal'): cmd.append('-n')
if args.get('reentrant'): cmd.append('-r') if args.get('reentrant'): cmd.append('-r')
if args.get('valgrind'): cmd.append('-V')
if args.get('geometry'): if args.get('geometry'):
cmd.append('-G%s' % args.get('geometry')) cmd.append('-G%s' % args.get('geometry'))
# defines?
if args.get('define'): if args.get('define'):
for define in args.get('define'): for define in args.get('define'):
cmd.append('-D%s' % define) cmd.append('-D%s' % define)
@@ -522,8 +530,7 @@ def find_cases(runner_, **args):
'^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+' '^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+'
'[^\s]+\s+(?P<filtered>\d+)/(?P<perms>\d+)') '[^\s]+\s+(?P<filtered>\d+)/(?P<perms>\d+)')
# skip the first line # skip the first line
next(proc.stdout) for line in it.islice(proc.stdout, 1, None):
for line in proc.stdout:
m = pattern.match(line) m = pattern.match(line)
if m: if m:
filtered = int(m.group('filtered')) filtered = int(m.group('filtered'))
@@ -558,7 +565,6 @@ def find_paths(runner_, **args):
pattern = re.compile( pattern = re.compile(
'^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+' '^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+'
'(?P<path>[^:]+):(?P<lineno>\d+)') '(?P<path>[^:]+):(?P<lineno>\d+)')
# skip the first line
for line in proc.stdout: for line in proc.stdout:
m = pattern.match(line) m = pattern.match(line)
if m: if m:
@@ -586,7 +592,6 @@ def find_defines(runner_, **args):
pattern = re.compile( pattern = re.compile(
'^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+' '^(?P<id>(?P<case>(?P<suite>[^#]+)#[^\s#]+)[^\s]*)\s+'
'(?P<defines>(?:\w+=\w+\s*)+)') '(?P<defines>(?:\w+=\w+\s*)+)')
# skip the first line
for line in proc.stdout: for line in proc.stdout:
m = pattern.match(line) m = pattern.match(line)
if m: if m:
@@ -614,7 +619,6 @@ def run_stage(name, runner_, **args):
expected_suite_perms, expected_case_perms, expected_perms, total_perms = ( expected_suite_perms, expected_case_perms, expected_perms, total_perms = (
find_cases(runner_, **args)) find_cases(runner_, **args))
# TODO valgrind/gdb/exec
passed_suite_perms = co.defaultdict(lambda: 0) passed_suite_perms = co.defaultdict(lambda: 0)
passed_case_perms = co.defaultdict(lambda: 0) passed_case_perms = co.defaultdict(lambda: 0)
passed_perms = 0 passed_perms = 0
@@ -627,7 +631,6 @@ def run_stage(name, runner_, **args):
'|' '(?P<path>[^:]+):(?P<lineno>\d+):(?P<op_>assert):' '|' '(?P<path>[^:]+):(?P<lineno>\d+):(?P<op_>assert):'
' *(?P<message>.*)' ')$') ' *(?P<message>.*)' ')$')
locals = th.local() locals = th.local()
# TODO use process group instead of this set?
children = set() children = set()
def run_runner(runner_): def run_runner(runner_):
@@ -714,17 +717,23 @@ def run_stage(name, runner_, **args):
nonlocal failures nonlocal failures
nonlocal locals nonlocal locals
while (start or 0) < total_perms: start = start or 0
step = step or 1
while start < total_perms:
runner_ = runner.copy() runner_ = runner.copy()
if start is not None: if start is not None:
runner_.append('--start=%d' % start) runner_.append('--start=%d' % start)
if step is not None: if step is not None:
runner_.append('--step=%d' % step) runner_.append('--step=%d' % step)
if args.get('isolate') or args.get('valgrind'):
runner_.append('--stop=%d' % (start+step))
try: try:
# run the tests # run the tests
locals.seen_perms = 0 locals.seen_perms = 0
run_runner(runner_) run_runner(runner_)
assert locals.seen_perms > 0
start += locals.seen_perms*step
except TestFailure as failure: except TestFailure as failure:
# race condition for multiple failures? # race condition for multiple failures?
@@ -735,14 +744,13 @@ def run_stage(name, runner_, **args):
if args.get('keep_going') and not killed: if args.get('keep_going') and not killed:
# resume after failed test # resume after failed test
start = (start or 0) + locals.seen_perms*(step or 1) assert locals.seen_perms > 0
start += locals.seen_perms*step
continue continue
else: else:
# stop other tests # stop other tests
for child in children.copy(): for child in children.copy():
child.kill() child.kill()
break
# parallel jobs? # parallel jobs?
@@ -808,7 +816,7 @@ def run(**args):
start = time.time() start = time.time()
runner_ = runner(**args) runner_ = runner(**args)
print('using runner `%s`' print('using runner: %s'
% ' '.join(shlex.quote(c) for c in runner_)) % ' '.join(shlex.quote(c) for c in runner_))
expected_suite_perms, expected_case_perms, expected_perms, total_perms = ( expected_suite_perms, expected_case_perms, expected_perms, total_perms = (
find_cases(runner_, **args)) find_cases(runner_, **args))
@@ -823,15 +831,19 @@ def run(**args):
passed = 0 passed = 0
failures = [] failures = []
for type, by in it.product( for type, by in it.product(
['normal', 'reentrant', 'valgrind'], ['normal', 'reentrant'],
expected_case_perms.keys() if args.get('by_cases') expected_case_perms.keys() if args.get('by_cases')
else expected_suite_perms.keys() if args.get('by_suites') else expected_suite_perms.keys() if args.get('by_suites')
else [None]): else [None]):
# rebuild runner for each stage to override test identifier if needed
stage_runner = runner(**args | {
'test_ids': [by] if by is not None else args.get('test_ids', []),
'normal': type == 'normal',
'reentrant': type == 'reentrant'})
# spawn jobs for stage
expected_, passed_, failures_, killed = run_stage( expected_, passed_, failures_, killed = run_stage(
'%s %s' % (type, by or 'tests'), '%s %s' % (type, by or 'tests'), stage_runner, **args)
runner_ + ['--%s' % type] + ([by] if by is not None else []),
**args)
expected += expected_ expected += expected_
passed += passed_ passed += passed_
failures.extend(failures_) failures.extend(failures_)
@@ -879,6 +891,40 @@ def run(**args):
print(line) print(line)
print() print()
# drop into gdb?
if failures and (args.get('gdb')
or args.get('gdb_case')
or args.get('gdb_main')):
failure = failures[0]
runner_ = runner(**args | {'test_ids': [failure.id]})
if args.get('gdb_main'):
cmd = ['gdb',
'-ex', 'break main',
'-ex', 'run',
'--args'] + runner_
elif args.get('gdb_case'):
path, lineno = runner_paths[testcase(failure.id)]
cmd = ['gdb',
'-ex', 'break %s:%d' % (path, lineno),
'-ex', 'run',
'--args'] + runner_
elif failure.assert_ is not None:
cmd = ['gdb',
'-ex', 'run',
'-ex', 'frame function raise',
'-ex', 'up 2',
'--args'] + runner_
else:
cmd = ['gdb',
'-ex', 'run',
'--args'] + runner_
# exec gdb interactively
if args.get('verbose'):
print(' '.join(shlex.quote(c) for c in cmd))
os.execvp(cmd[0], cmd)
return 1 if failures else 0 return 1 if failures else 0
@@ -903,10 +949,11 @@ if __name__ == "__main__":
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Build and run tests.", description="Build and run tests.",
conflict_handler='resolve') conflict_handler='resolve')
# TODO document test case/perm specifier parser.add_argument('test_ids', nargs='*',
parser.add_argument('test_paths', nargs='*',
help="Description of testis to run. May be a directory, path, or \ help="Description of testis to run. May be a directory, path, or \
test identifier. Defaults to %r." % TEST_PATHS) test identifier. Test identifiers are of the form \
<suite_name>#<case_name>#<permutation>, but suffixes can be \
dropped to run any matching tests. Defaults to %r." % TEST_PATHS)
parser.add_argument('-v', '--verbose', action='store_true', parser.add_argument('-v', '--verbose', action='store_true',
help="Output commands that run behind the scenes.") help="Output commands that run behind the scenes.")
# test flags # test flags
@@ -933,8 +980,6 @@ if __name__ == "__main__":
help="Filter for normal tests. Can be combined.") help="Filter for normal tests. Can be combined.")
test_parser.add_argument('-r', '--reentrant', action='store_true', test_parser.add_argument('-r', '--reentrant', action='store_true',
help="Filter for reentrant tests. Can be combined.") help="Filter for reentrant tests. Can be combined.")
test_parser.add_argument('-V', '--valgrind', action='store_true',
help="Filter for Valgrind tests. Can be combined.")
test_parser.add_argument('-d', '--disk', test_parser.add_argument('-d', '--disk',
help="Use this file as the disk.") help="Use this file as the disk.")
test_parser.add_argument('-t', '--trace', test_parser.add_argument('-t', '--trace',
@@ -949,10 +994,25 @@ if __name__ == "__main__":
help="Number of parallel runners to run.") help="Number of parallel runners to run.")
test_parser.add_argument('-k', '--keep-going', action='store_true', test_parser.add_argument('-k', '--keep-going', action='store_true',
help="Don't stop on first error.") help="Don't stop on first error.")
test_parser.add_argument('-i', '--isolate', action='store_true',
help="Run each test permutation in a separate process.")
test_parser.add_argument('-b', '--by-suites', action='store_true', test_parser.add_argument('-b', '--by-suites', action='store_true',
help="Step through tests by suite.") help="Step through tests by suite.")
test_parser.add_argument('-B', '--by-cases', action='store_true', test_parser.add_argument('-B', '--by-cases', action='store_true',
help="Step through tests by case.") help="Step through tests by case.")
test_parser.add_argument('--gdb', action='store_true',
help="Drop into gdb on test failure.")
test_parser.add_argument('--gdb-case', action='store_true',
help="Drop into gdb on test failure but stop at the beginning \
of the failing test case.")
test_parser.add_argument('--gdb-main', action='store_true',
help="Drop into gdb on test failure but stop at the beginning \
of main.")
test_parser.add_argument('--valgrind', action='store_true',
help="Run under Valgrind to find memory errors. Implicitly sets \
--isolate.")
test_parser.add_argument('--exec', default=[], type=lambda e: e.split(),
help="Run under another executable.")
# compilation flags # compilation flags
comp_parser = parser.add_argument_group('compilation options') comp_parser = parser.add_argument_group('compilation options')
comp_parser.add_argument('-c', '--compile', action='store_true', comp_parser.add_argument('-c', '--compile', action='store_true',