2 # SPDX-License-Identifier: GPL-2.0
5 tdc.py - Linux tc (Traffic Control) unit test driver
7 Copyright (C) 2017 Lucas Bates <lucasb@mojatatu.com>
19 from collections
import OrderedDict
20 from string
import Template
22 from tdc_config
import *
23 from tdc_helper
import *
26 from TdcResults
import *
28 class PluginDependencyException(Exception):
29 def __init__(self
, missing_pg
):
30 self
.missing_pg
= missing_pg
32 class PluginMgrTestFail(Exception):
33 def __init__(self
, stage
, output
, message
):
36 self
.message
= message
39 def __init__(self
, argparser
):
42 self
.plugin_instances
= []
43 self
.failed_plugins
= {}
44 self
.argparser
= argparser
46 # TODO, put plugins in order
47 plugindir
= os
.getenv('TDC_PLUGIN_DIR', './plugins')
48 for dirpath
, dirnames
, filenames
in os
.walk(plugindir
):
50 if (fn
.endswith('.py') and
51 not fn
== '__init__.py' and
52 not fn
.startswith('#') and
53 not fn
.startswith('.#')):
55 foo
= importlib
.import_module('plugins.' + mn
)
56 self
.plugins
[mn
] = foo
57 self
.plugin_instances
.append(foo
.SubPlugin())
59 def load_plugin(self
, pgdir
, pgname
):
61 foo
= importlib
.import_module('{}.{}'.format(pgdir
, pgname
))
62 self
.plugins
[pgname
] = foo
63 self
.plugin_instances
.append(foo
.SubPlugin())
64 self
.plugin_instances
[-1].check_args(self
.args
, None)
66 def get_required_plugins(self
, testlist
):
68 Get all required plugins from the list of test cases and return
74 if 'requires' in t
['plugins']:
75 if isinstance(t
['plugins']['requires'], list):
76 reqs
.extend(t
['plugins']['requires'])
78 reqs
.append(t
['plugins']['requires'])
81 reqs
= get_unique_item(reqs
)
84 def load_required_plugins(self
, reqs
, parser
, args
, remaining
):
86 Get all required plugins from the list of test cases and load any plugin
87 that is not already enabled.
89 pgd
= ['plugin-lib', 'plugin-lib-custom']
93 if r
not in self
.plugins
:
94 fname
= '{}.py'.format(r
)
97 pgpath
= '{}/{}'.format(d
, fname
)
98 if os
.path
.isfile(pgpath
):
99 source_path
.append(pgpath
)
100 if len(source_path
) == 0:
101 print('ERROR: unable to find required plugin {}'.format(r
))
104 elif len(source_path
) > 1:
105 print('WARNING: multiple copies of plugin {} found, using version found')
106 print('at {}'.format(source_path
[0]))
107 pgdir
= source_path
[0]
108 pgdir
= pgdir
.split('/')[0]
109 self
.load_plugin(pgdir
, fname
)
111 raise PluginDependencyException(pnf
)
113 parser
= self
.call_add_args(parser
)
114 (args
, remaining
) = parser
.parse_known_args(args
=remaining
, namespace
=args
)
117 def call_pre_suite(self
, testcount
, testidlist
):
118 for pgn_inst
in self
.plugin_instances
:
119 pgn_inst
.pre_suite(testcount
, testidlist
)
121 def call_post_suite(self
, index
):
122 for pgn_inst
in reversed(self
.plugin_instances
):
123 pgn_inst
.post_suite(index
)
125 def call_pre_case(self
, caseinfo
, *, test_skip
=False):
126 for pgn_inst
in self
.plugin_instances
:
128 pgn_inst
.pre_case(caseinfo
, test_skip
)
129 except Exception as ee
:
130 print('exception {} in call to pre_case for {} plugin'.
131 format(ee
, pgn_inst
.__class
__))
132 print('test_ordinal is {}'.format(test_ordinal
))
133 print('testid is {}'.format(caseinfo
['id']))
136 def call_post_case(self
):
137 for pgn_inst
in reversed(self
.plugin_instances
):
140 def call_pre_execute(self
):
141 for pgn_inst
in self
.plugin_instances
:
142 pgn_inst
.pre_execute()
144 def call_post_execute(self
):
145 for pgn_inst
in reversed(self
.plugin_instances
):
146 pgn_inst
.post_execute()
148 def call_add_args(self
, parser
):
149 for pgn_inst
in self
.plugin_instances
:
150 parser
= pgn_inst
.add_args(parser
)
153 def call_check_args(self
, args
, remaining
):
154 for pgn_inst
in self
.plugin_instances
:
155 pgn_inst
.check_args(args
, remaining
)
157 def call_adjust_command(self
, stage
, command
):
158 for pgn_inst
in self
.plugin_instances
:
159 command
= pgn_inst
.adjust_command(stage
, command
)
162 def set_args(self
, args
):
166 def _make_argparser(args
):
167 self
.argparser
= argparse
.ArgumentParser(
168 description
='Linux TC unit tests')
170 def replace_keywords(cmd
):
172 For a given executable command, substitute any known
173 variables contained within NAMES with the correct values
176 subcmd
= tcmd
.safe_substitute(NAMES
)
180 def exec_cmd(args
, pm
, stage
, command
):
182 Perform any required modifications on an executable command, then run
183 it in a subprocess and return the results.
185 if len(command
.strip()) == 0:
188 command
= replace_keywords(command
)
190 command
= pm
.call_adjust_command(stage
, command
)
192 print('command "{}"'.format(command
))
193 proc
= subprocess
.Popen(command
,
195 stdout
=subprocess
.PIPE
,
196 stderr
=subprocess
.PIPE
,
200 (rawout
, serr
) = proc
.communicate(timeout
=NAMES
['TIMEOUT'])
201 if proc
.returncode
!= 0 and len(serr
) > 0:
202 foutput
= serr
.decode("utf-8", errors
="ignore")
204 foutput
= rawout
.decode("utf-8", errors
="ignore")
205 except subprocess
.TimeoutExpired
:
206 foutput
= "Command \"{}\" timed out\n".format(command
)
207 proc
.returncode
= 255
214 def prepare_env(args
, pm
, stage
, prefix
, cmdlist
, output
= None):
216 Execute the setup/teardown commands for a test case.
217 Optionally terminate test execution if the command fails.
220 print('{}'.format(prefix
))
221 for cmdinfo
in cmdlist
:
222 if isinstance(cmdinfo
, list):
223 exit_codes
= cmdinfo
[1:]
232 (proc
, foutput
) = exec_cmd(args
, pm
, stage
, cmd
)
234 if proc
and (proc
.returncode
not in exit_codes
):
235 print('', file=sys
.stderr
)
236 print("{} *** Could not execute: \"{}\"".format(prefix
, cmd
),
238 print("\n{} *** Error message: \"{}\"".format(prefix
, foutput
),
240 print("returncode {}; expected {}".format(proc
.returncode
,
242 print("\n{} *** Aborting test run.".format(prefix
), file=sys
.stderr
)
243 print("\n\n{} *** stdout ***".format(proc
.stdout
), file=sys
.stderr
)
244 print("\n\n{} *** stderr ***".format(proc
.stderr
), file=sys
.stderr
)
245 raise PluginMgrTestFail(
247 '"{}" did not complete successfully'.format(prefix
))
249 def run_one_test(pm
, args
, index
, tidx
):
254 res
= TestResult(tidx
['id'], tidx
['name'])
256 print("\t====================\n=====> ", end
="")
257 print("Test " + tidx
["id"] + ": " + tidx
["name"])
260 if tidx
['skip'] == 'yes':
261 res
= TestResult(tidx
['id'], tidx
['name'])
262 res
.set_result(ResultState
.skip
)
263 res
.set_errormsg('Test case designated as skipped.')
264 pm
.call_pre_case(tidx
, test_skip
=True)
265 pm
.call_post_execute()
268 # populate NAMES with TESTID for this test
269 NAMES
['TESTID'] = tidx
['id']
271 pm
.call_pre_case(tidx
)
272 prepare_env(args
, pm
, 'setup', "-----> prepare stage", tidx
["setup"])
274 if (args
.verbose
> 0):
275 print('-----> execute stage')
276 pm
.call_pre_execute()
277 (p
, procout
) = exec_cmd(args
, pm
, 'execute', tidx
["cmdUnderTest"])
279 exit_code
= p
.returncode
283 pm
.call_post_execute()
285 if (exit_code
is None or exit_code
!= int(tidx
["expExitCode"])):
286 print("exit: {!r}".format(exit_code
))
287 print("exit: {}".format(int(tidx
["expExitCode"])))
288 #print("exit: {!r} {}".format(exit_code, int(tidx["expExitCode"])))
289 res
.set_result(ResultState
.fail
)
290 res
.set_failmsg('Command exited with {}, expected {}\n{}'.format(exit_code
, tidx
["expExitCode"], procout
))
294 print('-----> verify stage')
295 match_pattern
= re
.compile(
296 str(tidx
["matchPattern"]), re
.DOTALL | re
.MULTILINE
)
297 (p
, procout
) = exec_cmd(args
, pm
, 'verify', tidx
["verifyCmd"])
299 match_index
= re
.findall(match_pattern
, procout
)
300 if len(match_index
) != int(tidx
["matchCount"]):
301 res
.set_result(ResultState
.fail
)
302 res
.set_failmsg('Could not match regex pattern. Verify command output:\n{}'.format(procout
))
304 res
.set_result(ResultState
.success
)
305 elif int(tidx
["matchCount"]) != 0:
306 res
.set_result(ResultState
.fail
)
307 res
.set_failmsg('No output generated by verify command.')
309 res
.set_result(ResultState
.success
)
311 prepare_env(args
, pm
, 'teardown', '-----> teardown stage', tidx
['teardown'], procout
)
316 # remove TESTID from NAMES
320 def test_runner(pm
, args
, filtered_tests
):
322 Driver function for the unit tests.
324 Prints information about the tests being run, executes the setup and
325 teardown commands and the command under test itself. Also determines
326 success/failure based on the information in the test case and generates
327 TAP output accordingly.
329 testlist
= filtered_tests
330 tcount
= len(testlist
)
335 emergency_exit
= False
336 emergency_exit_message
= ''
338 tsr
= TestSuiteReport()
341 pm
.call_pre_suite(tcount
, [tidx
['id'] for tidx
in testlist
])
342 except Exception as ee
:
343 ex_type
, ex
, ex_tb
= sys
.exc_info()
344 print('Exception {} {} (caught in pre_suite).'.
346 traceback
.print_tb(ex_tb
)
347 emergency_exit_message
= 'EMERGENCY EXIT, call_pre_suite failed with exception {} {}\n'.format(ex_type
, ex
)
348 emergency_exit
= True
352 pm
.call_post_suite(index
)
353 return emergency_exit_message
355 print('give test rig 2 seconds to stabilize')
357 for tidx
in testlist
:
358 if "flower" in tidx
["category"] and args
.device
== None:
359 errmsg
= "Tests using the DEV2 variable must define the name of a "
360 errmsg
+= "physical NIC with the -d option when running tdc.\n"
361 errmsg
+= "Test has been skipped."
364 res
= TestResult(tidx
['id'], tidx
['name'])
365 res
.set_result(ResultState
.skip
)
366 res
.set_errormsg(errmsg
)
367 tsr
.add_resultdata(res
)
370 badtest
= tidx
# in case it goes bad
371 res
= run_one_test(pm
, args
, index
, tidx
)
372 tsr
.add_resultdata(res
)
373 except PluginMgrTestFail
as pmtf
:
374 ex_type
, ex
, ex_tb
= sys
.exc_info()
376 message
= pmtf
.message
378 res
= TestResult(tidx
['id'], tidx
['name'])
379 res
.set_result(ResultState
.skip
)
380 res
.set_errormsg(pmtf
.message
)
381 res
.set_failmsg(pmtf
.output
)
382 tsr
.add_resultdata(res
)
385 print('Exception {} {} (caught in test_runner, running test {} {} {} stage {})'.
386 format(ex_type
, ex
, index
, tidx
['id'], tidx
['name'], stage
))
387 print('---------------')
389 traceback
.print_tb(ex_tb
)
390 print('---------------')
391 if stage
== 'teardown':
392 print('accumulated output for this test:')
395 print('---------------')
399 # if we failed in setup or teardown,
400 # fill in the remaining tests with ok-skipped
403 if tcount
+ 1 != count
:
404 for tidx
in testlist
[count
- 1:]:
405 res
= TestResult(tidx
['id'], tidx
['name'])
406 res
.set_result(ResultState
.skip
)
407 msg
= 'skipped - previous {} failed {} {}'.format(stage
,
408 index
, badtest
.get('id', '--Unknown--'))
409 res
.set_errormsg(msg
)
410 tsr
.add_resultdata(res
)
414 print('Want to pause\nPress enter to continue ...')
416 print('got something on stdin')
418 pm
.call_post_suite(index
)
422 def has_blank_ids(idlist
):
424 Search the list for empty ID fields and return true/false accordingly.
426 return not(all(k
for k
in idlist
))
429 def load_from_file(filename
):
431 Open the JSON file containing the test cases and return them
432 as list of ordered dictionary objects.
435 with
open(filename
) as test_data
:
436 testlist
= json
.load(test_data
, object_pairs_hook
=OrderedDict
)
437 except json
.JSONDecodeError
as jde
:
438 print('IGNORING test case file {}\n\tBECAUSE: {}'.format(filename
, jde
))
441 idlist
= get_id_list(testlist
)
442 if (has_blank_ids(idlist
)):
444 k
['filename'] = filename
450 Create the argument parser.
452 parser
= argparse
.ArgumentParser(description
='Linux TC unit tests')
456 def set_args(parser
):
458 Set the command line arguments for tdc.
461 '--outfile', type=str,
462 help='Path to the file in which results should be saved. ' +
463 'Default target is the current directory.')
465 '-p', '--path', type=str,
466 help='The full path to the tc executable to use')
467 sg
= parser
.add_argument_group(
468 'selection', 'select which test cases: ' +
469 'files plus directories; filtered by categories plus testids')
470 ag
= parser
.add_argument_group(
471 'action', 'select action to perform on selected test cases')
474 '-D', '--directory', nargs
='+', metavar
='DIR',
475 help='Collect tests from the specified directory(ies) ' +
476 '(default [tc-tests])')
478 '-f', '--file', nargs
='+', metavar
='FILE',
479 help='Run tests from the specified file(s)')
481 '-c', '--category', nargs
='*', metavar
='CATG', default
=['+c'],
482 help='Run tests only from the specified category/ies, ' +
483 'or if no category/ies is/are specified, list known categories.')
485 '-e', '--execute', nargs
='+', metavar
='ID',
486 help='Execute the specified test cases with specified IDs')
488 '-l', '--list', action
='store_true',
489 help='List all test cases, or those only within the specified category')
491 '-s', '--show', action
='store_true', dest
='showID',
492 help='Display the selected test cases')
494 '-i', '--id', action
='store_true', dest
='gen_id',
495 help='Generate ID numbers for new test cases')
497 '-v', '--verbose', action
='count', default
=0,
498 help='Show the commands that are being run')
500 '--format', default
='tap', const
='tap', nargs
='?',
501 choices
=['none', 'xunit', 'tap'],
502 help='Specify the format for test results. (Default: TAP)')
503 parser
.add_argument('-d', '--device',
504 help='Execute test cases that use a physical device, ' +
505 'where DEVICE is its name. (If not defined, tests ' +
506 'that require a physical device will be skipped)')
508 '-P', '--pause', action
='store_true',
509 help='Pause execution just before post-suite stage')
513 def check_default_settings(args
, remaining
, pm
):
515 Process any arguments overriding the default settings,
516 and ensure the settings are correct.
518 # Allow for overriding specific settings
521 if args
.path
!= None:
522 NAMES
['TC'] = args
.path
523 if args
.device
!= None:
524 NAMES
['DEV2'] = args
.device
525 if 'TIMEOUT' not in NAMES
:
526 NAMES
['TIMEOUT'] = None
527 if not os
.path
.isfile(NAMES
['TC']):
528 print("The specified tc path " + NAMES
['TC'] + " does not exist.")
531 pm
.call_check_args(args
, remaining
)
534 def get_id_list(alltests
):
536 Generate a list of all IDs in the test cases.
538 return [x
["id"] for x
in alltests
]
541 def check_case_id(alltests
):
543 Check for duplicate test case IDs.
545 idl
= get_id_list(alltests
)
546 return [x
for x
in idl
if idl
.count(x
) > 1]
549 def does_id_exist(alltests
, newid
):
551 Check if a given ID already exists in the list of test cases.
553 idl
= get_id_list(alltests
)
554 return (any(newid
== x
for x
in idl
))
557 def generate_case_ids(alltests
):
559 If a test case has a blank ID field, generate a random hex ID for it
560 and then write the test cases back to disk.
566 newid
= str('{:04x}'.format(random
.randrange(16**4)))
567 if (does_id_exist(alltests
, newid
)):
575 if ('filename' in c
):
576 ufilename
.append(c
['filename'])
577 ufilename
= get_unique_item(ufilename
)
582 if t
['filename'] == f
:
585 outfile
= open(f
, "w")
586 json
.dump(testlist
, outfile
, indent
=4)
590 def filter_tests_by_id(args
, testlist
):
592 Remove tests from testlist that are not in the named id list.
593 If id list is empty, return empty list.
596 if testlist
and args
.execute
:
597 target_ids
= args
.execute
599 if isinstance(target_ids
, list) and (len(target_ids
) > 0):
600 newlist
= list(filter(lambda x
: x
['id'] in target_ids
, testlist
))
603 def filter_tests_by_category(args
, testlist
):
605 Remove tests from testlist that are not in a named category.
608 if args
.category
and testlist
:
610 for catg
in set(args
.category
):
613 print('considering category {}'.format(catg
))
615 if catg
in tc
['category'] and tc
['id'] not in test_ids
:
617 test_ids
.append(tc
['id'])
622 def get_test_cases(args
):
624 If a test case file is specified, retrieve tests from that file.
625 Otherwise, glob for all json files in subdirectories and load from
627 Also, if requested, filter by category, and add tests matching
633 testdirs
= ['tc-tests']
636 # at least one file was specified - remove the default directory
640 if not os
.path
.isfile(ff
):
641 print("IGNORING file " + ff
+ "\n\tBECAUSE does not exist.")
643 flist
.append(os
.path
.abspath(ff
))
646 testdirs
= args
.directory
648 for testdir
in testdirs
:
649 for root
, dirnames
, filenames
in os
.walk(testdir
):
650 for filename
in fnmatch
.filter(filenames
, '*.json'):
651 candidate
= os
.path
.abspath(os
.path
.join(root
, filename
))
652 if candidate
not in testdirs
:
653 flist
.append(candidate
)
655 alltestcases
= list()
656 for casefile
in flist
:
657 alltestcases
= alltestcases
+ (load_from_file(casefile
))
659 allcatlist
= get_test_categories(alltestcases
)
660 allidlist
= get_id_list(alltestcases
)
662 testcases_by_cats
= get_categorized_testlist(alltestcases
, allcatlist
)
663 idtestcases
= filter_tests_by_id(args
, alltestcases
)
664 cattestcases
= filter_tests_by_category(args
, alltestcases
)
666 cat_ids
= [x
['id'] for x
in cattestcases
]
669 alltestcases
= cattestcases
+ [x
for x
in idtestcases
if x
['id'] not in cat_ids
]
671 alltestcases
= idtestcases
674 alltestcases
= cattestcases
676 # just accept the existing value of alltestcases,
677 # which has been filtered by file/directory
680 return allcatlist
, allidlist
, testcases_by_cats
, alltestcases
683 def set_operation_mode(pm
, parser
, args
, remaining
):
685 Load the test case data and process remaining arguments to determine
686 what the script should do for this run, and call the appropriate
689 ucat
, idlist
, testcases
, alltests
= get_test_cases(args
)
692 if (has_blank_ids(idlist
)):
693 alltests
= generate_case_ids(alltests
)
695 print("No empty ID fields found in test files.")
698 duplicate_ids
= check_case_id(alltests
)
699 if (len(duplicate_ids
) > 0):
700 print("The following test case IDs are not unique:")
701 print(str(set(duplicate_ids
)))
702 print("Please correct them before continuing.")
706 for atest
in alltests
:
707 print_test_case(atest
)
710 if isinstance(args
.category
, list) and (len(args
.category
) == 0):
711 print("Available categories:")
716 list_test_cases(alltests
)
720 req_plugins
= pm
.get_required_plugins(alltests
)
722 args
= pm
.load_required_plugins(req_plugins
, parser
, args
, remaining
)
723 except PluginDependencyException
as pde
:
724 print('The following plugins were not found:')
725 print('{}'.format(pde
.missing_pg
))
726 catresults
= test_runner(pm
, args
, alltests
)
727 if args
.format
== 'none':
728 print('Test results output suppression requested\n')
730 print('\nAll test results: \n')
731 if args
.format
== 'xunit':
733 res
= catresults
.format_xunit()
734 elif args
.format
== 'tap':
736 res
= catresults
.format_tap()
740 fname
= 'test-results.{}'.format(suffix
)
743 with
open(fname
, 'w') as fh
:
746 if os
.getenv('SUDO_UID') is not None:
747 os
.chown(fname
, uid
=int(os
.getenv('SUDO_UID')),
748 gid
=int(os
.getenv('SUDO_GID')))
750 print('No tests found\n')
754 Start of execution; set up argument parser and get the arguments,
755 and start operations.
757 parser
= args_parse()
758 parser
= set_args(parser
)
759 pm
= PluginMgr(parser
)
760 parser
= pm
.call_add_args(parser
)
761 (args
, remaining
) = parser
.parse_known_args()
764 check_default_settings(args
, remaining
, pm
)
766 print('args is {}'.format(args
))
768 set_operation_mode(pm
, parser
, args
, remaining
)
773 if __name__
== "__main__":