#!@TOOLS_PYTHON@ -Es # # This file and its contents are supplied under the terms of the # Common Development and Distribution License ("CDDL"), version 1.0. # You may only use this file in accordance with the terms of version # 1.0 of the CDDL. # # A full copy of the text of the CDDL should have accompanied this # source. A copy of the CDDL is also available via the Internet at # http://www.illumos.org/license/CDDL. # # # Copyright 2026 Gordon W. Ross # # # Check test runfiles against installed test artifacts in proto. # # For each section in a runfile, verify that the test group path, # the tests (if listed), and any pre/post auxiliary scripts exist # in the proto area and are executable. Auxiliary scripts are # allowed to be outside the test group's own directory. # import argparse import ast import configparser import io import os import posixpath import re import sys import tokenize SECTION_RE = re.compile(r'^\s*\[(.+?)\]\s*$') def parse_args(): parser = argparse.ArgumentParser( description='Validate test runfiles against proto/root_i386 installs') parser.add_argument( '-R', dest='root', default=os.environ.get('ROOT'), help='proto root path (for example, .../proto/root_i386); ' 'default: $ROOT') parser.add_argument( '-T', dest='testroot', required=True, help='test root relative to proto root (for example, opt/os-tests)') parser.add_argument( '-a', '--arch', default='i86pc', help='only check sections where arch matches this value ' '(default: i86pc)') parser.add_argument( 'runfiles', nargs='+', help='runfile paths to check') args = parser.parse_args() if args.root is None: parser.error('missing proto root: set -R or the ROOT ' 'environment variable') return args def find_runfiles(args): return list(args.runfiles) def normalize_testroot(testroot): root = testroot.strip() if not root: return None if root.startswith('/'): return None root = posixpath.normpath(root) if root in ('.', '..') or root.startswith('../'): return None return root def section_path(section, testroot): raw = section.split(':', 1)[0].strip() if raw.startswith('/'): return posixpath.normpath(raw) return posixpath.normpath(posixpath.join('/', testroot, raw)) def section_raw_path(section): return section.split(':', 1)[0].strip() def is_test_file(path): return (os.path.isfile(path) and not os.path.islink(path) and os.access(path, os.X_OK)) def has_adjacent_string_literals(expr): try: toks = tokenize.generate_tokens(io.StringIO(expr).readline) except tokenize.TokenError: return False prev = None ignored = set([tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT, tokenize.COMMENT, tokenize.ENDMARKER]) for tok in toks: if tok.type in ignored: continue if tok.type == tokenize.STRING and prev == tokenize.STRING: return True prev = tok.type return False def section_line_map(content): lines = {} for lineno, line in enumerate(content.splitlines(), 1): match = SECTION_RE.match(line) if match is None: continue section = match.group(1).strip() if section not in lines: lines[section] = lineno return lines def format_error(runfile, lineno, test_group, detail): loc = '%s:%s' % (runfile, lineno) return 'In test group %s, %s: %s' % (test_group, loc, detail) def emit_error(issues, runfile, lineno, test_group, detail): issues.append(format_error(runfile, lineno, test_group, detail)) def check_runfile(runfile, testroot, protoroot, arch): issues = [] config = configparser.RawConfigParser() content = None try: with open(runfile, encoding='utf-8') as f: content = f.read() except OSError as err: return ['%s: failed to read runfile: %s' % (runfile, err)] section_lines = section_line_map(content) try: config.read_string(content, source=runfile) except configparser.Error as err: return ['%s: parse error: %s' % (os.path.basename(runfile), err)] for sec in config.sections(): lineno = section_lines.get(sec, '?') if section_raw_path(sec).upper() == 'DEFAULT': continue if config.has_option(sec, 'arch'): sec_arch = config.get(sec, 'arch').strip() if sec_arch != arch: continue secpath = section_path(sec, testroot) proto_path = os.path.join(protoroot, secpath.lstrip('/')) has_tests = config.has_option(sec, 'tests') has_autotests = config.has_option(sec, 'autotests') if has_tests or has_autotests: path_exists = os.path.exists(proto_path) path_is_dir = os.path.isdir(proto_path) path_is_file = os.path.isfile(proto_path) if not path_exists: emit_error(issues, runfile, lineno, secpath, 'test group path not found in proto area.') elif path_is_file: if has_tests: emit_error( issues, runfile, lineno, secpath, 'test group path is a file and should not have a ' 'tests property.') else: emit_error( issues, runfile, lineno, secpath, 'test group path is a file and should not have an ' 'autotests property.') if has_tests: tests_raw = config.get(sec, 'tests') if has_adjacent_string_literals(tests_raw): emit_error( issues, runfile, lineno, secpath, 'tests list contains adjacent string literals ' '(possible missing comma).') try: tests = ast.literal_eval(tests_raw) except (SyntaxError, ValueError) as err: emit_error( issues, runfile, lineno, secpath, 'tests is not a valid Python list: %s' % err) continue if not isinstance(tests, list): emit_error(issues, runfile, lineno, secpath, 'tests must evaluate to a list.') continue bad = [repr(x) for x in tests if not isinstance(x, str)] if bad: emit_error(issues, runfile, lineno, secpath, 'tests must contain only strings: %s' % ', '.join(bad)) continue if not path_is_dir: continue for test in tests: tpath = os.path.join(proto_path, test) if not is_test_file(tpath): emit_error(issues, runfile, lineno, secpath, 'test %s not found in proto area.' % test) else: if not is_test_file(proto_path): emit_error(issues, runfile, lineno, secpath, 'single test path not found in proto area.') # Check pre/post auxiliary scripts. They are allowed to live in any # directory, so we only verify they exist in the proto area. aux_dir = secpath if (has_tests or has_autotests) \ else posixpath.dirname(secpath) for prop in ('pre', 'post'): if not config.has_option(sec, prop): continue val = config.get(sec, prop).strip() if not val: continue if posixpath.isabs(val): apath = os.path.join(protoroot, val.lstrip('/')) else: apath = os.path.join(protoroot, aux_dir.lstrip('/'), val) if not is_test_file(apath): emit_error(issues, runfile, lineno, secpath, '%s script not found in proto area: %s' % (prop, val)) return issues def main(): args = parse_args() protoroot = os.path.abspath(os.path.expanduser(args.root)) testroot = normalize_testroot(args.testroot) if not os.path.isdir(protoroot): sys.stderr.write('error: proto root not found: %s\n' % protoroot) return 2 if not os.path.isdir(os.path.join(protoroot, 'opt')): sys.stderr.write('error: invalid proto root (missing opt/): %s\n' % protoroot) return 2 if testroot is None: sys.stderr.write('error: invalid -T value (must be a relative path ' 'under proto root): %s\n' % args.testroot) return 2 testroot_path = os.path.join(protoroot, testroot) if not os.path.isdir(testroot_path): sys.stderr.write('error: test root not found under proto root: %s\n' % testroot_path) return 2 runfiles = find_runfiles(args) issues = [] for runfile in runfiles: issues.extend(check_runfile(runfile, testroot, protoroot, args.arch)) for issue in issues: sys.stderr.write('%s\n' % issue) return 1 if issues else 0 if __name__ == '__main__': sys.exit(main())