diff --git a/README.rst b/README.rst index 2567499..94c7399 100644 --- a/README.rst +++ b/README.rst @@ -716,6 +716,7 @@ hooks to PATH. - ``-T, --trace``: Enable function tracing; adds CONFIG_TRACE and CONFIG_TRACE_EARLY (use with -b) - ``--no-trace-early``: Disable TRACE_EARLY when using -T (use with -b) +- ``--leak-check``: Check for memory leaks around each test using mallinfo() - ``--malloc-dump FILE``: Write malloc heap dump on exit; ``%d`` in the filename is expanded to a sequence number - ``--no-timeout``: Disable test timeout @@ -866,6 +867,8 @@ without going through pytest. This is faster for quick iteration on C code. - ``-j, --jobs JOBS``: Number of parallel jobs (use with -b) - ``-l, --list``: List available tests - ``-L, --lto``: Enable LTO when building (use with -b) +- ``--leak-check``: Check for memory leaks around each test using mallinfo() +- ``--show-leaks N``: Show top N leaks by bytes (default: 10, 0 to disable) - ``--legacy``: Use legacy result parsing (for old U-Boot) - ``--malloc-dump FILE``: Write malloc heap dump on exit; ``%d`` in the filename is expanded to a sequence number diff --git a/uman_pkg/build.py b/uman_pkg/build.py index 7f87264..26e221f 100644 --- a/uman_pkg/build.py +++ b/uman_pkg/build.py @@ -174,6 +174,23 @@ def get_cmd(args, board, build_dir): return ['buildman'] + get_buildman_args(args, board, build_dir) +def base_bm_args(board, build_dir, lto=True): + """Build the common buildman arguments + + Args: + board (str): Board name to build + build_dir (str): Path to build directory + lto (bool): Enable LTO (default True) + + Returns: + list: Base arguments for buildman + """ + bm_args = ['-I', '-w', '-W', '--boards', board, '-o', build_dir] + if not lto: + bm_args.insert(0, '-L') + return bm_args + + def get_buildman_args(args, board, build_dir): """Build the buildman arguments @@ -188,9 +205,7 @@ def get_buildman_args(args, board, build_dir): if args.in_tree: bm_args = ['-i', '--boards', board] else: - bm_args = ['-I', '-w', '--boards', board, '-o', build_dir] - if not args.lto: - bm_args.insert(0, '-L') + bm_args = base_bm_args(board, build_dir, args.lto) if args.target: bm_args.extend(['--target', args.target]) if args.jobs: @@ -234,9 +249,7 @@ def build_board(board, dry_run=False, lto=False, adjust_cfg=None, tout.progress(f'Building {board}') - bm_args = ['-I', '-w', '--boards', board, '-o', build_dir] - if not lto: - bm_args.insert(0, '-L') + bm_args = base_bm_args(board, build_dir, lto) if force_reconfig: bm_args.append('-C') if jobs: diff --git a/uman_pkg/cc.py b/uman_pkg/cc.py index 47a12b7..d6f414f 100644 --- a/uman_pkg/cc.py +++ b/uman_pkg/cc.py @@ -40,8 +40,8 @@ ALSA_PULSE_CONF = '/etc/alsa-pulse.conf' # Default packages to install in containers -DEFAULT_PACKAGES = ('build-essential gh glab libasound2-plugins' - ' libsox-fmt-pulse pylint sox xclip') +DEFAULT_PACKAGES = ('build-essential gdb-multiarch gh glab' + ' libasound2-plugins libsox-fmt-pulse pylint sox xclip') def get_log_path(name): diff --git a/uman_pkg/cmdline.py b/uman_pkg/cmdline.py index 19511a8..01440f9 100644 --- a/uman_pkg/cmdline.py +++ b/uman_pkg/cmdline.py @@ -177,6 +177,23 @@ def add_selftest_subparser(subparsers): return stest +def add_leak_opts(parser): + """Add leak-check and malloc-dump options to a parser + + Args: + parser: Argument parser to add options to + """ + parser.add_argument( + '-M', '--leak-check', action='store_true', dest='leak_check', + help='Check for memory leaks around each test') + parser.add_argument( + '--show-leaks', type=int, default=10, metavar='N', dest='show_leaks', + help='Show top N leaks by bytes (default: 10, 0 to disable)') + parser.add_argument( + '--malloc-dump', metavar='FILE', dest='malloc_dump', + help='Write malloc dump to FILE on exit (use %%d for sequence number)') + + def add_test_opts(parser, board_help=None, board_default=None): """Add common test options to a parser @@ -204,9 +221,7 @@ def add_test_opts(parser, board_help=None, board_default=None): parser.add_argument( '-x', '--exitfirst', action='store_true', help='Stop on first test failure') - parser.add_argument( - '--malloc-dump', metavar='FILE', dest='malloc_dump', - help='Write malloc dump to FILE on exit (use %%d for sequence number)') + add_leak_opts(parser) def add_build_opts(parser): @@ -400,12 +415,10 @@ def add_test_subparser(subparsers): test.add_argument( '-s', '--suites', action='store_true', dest='list_suites', help='List available test suites') - test.add_argument( - '--malloc-dump', metavar='FILE', dest='malloc_dump', - help='Write malloc dump to FILE on exit (use %%d for sequence number)') test.add_argument( '-V', '--test-verbose', action='store_true', dest='test_verbose', help='Enable verbose test output') + add_leak_opts(test) add_build_opts(test) return test diff --git a/uman_pkg/cmdpy.py b/uman_pkg/cmdpy.py index e09673c..7395166 100644 --- a/uman_pkg/cmdpy.py +++ b/uman_pkg/cmdpy.py @@ -27,8 +27,9 @@ from uman_pkg import build as build_mod from uman_pkg import settings -from uman_pkg.cmdtest import get_sandbox_path -from uman_pkg.util import exec_cmd, get_uboot_dir, show_summary +from uman_pkg.cmdtest import get_sandbox_path, parse_results +from uman_pkg.util import (exec_cmd, get_uboot_dir, show_leak_top, + show_summary) # Fallback hostname directory for test hooks (has standard QEMU board configs) HOOKS_FALLBACK = 'travis-ci' @@ -471,6 +472,8 @@ def build_pytest_cmd(args): cmd.append('--no-full') if args.malloc_dump: cmd.extend(['--malloc-dump', args.malloc_dump]) + if args.leak_check: + cmd.append('--leak-check') # Add extra pytest arguments (after --) if args.extra_args: @@ -1154,9 +1157,8 @@ def do_pollute(args): base_dir = settings.get('build_dir', '/tmp/b') build_dir = f'{base_dir}/{args.board}-pollute' tout.notice(f'Building to {build_dir}...') - cmd = ['buildman', '-I', '-w', '--boards', args.board, '-o', build_dir] - if not args.lto: - cmd.insert(1, '-L') + cmd = ['buildman'] + build_mod.base_bm_args(args.board, build_dir, + args.lto) result = exec_cmd(cmd, args.dry_run, capture=False) if result and result.return_code != 0: tout.error('Build failed') @@ -1364,7 +1366,9 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran if args.gdb: return run_with_gdb(args) + start_time = time.time() result = exec_cmd(cmd, args.dry_run, env=env, capture=False) + elapsed = time.time() - start_time if result is None: # dry-run qemu_cmd = get_qemu_command(board, args) @@ -1377,8 +1381,29 @@ def do_pytest(args): # pylint: disable=too-many-return-statements,too-many-bran print(result.stderr, file=sys.stderr) if not args.quiet: tout.error('pytest failed') - return result.return_code + else: + if not args.quiet: + tout.notice('pytest passed') - if not args.quiet: - tout.notice('pytest passed') - return 0 + # Show leak summary from test log if leak-check was enabled + if args.leak_check: + if args.output_dir: + build_dir = args.output_dir + else: + base_dir = settings.get('build_dir', '/tmp/b') + build_dir = f'{base_dir}/{args.board}' + log_path = os.path.join(build_dir, 'test-log.html') + if os.path.exists(log_path): + import html + with open(log_path) as fh: + text = fh.read() + # Strip HTML tags first, then unescape entities + text = re.sub(r'<[^>]+>', '\n', text) + text = html.unescape(text) + res = parse_results(text) + if res and res.leaked: + show_summary(res.passed, res.failed, res.skipped, elapsed, + res.leaked, res.leak_bytes) + if res.leak_top and args.show_leaks: + show_leak_top(res.leak_top, args.show_leaks) + return result.return_code diff --git a/uman_pkg/cmdtest.py b/uman_pkg/cmdtest.py index dc3e838..7adf9d5 100644 --- a/uman_pkg/cmdtest.py +++ b/uman_pkg/cmdtest.py @@ -23,10 +23,12 @@ from u_boot_pylib import tout from uman_pkg import build, settings -from uman_pkg.util import run_pytest, show_summary +from uman_pkg.util import format_bytes, run_pytest, show_leak_top, show_summary # Named tuple for test result counts -TestCounts = namedtuple('TestCounts', ['passed', 'failed', 'skipped']) +TestCounts = namedtuple('TestCounts', ['passed', 'failed', 'skipped', + 'leaked', 'leak_bytes', 'leak_top'], + defaults=[0, 0, None]) # Patterns for parsing linker-list symbols from nm output # Format: _u_boot_list_2_ut__2_ @@ -42,6 +44,8 @@ RE_RESULT = re.compile(r'Result:\s*(PASS|FAIL|SKIP):?\s+(\S+)') RE_SUMMARY = re.compile(r'Tests run:\s*(\d+),.*failures:\s*(\d+)') RE_TEST_FAILED = re.compile(r"Test '.+' failed \d+ times") +RE_LEAK = re.compile(r'Leak:\s+(\d+)\s+alloc') +RE_LEAK_DETAIL = re.compile(r'\s+[0-9a-f]+\s+([0-9a-f]+)\s+(.*)') # Unit test flags from include/test/test.h UTF_FLAT_TREE = 0x08 @@ -322,6 +326,50 @@ def parse_test_specs(tests): return [parse_one_test(t) for t in tests] +def resolve_one(suite, pattern, all_tests, known_suites): + """Resolve a single (suite, pattern) spec against known tests + + Args: + suite (str or None): Suite name, 'all', or None to search + pattern (str or None): Test pattern or None for whole suite + all_tests (list): List of (suite, test_name) from nm + known_suites (set): Set of known suite names + + Returns: + tuple: (resolved_list, matched) where resolved_list is a list of + (suite, pattern) tuples and matched is True if something matched + """ + if suite == 'all' or suite in known_suites: + return [(suite, pattern)], True + + if suite is not None: + # Suite doesn't exist - try to find full test name + if pattern: + full_name = f'{suite}_test_{pattern}' + else: + full_name = suite + for test_suite, test_name in all_tests: + if test_name == full_name: + return [(test_suite, full_name)], True + + # Try as a pattern across all suites + if pattern is None: + matches = set() + for test_suite, test_name in all_tests: + if fnmatch.fnmatch(test_name, f'*{suite}*'): + matches.add(test_suite) + if matches: + return [(s, f'{s}_test_{suite}*') + for s in sorted(matches)], True + return [], False + + # suite is None - search all suites for this pattern + for test_suite, test_name in all_tests: + if fnmatch.fnmatch(test_name, f'*{pattern}'): + return [(test_suite, pattern)], True + return [], False + + def resolve_specs(sandbox, specs): """Resolve specs with suite=None or invalid suite by looking up from nm @@ -334,48 +382,23 @@ def resolve_specs(sandbox, specs): """ resolved = [] unmatched = [] - all_tests = None # Lazy load - known_suites = None # Lazy load + all_tests = None + known_suites = None for suite, pattern in specs: - if suite is not None and suite != 'all': - # Check if suite exists + if suite not in (None, 'all'): if known_suites is None: - if all_tests is None: - all_tests = get_tests_from_nm(sandbox) - known_suites = {s for s, _ in all_tests} - - if suite in known_suites: - resolved.append((suite, pattern)) - else: - # Suite doesn't exist - try to find full test name - # Reconstruct the original test name - if pattern: - full_name = f'{suite}_test_{pattern}' - else: - full_name = suite - found = False - for test_suite, test_name in all_tests: - if test_name == full_name: - resolved.append((test_suite, full_name)) - found = True - break - if not found: - unmatched.append((suite, pattern)) - elif suite == 'all': - resolved.append((suite, pattern)) - else: - # Need to find suite(s) for this pattern - if all_tests is None: all_tests = get_tests_from_nm(sandbox) - found = False - for test_suite, test_name in all_tests: - if fnmatch.fnmatch(test_name, f'*{pattern}'): - resolved.append((test_suite, pattern)) - found = True - break # Only add first match - if not found: - unmatched.append((None, pattern)) + known_suites = {s for s, _ in all_tests} + elif suite is None and all_tests is None: + all_tests = get_tests_from_nm(sandbox) + known_suites = {s for s, _ in all_tests} + + found, matched = resolve_one(suite, pattern, + all_tests or [], known_suites or set()) + resolved.extend(found) + if not matched: + unmatched.append((suite, pattern)) return resolved, unmatched @@ -446,7 +469,7 @@ def has_emit_result(): def build_ut_cmd(sandbox, specs, full=False, verbose=False, legacy=False, - manual=False, malloc_dump=None): + manual=False, malloc_dump=None, leak_check=False): """Build the sandbox command line for running tests Args: @@ -457,6 +480,7 @@ def build_ut_cmd(sandbox, specs, full=False, verbose=False, legacy=False, legacy (bool): Legacy mode (don't use -E flag for older U-Boot) manual (bool): Force manual tests to run malloc_dump (str or None): File to write malloc dump to on exit + leak_check (bool): Check for memory leaks around each test Returns: list: Command and arguments @@ -481,6 +505,8 @@ def build_ut_cmd(sandbox, specs, full=False, verbose=False, legacy=False, flags += '-E ' if manual: flags += '-m ' + if leak_check: + flags += '-L ' cmds = [] for suite, pattern in specs: if pattern: @@ -587,8 +613,35 @@ def parse_results(output, show_results=False, col=None): passed = 0 failed = 0 skipped = 0 + leaked = 0 + leak_bytes = 0 + cur_test = None + cur_leak_bytes = 0 + cur_details = [] + leak_top = [] + + def flush_leak(): + if cur_leak_bytes and cur_test: + leak_top.append((cur_leak_bytes, cur_test, cur_details)) for line in output.splitlines(): + test_match = RE_TEST_NAME.match(line) + if test_match: + flush_leak() + cur_test = test_match.group(1) + cur_leak_bytes = 0 + cur_details = [] + continue + if RE_LEAK.match(line): + leaked += 1 + continue + detail = RE_LEAK_DETAIL.match(line) + if detail: + nbytes = int(detail.group(1), 16) + leak_bytes += nbytes + cur_leak_bytes += nbytes + cur_details.append((nbytes, detail.group(2))) + continue result_match = RE_RESULT.match(line) if result_match: status, name = result_match.groups() @@ -600,10 +653,16 @@ def parse_results(output, show_results=False, col=None): skipped += 1 if show_results: show_result(status, name, col) + flush_leak() + cur_leak_bytes = 0 + cur_details = [] + flush_leak() - if not passed and not failed and not skipped: + if not passed and not failed and not skipped and not leaked: return None - return TestCounts(passed, failed, skipped) + leak_top.sort(reverse=True) + return TestCounts(passed, failed, skipped, leaked, leak_bytes, + leak_top) def count_tests(sandbox, specs): @@ -645,6 +704,8 @@ def __init__(self, emit_result, total=0): self.passed = 0 self.failed = 0 self.skipped = 0 + self.leaked = 0 + self.leak_bytes = 0 self.buf = '' self.pending = False # A Test: line seen, not yet resolved @@ -660,14 +721,28 @@ def _show(self): hdr = f'{done}/{self.total}:' else: hdr = f'{done}:' - sys.stderr.write( - f'\r {hdr} {grn}{self.passed} passed{rst}, ' - f'{red}{self.failed} failed{rst}, ' - f'{yel}{self.skipped} skipped{rst}') + mag = col.start(terminal.Color.MAGENTA) + parts = [f'{grn}{self.passed} passed{rst}', + f'{red}{self.failed} failed{rst}', + f'{yel}{self.skipped} skipped{rst}'] + if self.leaked: + leak_str = f'{self.leaked} leaked' + if self.leak_bytes: + leak_str += f' ({format_bytes(self.leak_bytes)})' + parts.append(f'{mag}{leak_str}{rst}') + sys.stderr.write(f'\r {hdr} {", ".join(parts)}') sys.stderr.flush() def _process_line(self, line): """Process one complete line of output""" + if RE_LEAK.match(line): + self.leaked += 1 + self._show() + return + detail = RE_LEAK_DETAIL.match(line) + if detail: + self.leak_bytes += int(detail.group(1), 16) + return if self.emit: match = RE_RESULT.match(line) if match: @@ -710,40 +785,25 @@ def finish(self): sys.stderr.flush() -def run_tests(sandbox, specs, args, col): # pylint: disable=R0914 - """Run sandbox tests +def run_ut(cmd, sandbox, specs): + """Run a ut command and capture the output + + Sets up the persistent-data directory and shows live progress on + stderr when available. Args: + cmd (list): Command and arguments to run sandbox (str): Path to sandbox executable - specs (list): List of (suite, pattern) tuples from parse_test_specs - args (argparse.Namespace): Arguments from cmdline - col (terminal.Color): Color object for output + specs (list): List of (suite, pattern) tuples Returns: - int: Exit code from tests + tuple: (result, elapsed) or (None, 0) on failure """ - # Ensure dm init data files exist if needed - if needs_dm_init(specs) and not ensure_dm_init_files(): - return 1 - - cmd = build_ut_cmd(sandbox, specs, full=args.flattree_too, - verbose=args.test_verbose, legacy=args.legacy, - manual=args.manual, - malloc_dump=args.malloc_dump) - - if args.dry_run: - tout.notice(shlex.join(cmd)) - return 0 - - tout.info(f"Running: {shlex.join(cmd)}") - - # Set up environment with persistent data directory build_dir = settings.get('build_dir', '/tmp/b') persist_dir = os.path.join(build_dir, 'sandbox', 'persistent-data') env = os.environ.copy() env['U_BOOT_PERSISTENT_DATA_DIR'] = persist_dir - # Show live progress if stderr is a terminal emit = has_emit_result() if sys.stderr.isatty(): total = count_tests(sandbox, specs) @@ -757,27 +817,38 @@ def run_tests(sandbox, specs, args, col): # pylint: disable=R0914 result = command.run_one(*cmd, capture=True, env=env, output_func=output_func) except command.CommandExc as exc: - # Tests may fail but still produce parseable output result = exc.result if result and isinstance(result.stdout, (bytes, bytearray)): result.to_output(False) if not result: tout.error(f'Command failed: {exc}') - return 1 + return None, 0 finally: if progress: progress.finish() - elapsed = time.time() - start_time + return result, time.time() - start_time + +def show_test_output(result, args, col): + """Parse and display test results + + Args: + result (CommandResult): Output from running tests + args (argparse.Namespace): Arguments from cmdline + col (terminal.Color): Color object for output + + Returns: + TestCounts, False, or None: Parsed result counts, False if no + results were found, None on error + """ # Detect old U-Boot that doesn't understand -E or -F flags if 'failed while parsing option: -E' in result.stdout: tout.error('U-Boot does not support -E flag; use -L for legacy mode') - return 1 + return None if 'failed while parsing option: -F' in result.stdout: tout.error('U-Boot does not support -F flag; use -f to run all tests') - return 1 + return None - # Parse results first to check for failures legacy = args.legacy or not has_emit_result() res = parse_results(result.stdout, show_results=args.results, col=col) if not res and legacy: @@ -789,7 +860,6 @@ def run_tests(sandbox, specs, args, col): # pylint: disable=R0914 # Print output in verbose mode, if there are failures, or no results if result.stdout and not args.results: if args.test_verbose or (res and res.failed) or not res: - # Skip U-Boot banner, show only test output in_tests = False for line in result.stdout.splitlines(): if not in_tests: @@ -797,29 +867,130 @@ def run_tests(sandbox, specs, args, col): # pylint: disable=R0914 in_tests = True if in_tests: print(line) - if res: - show_summary(res.passed, res.failed, res.skipped, elapsed) - return result.return_code - - # Check for crash (signal termination) - ret = result.return_code - sig = None - if ret < 0: - sig = -ret - elif ret > 128: - sig = ret - 128 + return res or False + + +def check_signal(return_code): + """Check if a return code indicates signal termination + + Args: + return_code (int): Process return code + + Returns: + int or None: Signal number, or None if not a signal + """ + if return_code < 0: + return -return_code + if return_code > 128: + return return_code - 128 + return None + + +def run_tests(sandbox, specs, args, col): + """Run sandbox tests + + Args: + sandbox (str): Path to sandbox executable + specs (list): List of (suite, pattern) tuples from parse_test_specs + args (argparse.Namespace): Arguments from cmdline + col (terminal.Color): Color object for output + + Returns: + int: Exit code from tests + """ + if needs_dm_init(specs) and not ensure_dm_init_files(): + return 1 + + cmd = build_ut_cmd(sandbox, specs, full=args.flattree_too, + verbose=args.test_verbose, legacy=args.legacy, + manual=args.manual, + malloc_dump=args.malloc_dump, + leak_check=args.leak_check) + + if args.dry_run: + tout.notice(shlex.join(cmd)) + return 0 + + tout.info(f"Running: {shlex.join(cmd)}") + ret = 1 + + result, elapsed = run_ut(cmd, sandbox, specs) + if result: + res = show_test_output(result, args, col) + else: + res = None + + # Reset terminal if killed by a signal (e.g. SIGSEGV) + sig = check_signal(result.return_code) if result else None if sig: - sig_names = {6: 'SIGABRT', 11: 'SIGSEGV', 15: 'SIGTERM'} - sig_name = sig_names.get(sig, f'signal {sig}') os.system('tset') - tout.error(f'Test crashed ({sig_name})') - return ret - tout.warning('No results detected (use -L for older U-Boot)') - return 1 + if res is not None and res: + show_summary(res.passed, res.failed, res.skipped, elapsed, + res.leaked, res.leak_bytes) + if res.leak_top and args.show_leaks: + show_leak_top(res.leak_top, args.show_leaks) + if sig: + sig_names = {6: 'SIGABRT', 11: 'SIGSEGV', 15: 'SIGTERM'} + tout.error(f'Test crashed ' + f'({sig_names.get(sig, f"signal {sig}")})') + ret = result.return_code + elif res is not None: + if sig: + sig_names = {6: 'SIGABRT', 11: 'SIGSEGV', 15: 'SIGTERM'} + tout.error(f'Test crashed ' + f'({sig_names.get(sig, f"signal {sig}")})') + ret = result.return_code + else: + tout.warning('No results detected (use -L for older U-Boot)') + + return ret -def do_test(args): # pylint: disable=R0912 +def report_unmatched(unmatched): + """Report unmatched test specs to stderr + + Args: + unmatched (list): List of (suite, pattern) tuples that did not match + """ + for suite, pattern in unmatched: + if suite and pattern: + tout.error(f'No tests found matching: {suite}.{pattern}') + elif suite: + tout.error(f'No tests found in suite: {suite}') + else: + tout.error(f'No tests found matching: {pattern}') + + +def list_suites(sandbox): + """List available test suites + + Args: + sandbox (str): Path to sandbox executable + """ + suites = get_suites_from_nm(sandbox) + tout.notice('Available test suites:') + for suite in suites: + print(f' {suite}') + + +def list_tests(sandbox, suite): + """List available tests, optionally filtered by suite + + Args: + sandbox (str): Path to sandbox executable + suite (str or None): Suite name to filter by, or None for all + """ + tests = get_tests_from_nm(sandbox, suite) + if suite: + tout.notice(f'Tests in suite "{suite}":') + else: + tout.notice('Available tests:') + for suite_name, test_name in tests: + print(f' {suite_name}.{test_name}') + + +def do_test(args): """Handle test command - run U-Boot sandbox tests Args: @@ -829,8 +1000,8 @@ def do_test(args): # pylint: disable=R0912 int: Exit code """ board = args.board or 'sandbox' + ret = 0 - # Build if requested if args.build: if not build.build_board( board, args.dry_run, lto=args.lto, @@ -839,52 +1010,25 @@ def do_test(args): # pylint: disable=R0912 jobs=args.jobs, trace=args.trace, trace_early=not args.no_trace_early, output_dir=args.output_dir): - return 1 + ret = 1 - sandbox = get_sandbox_path() - if not sandbox: + sandbox = None if ret else get_sandbox_path() + if not ret and not sandbox: tout.error(f'Sandbox not found. Build first with: uman build {board}') - return 1 - - # Handle list suites - if args.list_suites: - suites = get_suites_from_nm(sandbox) - tout.notice('Available test suites:') - for suite in suites: - print(f' {suite}') - return 0 - - # Handle list tests - if args.list_tests: - suite = args.tests[0] if args.tests else None - tests = get_tests_from_nm(sandbox, suite) - if suite: - tout.notice(f'Tests in suite "{suite}":') + ret = 1 + + if not ret and args.list_suites: + list_suites(sandbox) + elif not ret and args.list_tests: + list_tests(sandbox, args.tests[0] if args.tests else None) + elif not ret: + specs = parse_test_specs(args.tests) + specs, unmatched = resolve_specs(sandbox, specs) + if not unmatched: + unmatched = validate_specs(sandbox, specs) + if unmatched: + report_unmatched(unmatched) + ret = 1 else: - tout.notice('Available tests:') - for suite_name, test_name in tests: - print(f' {suite_name}.{test_name}') - return 0 - - # Parse test specs - specs = parse_test_specs(args.tests) - - # Resolve any specs that need suite lookup - specs, unmatched = resolve_specs(sandbox, specs) - if unmatched: - for suite, pattern in unmatched: - tout.error(f'No tests found matching: {pattern}') - return 1 - - # Validate that specs match actual tests - unmatched = validate_specs(sandbox, specs) - if unmatched: - for suite, pattern in unmatched: - if pattern: - tout.error(f'No tests found matching: {suite}.{pattern}') - else: - tout.error(f'No tests found in suite: {suite}') - return 1 - - # Run tests - return run_tests(sandbox, specs, args, args.col) + ret = run_tests(sandbox, specs, args, args.col) + return ret diff --git a/uman_pkg/ftest.py b/uman_pkg/ftest.py index 9935044..a3f91fb 100644 --- a/uman_pkg/ftest.py +++ b/uman_pkg/ftest.py @@ -315,8 +315,8 @@ def test_get_dir_default(self): def test_get_cmd_basic(self): """Test basic build command generation (LTO disabled by default)""" args = cmdline.parse_args(['build', 'sandbox']) - self.assertEqual(['buildman', '-L', '-I', '-w', '--boards', 'sandbox', - '-o', '/tmp/b/sandbox'], + self.assertEqual(['buildman', '-L', '-I', '-w', '-W', '--boards', + 'sandbox', '-o', '/tmp/b/sandbox'], build.get_cmd(args, 'sandbox', '/tmp/b/sandbox')) def test_run_no_board(self): diff --git a/uman_pkg/util.py b/uman_pkg/util.py index 6179b6e..5d087ac 100644 --- a/uman_pkg/util.py +++ b/uman_pkg/util.py @@ -220,7 +220,24 @@ def format_duration(seconds): return f'{minutes}m {secs:.1f}s' -def show_summary(passed, failed, skipped, elapsed): +def format_bytes(nbytes): + """Format a byte count with K/M suffixes + + Args: + nbytes (int): Number of bytes + + Returns: + str: Formatted string (e.g. '1.2K', '3.5M', '512') + """ + if nbytes >= 1024 * 1024: + return f'{nbytes / 1024 / 1024:.1f}M' + if nbytes >= 1024: + return f'{nbytes / 1024:.1f}K' + return str(nbytes) + + +def show_summary(passed, failed, skipped, elapsed, leaked=0, + leak_bytes=0): """Show a test results summary Args: @@ -228,13 +245,46 @@ def show_summary(passed, failed, skipped, elapsed): failed (int): Number of tests failed skipped (int): Number of tests skipped elapsed (float): Time taken in seconds + leaked (int): Number of tests that leaked memory + leak_bytes (int): Total bytes leaked """ col = terminal.Color() green = col.start(terminal.Color.GREEN) red = col.start(terminal.Color.RED) yellow = col.start(terminal.Color.YELLOW) reset = col.stop() - print(f'Results: {green}{passed} passed{reset}, ' - f'{red}{failed} failed{reset}, ' - f'{yellow}{skipped} skipped{reset} in ' - f'{format_duration(elapsed)}') + magenta = col.start(terminal.Color.MAGENTA) + parts = [f'{green}{passed} passed{reset}', + f'{red}{failed} failed{reset}', + f'{yellow}{skipped} skipped{reset}'] + if leaked: + leak_str = f'{leaked} leaked' + if leak_bytes: + leak_str += f' ({format_bytes(leak_bytes)})' + parts.append(f'{magenta}{leak_str}{reset}') + print(f'Results: {", ".join(parts)} in {format_duration(elapsed)}') + + +def show_leak_top(leak_top, count): + """Show the top leaking tests with deduplicated backtraces + + Args: + leak_top (list): List of (bytes, name, details) tuples + count (int): Number of entries to show + """ + print('Top leaks:') + for nbytes, name, details in leak_top[:count]: + name = name.rstrip(':') + print(f' {format_bytes(nbytes):>7s} {name}') + by_trace = {} + for size, trace in details: + if trace in by_trace: + cnt, total = by_trace[trace] + by_trace[trace] = (cnt + 1, total + size) + else: + by_trace[trace] = (1, size) + for trace, (cnt, total) in by_trace.items(): + if cnt > 1: + print(f' {cnt}x {format_bytes(total)} {trace}') + else: + print(f' {format_bytes(total)} {trace}')