diff --git a/colcon_output/event_handler/summary.py b/colcon_output/event_handler/summary.py index be68f7f..b6d57d9 100644 --- a/colcon_output/event_handler/summary.py +++ b/colcon_output/event_handler/summary.py @@ -71,47 +71,62 @@ def _print_summary(self): duration = time.monotonic() - self._start_time duration_string = format_duration(duration) - count, job_type, _ = _msg_arguments( - self._ended - self._interrupted - self._failed) + blocked_jobs = self._queued - self._ended + + # convert jobs to package names + ended = {j.task_context.pkg for j in self._ended} + interrupted = {j.task_context.pkg for j in self._interrupted} + failed = {j.task_context.pkg for j in self._failed} + with_stderr = {j.task_context.pkg for j in self._with_stderr} + with_test_failures = { + j.task_context.pkg for j in self._with_test_failures} + blocked = {j.task_context.pkg for j in blocked_jobs} + + # packages with successful jobs and blocked jobs are "interrupted" + interrupted |= ended & blocked - failed + + # truly "blocked" packages have no jobs attempted + blocked -= ended + + count, job_type, _ = _msg_arguments(ended - interrupted - failed) print('Summary: {count} {job_type} finished ' '[{duration_string}]'.format_map(locals())) - if self._failed: - count, job_type, names = _msg_arguments(self._failed) + if failed: + count, job_type, names = _msg_arguments(failed) print(' {count} {job_type} failed: {names}' .format_map(locals())) - if self._interrupted: - count, job_type, names = _msg_arguments(self._interrupted) + if interrupted: + count, job_type, names = _msg_arguments(interrupted) print(' {count} {job_type} aborted: {names}' .format_map(locals())) - if self._with_stderr: - count, job_type, names = _msg_arguments(self._with_stderr) + if with_stderr: + count, job_type, names = _msg_arguments(with_stderr) print( ' {count} {job_type} had stderr output: {names}' .format_map(locals())) - if self._with_test_failures: - count, job_type, names = _msg_arguments( - self._with_test_failures) + if with_test_failures: + count, job_type, names = _msg_arguments(with_test_failures) print( ' {count} {job_type} had test failures: {names}' .format_map(locals())) - if len(self._queued) > len(self._ended): - count = len(self._queued - self._ended) + if blocked: + count = len(blocked) job_type = get_job_type_word_form(count) print( ' {count} {job_type} not processed' .format_map(locals())) -def _msg_arguments(jobs): +def _msg_arguments(packages): return ( - len(jobs), - get_job_type_word_form(len(jobs)), - ' '.join(sorted(j.task.context.pkg.name for j in jobs)), + len(packages), + get_job_type_word_form(len(packages)), + ' '.join(sorted(p.name for p in packages)), ) diff --git a/test/spell_check.words b/test/spell_check.words index ecd4bf7..4deba61 100644 --- a/test/spell_check.words +++ b/test/spell_check.words @@ -1,4 +1,5 @@ apache +capsys colcon defaultdict enospc @@ -10,9 +11,11 @@ pathlib plugin pydocstyle pytest +readouterr rtype scspell setuptools sigint stacktrace thomas +unittest diff --git a/test/test_summary.py b/test/test_summary.py new file mode 100644 index 0000000..25bb908 --- /dev/null +++ b/test/test_summary.py @@ -0,0 +1,205 @@ +# Copyright 2026 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +from pathlib import Path +from unittest.mock import Mock + +from colcon_core.event.job import JobEnded +from colcon_core.event.job import JobQueued +from colcon_core.event.output import StderrLine +from colcon_core.event.test import TestFailure as _TestFailure +from colcon_core.event_reactor import EventReactorShutdown +from colcon_core.executor import Job +from colcon_core.package_descriptor import PackageDescriptor +from colcon_core.subprocess import SIGINT_RESULT +from colcon_core.task import TaskContext +from colcon_output.event_handler.summary import SummaryHandler +import pytest + + +@pytest.fixture +def summary_handler(): + return SummaryHandler() + + +def _create_mock_job(name, pkg=None): + if pkg is None: + pkg = PackageDescriptor(Path('/path/to/' + name)) + pkg.name = name + + context = TaskContext(pkg=pkg, args=None, dependencies=None) + + task = Mock() + task.context = context + + job = Job( + identifier=name, dependencies=set(), task=task, + task_context=context) + return job + + +def test_successful_job(summary_handler, capsys): + job = _create_mock_job('pkg_a') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + summary_handler((JobEnded('pkg_a', 0), job)) + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '1 package finished' in captured.out + + +def test_failed_job(summary_handler, capsys): + job = _create_mock_job('pkg_fail') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + summary_handler((JobEnded('pkg_fail', 1), job)) + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '0 packages finished' in captured.out + assert '1 package failed: pkg_fail' in captured.out + + +def test_interrupted_job(summary_handler, capsys): + job = _create_mock_job('pkg_abort') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + summary_handler((JobEnded('pkg_abort', SIGINT_RESULT), job)) + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '0 packages finished' in captured.out + assert '1 package aborted: pkg_abort' in captured.out + + +def test_stderr_job(summary_handler, capsys): + job = _create_mock_job('pkg_stderr') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + summary_handler((StderrLine(b'error'), job)) + summary_handler((JobEnded('pkg_stderr', 0), job)) + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '1 package finished' in captured.out + assert '1 package had stderr output: pkg_stderr' in captured.out + + +def test_test_failure_job(summary_handler, capsys): + job = _create_mock_job('pkg_test_fail') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + summary_handler((_TestFailure('pkg_test_fail'), job)) + summary_handler((JobEnded('pkg_test_fail', 0), job)) + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '1 package finished' in captured.out + assert '1 package had test failures: pkg_test_fail' in captured.out + + +def test_blocked_job(summary_handler, capsys): + job = _create_mock_job('pkg_blocked') + summary_handler((JobQueued(job.task_context.pkg.name), job)) + # Never ends + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + assert '0 packages finished' in captured.out + assert '1 package not processed' in captured.out + + +def test_multi_job_interrupted(summary_handler, capsys): + # One job succeeds, one job blocked -> the package is considered aborted + shared_pkg = PackageDescriptor(Path('/path/to/pkg_multi')) + shared_pkg.name = 'pkg_multi' + shared_pkg.type = 'mock' + + job1 = _create_mock_job('pkg_multi', pkg=shared_pkg) + job2 = _create_mock_job('pkg_multi', pkg=shared_pkg) + + summary_handler((JobQueued(job1.task.context.pkg.name), job1)) + summary_handler((JobQueued(job2.task.context.pkg.name), job2)) + + summary_handler((JobEnded('pkg_multi', 0), job1)) + # job2 never ends + + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + + # "interrupted |= ended & blocked - failed" means it's aborted, + # not finished, not blocked + assert '0 packages finished' in captured.out + assert '1 package aborted: pkg_multi' in captured.out + + +def test_multi_job_failed_early(summary_handler, capsys): + shared_pkg = PackageDescriptor(Path('/path/to/pkg_multi_fail_early')) + shared_pkg.name = 'pkg_multi_fail_early' + + job1 = _create_mock_job('pkg_multi_fail_early', pkg=shared_pkg) + job2 = _create_mock_job('pkg_multi_fail_early', pkg=shared_pkg) + + summary_handler((JobQueued(job1.task.context.pkg.name), job1)) + summary_handler((JobQueued(job2.task.context.pkg.name), job2)) + + summary_handler((JobEnded('pkg_multi_fail_early', 1), job1)) + # job2 never ends + + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + + assert '0 packages finished' in captured.out + assert '1 package failed: pkg_multi_fail_early' in captured.out + + +def test_multi_job_failed_late(summary_handler, capsys): + shared_pkg = PackageDescriptor(Path('/path/to/pkg_multi_fail_late')) + shared_pkg.name = 'pkg_multi_fail_late' + + job1 = _create_mock_job('pkg_multi_fail_late', pkg=shared_pkg) + job2 = _create_mock_job('pkg_multi_fail_late', pkg=shared_pkg) + + summary_handler((JobQueued(job1.task.context.pkg.name), job1)) + summary_handler((JobQueued(job2.task.context.pkg.name), job2)) + + summary_handler((JobEnded('pkg_multi_fail_late', 0), job1)) + summary_handler((JobEnded('pkg_multi_fail_late', 1), job2)) + + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + + assert '0 packages finished' in captured.out + assert '1 package failed: pkg_multi_fail_late' in captured.out + + +def test_multi_job_stderr(summary_handler, capsys): + shared_pkg = PackageDescriptor(Path('/path/to/pkg_multi_stderr')) + shared_pkg.name = 'pkg_multi_stderr' + + job1 = _create_mock_job('pkg_multi_stderr', pkg=shared_pkg) + job2 = _create_mock_job('pkg_multi_stderr', pkg=shared_pkg) + + summary_handler((JobQueued(job1.task.context.pkg.name), job1)) + summary_handler((JobQueued(job2.task.context.pkg.name), job2)) + + summary_handler((StderrLine(b'error'), job1)) + summary_handler((JobEnded('pkg_multi_stderr', 0), job1)) + summary_handler((JobEnded('pkg_multi_stderr', 0), job2)) + + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + + assert '1 package finished' in captured.out + assert '1 package had stderr output: pkg_multi_stderr' in captured.out + + +def test_multi_job_test_failure(summary_handler, capsys): + shared_pkg = PackageDescriptor(Path('/path/to/pkg_multi_test_fail')) + shared_pkg.name = 'pkg_multi_test_fail' + + job1 = _create_mock_job('pkg_multi_test_fail', pkg=shared_pkg) + job2 = _create_mock_job('pkg_multi_test_fail', pkg=shared_pkg) + + summary_handler((JobQueued(job1.task.context.pkg.name), job1)) + summary_handler((JobQueued(job2.task.context.pkg.name), job2)) + + summary_handler((JobEnded('pkg_multi_test_fail', 0), job1)) + summary_handler((_TestFailure('pkg_multi_test_fail'), job2)) + summary_handler((JobEnded('pkg_multi_test_fail', 0), job2)) + + summary_handler((EventReactorShutdown(), None)) + captured = capsys.readouterr() + + assert '1 package finished' in captured.out + assert '1 package had test failures: pkg_multi_test_fail' in captured.out