xref: /illumos-gate/usr/src/tools/scripts/check_test_runfiles.py (revision e0416ec863e2a026ac6fe1f35033f0904f399bd7)
1#!@TOOLS_PYTHON@ -Es
2#
3# This file and its contents are supplied under the terms of the
4# Common Development and Distribution License ("CDDL"), version 1.0.
5# You may only use this file in accordance with the terms of version
6# 1.0 of the CDDL.
7#
8# A full copy of the text of the CDDL should have accompanied this
9# source.  A copy of the CDDL is also available via the Internet at
10# http://www.illumos.org/license/CDDL.
11#
12
13#
14# Copyright 2026 Gordon W. Ross
15#
16
17#
18# Check test runfiles against installed test artifacts in proto.
19#
20# For each section in a runfile, verify that the test group path,
21# the tests (if listed), and any pre/post auxiliary scripts exist
22# in the proto area and are executable.  Auxiliary scripts are
23# allowed to be outside the test group's own directory.
24#
25
26import argparse
27import ast
28import configparser
29import io
30import os
31import posixpath
32import re
33import sys
34import tokenize
35
36SECTION_RE = re.compile(r'^\s*\[(.+?)\]\s*$')
37
38
39def parse_args():
40    parser = argparse.ArgumentParser(
41        description='Validate test runfiles against proto/root_i386 installs')
42    parser.add_argument(
43        '-R', dest='root', default=os.environ.get('ROOT'),
44        help='proto root path (for example, .../proto/root_i386); '
45             'default: $ROOT')
46    parser.add_argument(
47        '-T', dest='testroot', required=True,
48        help='test root relative to proto root (for example, opt/os-tests)')
49    parser.add_argument(
50        '-a', '--arch', default='i86pc',
51        help='only check sections where arch matches this value '
52             '(default: i86pc)')
53    parser.add_argument(
54        'runfiles', nargs='+',
55        help='runfile paths to check')
56
57    args = parser.parse_args()
58    if args.root is None:
59        parser.error('missing proto root: set -R <root> or the ROOT '
60                     'environment variable')
61    return args
62
63
64def find_runfiles(args):
65    return list(args.runfiles)
66
67
68def normalize_testroot(testroot):
69    root = testroot.strip()
70    if not root:
71        return None
72    if root.startswith('/'):
73        return None
74    root = posixpath.normpath(root)
75    if root in ('.', '..') or root.startswith('../'):
76        return None
77    return root
78
79
80def section_path(section, testroot):
81    raw = section.split(':', 1)[0].strip()
82    if raw.startswith('/'):
83        return posixpath.normpath(raw)
84    return posixpath.normpath(posixpath.join('/', testroot, raw))
85
86
87def section_raw_path(section):
88    return section.split(':', 1)[0].strip()
89
90
91def is_test_file(path):
92    return (os.path.isfile(path) and not os.path.islink(path) and
93            os.access(path, os.X_OK))
94
95
96def has_adjacent_string_literals(expr):
97    try:
98        toks = tokenize.generate_tokens(io.StringIO(expr).readline)
99    except tokenize.TokenError:
100        return False
101
102    prev = None
103    ignored = set([tokenize.NL, tokenize.NEWLINE, tokenize.INDENT,
104                   tokenize.DEDENT, tokenize.COMMENT, tokenize.ENDMARKER])
105    for tok in toks:
106        if tok.type in ignored:
107            continue
108        if tok.type == tokenize.STRING and prev == tokenize.STRING:
109            return True
110        prev = tok.type
111    return False
112
113
114def section_line_map(content):
115    lines = {}
116    for lineno, line in enumerate(content.splitlines(), 1):
117        match = SECTION_RE.match(line)
118        if match is None:
119            continue
120        section = match.group(1).strip()
121        if section not in lines:
122            lines[section] = lineno
123    return lines
124
125
126def format_error(runfile, lineno, test_group, detail):
127    loc = '%s:%s' % (runfile, lineno)
128    return 'In test group %s, %s: %s' % (test_group, loc, detail)
129
130
131def emit_error(issues, runfile, lineno, test_group, detail):
132    issues.append(format_error(runfile, lineno, test_group, detail))
133
134
135def check_runfile(runfile, testroot, protoroot, arch):
136    issues = []
137    config = configparser.RawConfigParser()
138    content = None
139
140    try:
141        with open(runfile, encoding='utf-8') as f:
142            content = f.read()
143    except OSError as err:
144        return ['%s: failed to read runfile: %s' % (runfile, err)]
145
146    section_lines = section_line_map(content)
147
148    try:
149        config.read_string(content, source=runfile)
150    except configparser.Error as err:
151        return ['%s: parse error: %s' % (os.path.basename(runfile), err)]
152
153    for sec in config.sections():
154        lineno = section_lines.get(sec, '?')
155        if section_raw_path(sec).upper() == 'DEFAULT':
156            continue
157        if config.has_option(sec, 'arch'):
158            sec_arch = config.get(sec, 'arch').strip()
159            if sec_arch != arch:
160                continue
161
162        secpath = section_path(sec, testroot)
163        proto_path = os.path.join(protoroot, secpath.lstrip('/'))
164        has_tests = config.has_option(sec, 'tests')
165        has_autotests = config.has_option(sec, 'autotests')
166
167        if has_tests or has_autotests:
168            path_exists = os.path.exists(proto_path)
169            path_is_dir = os.path.isdir(proto_path)
170            path_is_file = os.path.isfile(proto_path)
171
172            if not path_exists:
173                emit_error(issues, runfile, lineno, secpath,
174                           'test group path not found in proto area.')
175            elif path_is_file:
176                if has_tests:
177                    emit_error(
178                        issues, runfile, lineno, secpath,
179                        'test group path is a file and should not have a '
180                        'tests property.')
181                else:
182                    emit_error(
183                        issues, runfile, lineno, secpath,
184                        'test group path is a file and should not have an '
185                        'autotests property.')
186
187            if has_tests:
188                tests_raw = config.get(sec, 'tests')
189                if has_adjacent_string_literals(tests_raw):
190                    emit_error(
191                        issues, runfile, lineno, secpath,
192                        'tests list contains adjacent string literals '
193                        '(possible missing comma).')
194
195                try:
196                    tests = ast.literal_eval(tests_raw)
197                except (SyntaxError, ValueError) as err:
198                    emit_error(
199                        issues, runfile, lineno, secpath,
200                        'tests is not a valid Python list: %s' % err)
201                    continue
202
203                if not isinstance(tests, list):
204                    emit_error(issues, runfile, lineno, secpath,
205                               'tests must evaluate to a list.')
206                    continue
207
208                bad = [repr(x) for x in tests if not isinstance(x, str)]
209                if bad:
210                    emit_error(issues, runfile, lineno, secpath,
211                               'tests must contain only strings: %s' %
212                               ', '.join(bad))
213                    continue
214
215                if not path_is_dir:
216                    continue
217
218                for test in tests:
219                    tpath = os.path.join(proto_path, test)
220                    if not is_test_file(tpath):
221                        emit_error(issues, runfile, lineno, secpath,
222                                   'test %s not found in proto area.' % test)
223        else:
224            if not is_test_file(proto_path):
225                emit_error(issues, runfile, lineno, secpath,
226                           'single test path not found in proto area.')
227
228        # Check pre/post auxiliary scripts. They are allowed to live in any
229        # directory, so we only verify they exist in the proto area.
230        aux_dir = secpath if (has_tests or has_autotests) \
231            else posixpath.dirname(secpath)
232        for prop in ('pre', 'post'):
233            if not config.has_option(sec, prop):
234                continue
235            val = config.get(sec, prop).strip()
236            if not val:
237                continue
238            if posixpath.isabs(val):
239                apath = os.path.join(protoroot, val.lstrip('/'))
240            else:
241                apath = os.path.join(protoroot, aux_dir.lstrip('/'), val)
242            if not is_test_file(apath):
243                emit_error(issues, runfile, lineno, secpath,
244                           '%s script not found in proto area: %s' %
245                           (prop, val))
246
247    return issues
248
249
250def main():
251    args = parse_args()
252    protoroot = os.path.abspath(os.path.expanduser(args.root))
253    testroot = normalize_testroot(args.testroot)
254
255    if not os.path.isdir(protoroot):
256        sys.stderr.write('error: proto root not found: %s\n' % protoroot)
257        return 2
258
259    if not os.path.isdir(os.path.join(protoroot, 'opt')):
260        sys.stderr.write('error: invalid proto root (missing opt/): %s\n' %
261                         protoroot)
262        return 2
263
264    if testroot is None:
265        sys.stderr.write('error: invalid -T value (must be a relative path '
266                         'under proto root): %s\n' % args.testroot)
267        return 2
268
269    testroot_path = os.path.join(protoroot, testroot)
270    if not os.path.isdir(testroot_path):
271        sys.stderr.write('error: test root not found under proto root: %s\n' %
272                         testroot_path)
273        return 2
274
275    runfiles = find_runfiles(args)
276    issues = []
277    for runfile in runfiles:
278        issues.extend(check_runfile(runfile, testroot, protoroot, args.arch))
279
280    for issue in issues:
281        sys.stderr.write('%s\n' % issue)
282
283    return 1 if issues else 0
284
285
286if __name__ == '__main__':
287    sys.exit(main())
288