# Copyright 2021-2024 The Debusine developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Unit tests for the base Task classes."""
import abc
import datetime
import math
import os
import shlex
import signal
import subprocess
import sys
import textwrap
import time
import traceback
from pathlib import Path
from typing import Optional, Union
from unittest import TestCase, mock
from unittest.mock import MagicMock

import psutil

from debusine.artifacts import WorkRequestDebugLogs
from debusine.client.debusine import Debusine
from debusine.client.models import ArtifactResponse
from debusine.tasks import (
    BaseExternalTask,
    BaseTask,
    BaseTaskWithExecutor,
    RunCommandTask,
    TaskConfigError,
)
from debusine.tasks.executors import ExecutorInterface, InstanceInterface
from debusine.tasks.models import (
    BackendType,
    BaseTaskData,
    BaseTaskDataWithExecutor,
    TaskNotification,
    TaskNotifications,
    WorkerType,
)
from debusine.tasks.sbuild import Sbuild
from debusine.tasks.tests.helper_mixin import ExternalTaskHelperMixin
from debusine.test import TestHelpersMixin


class TestBaseTask(BaseExternalTask[BaseTaskData]):
    """Sample class to test BaseExternalTask class."""


class TestBaseTaskWithExecutor(BaseTaskWithExecutor[BaseTaskDataWithExecutor]):
    """Sample class to test BaseTaskWithExecutor class."""


class TestBaseTask2Data(BaseTaskData):
    """Data representation for TestBaseTask2."""

    foo: str
    bar: Optional[float] = None


class TestBaseTask2(BaseExternalTask[TestBaseTask2Data]):
    """Test BaseExternalTask class with data validation."""

    TASK_VERSION = 1


class BaseTaskTests(TestHelpersMixin, TestCase):
    """Unit tests for BaseTask class."""

    worker_metadata = {"system:worker_type": WorkerType.EXTERNAL}

    def setUp(self) -> None:
        """Create the shared attributes."""
        self.task = TestBaseTask({})
        self.task2 = TestBaseTask2({"foo": "bar"})

    def tearDown(self):
        """Delete directory to avoid ResourceWarning."""
        if self.task._debug_log_files_directory:
            self.task._debug_log_files_directory.cleanup()

    def test_task_initialize_member_variables(self):
        """Member variables expected used in other methods are initialized."""
        self.assertIsNone(self.task.workspace)
        self.assertEqual(self.task._source_artifacts_ids, [])

    def test_name_is_set(self):
        """task.name is built from class name."""
        self.assertEqual(self.task.name, "testbasetask")

    def test_logger_is_configured(self):
        """task.logger is available."""
        self.assertIsNotNone(self.task.logger)

    def test_prefix_with_task_name(self):
        """task.prefix_with_task_name does what it says."""
        self.assertEqual(
            self.task.prefix_with_task_name("foobar"),
            f"{self.task.name}:foobar",
        )

    def test_configure_sets_data_attribute(self):
        """task.data is set with configure."""
        task_data = {
            "foo": "bar",
            "notifications": {"on_failure": [{"channel": "deblts-email"}]},
        }

        data = TestBaseTask2Data(**task_data)

        self.assertEqual(
            data,
            TestBaseTask2Data(
                foo="bar",
                notifications=TaskNotifications(
                    on_failure=[TaskNotification(channel="deblts-email")]
                ),
            ),
        )

    def test_configure_task_data_notification_drop(self):
        """Raise TaskConfigError: "notifications" is not valid."""
        task_data = {"foo": "bar"}
        task_data_notification = {"notifications": 10}

        with self.assertRaises(TaskConfigError):
            TestBaseTask({**task_data, **task_data_notification})

    def test_configure_fails_with_bad_schema(self):
        """configure() raises TaskConfigError if schema is not respected."""
        task_data = {"nonexistent": "bar"}
        with self.assertRaises(TaskConfigError):
            TestBaseTask2(task_data)

    def test_configure_works_with_good_schema(self):
        """configure() doesn't raise TaskConfigError."""
        task_data = {"foo": "bar", "bar": 3.14}

        task2 = TestBaseTask2(task_data)

        self.assertEqual(task2.data.foo, "bar")
        self.assertEqual(task2.data.bar, 3.14)

    def test_analyze_worker_without_task_version(self):
        """analyze_worker() reports an unknown task version."""
        metadata = self.task.analyze_worker()
        self.assertIsNone(metadata["testbasetask:version"])

    def test_analyze_worker_with_task_version(self):
        """analyze_worker() reports a task version."""
        metadata = self.task2.analyze_worker()
        self.assertEqual(metadata["testbasetask2:version"], 1)

    def test_analyze_worker_all_tasks(self):
        """analyze_worker_all_tasks() reports results for each task."""
        patcher = mock.patch.object(
            BaseTask,
            "_sub_tasks",
            new={"task": TestBaseTask, "task2": TestBaseTask2},
        )
        patcher.start()
        self.addCleanup(patcher.stop)

        metadata = self.task.analyze_worker_all_tasks()

        self.assertEqual(
            metadata,
            self.task2.analyze_worker() | self.task.analyze_worker(),
        )

        # Assert that metadata contains data
        self.assertNotEqual(metadata, {})

    def test_execute(self):
        """Ensure execute raises NotImplementedError."""
        with self.assertRaises(NotImplementedError):
            self.task.execute()

    def test_class_for_name_sbuild(self):
        """class_from_name returns Sbuild (case-insensitive)."""
        self.assertEqual(BaseTask.class_from_name('sBuIlD'), Sbuild)

    def test_class_for_name_no_class(self):
        """class_from_name raises a ValueError exception."""
        with self.assertRaisesRegex(
            ValueError, "'non-existing-class' is not a registered task_name"
        ):
            BaseTask.class_from_name('non-existing-class')

    def test_class_for_name_no_duplicates(self):
        """
        BaseTask.__init_subclass__ raises AssertionError for duplicated names.

        BaseTask.__init_subclass__ uses the class name in lowercase: it can
        cause unexpected duplicated names. Check that are detected.
        """
        with self.assertRaises(AssertionError):

            class Somesubclassname(BaseTask[BaseTaskData]):
                pass

            class SomeSubclassName(BaseTask[BaseTaskData]):
                pass

    def test_init_subclass_no_register_abstract_class(self):
        """BaseTask.__init_subclass__ does not register abstract classes."""

        class Abstract(BaseTask[BaseTaskData], abc.ABC):
            @abc.abstractmethod
            def _do_something(self):
                raise NotImplementedError()

        self.assertNotIn("abstract", BaseTask.task_names())

    def test_execute_logging_exceptions_execute(self):
        """BaseTask.execute_logging_exceptions() executes self.execute."""
        patcher = mock.patch.object(self.task, "execute", autospec=True)
        mocker = patcher.start()
        self.addCleanup(patcher.stop)

        return_values = [True, False]
        for return_value in return_values:
            mocker.return_value = return_value
            self.assertEqual(
                self.task.execute_logging_exceptions(), return_value
            )

        self.assertEqual(mocker.call_count, len(return_values))

    def test_execute_logging_exceptions_handle_exception(self):
        """BaseTask.execute_logging_exceptions() logs the exception."""
        patcher = mock.patch.object(self.task, "execute", autospec=True)
        mocker = patcher.start()
        mocker.side_effect = ValueError()
        self.addCleanup(patcher.stop)

        with self.assertRaises(ValueError), self.assertLogs(self.task.logger):
            self.task.execute_logging_exceptions()

    def test_append_to_log_file(self):
        """BaseTask.append_to_lot_file() appends data to a log file."""
        contents = ["a 1", "b 2"]
        contents_str = "\n".join(contents) + "\n"

        filename = "log.txt"

        # Add data into the file
        self.task.append_to_log_file(filename, contents)

        file = Path(self.task._debug_log_files_directory.name) / filename

        self.assertEqual(file.read_text(), contents_str)

        # Add more data
        self.task.append_to_log_file(filename, contents)
        self.assertEqual(file.read_text(), contents_str * 2)

    def test_open_debug_log_file_default_is_append_mode(self):
        """
        BaseTask.open_debug_log_file() return a file in a temporary directory.

        self.task._debug_log_files_directory is set to the temporary directory.

        Calling it again: return the same file (append mode by default).
        """
        line1 = "First line\n"
        filename = "test.log"
        with self.task.open_debug_log_file(filename) as file:
            file.write(line1)

        # Verify BaseTask._debug_log_files_directory contains the file
        directory = Path(self.task._debug_log_files_directory.name)

        self.assertTrue(directory.exists())
        log_file = Path(self.task._debug_log_files_directory.name) / filename

        self.assertEqual(log_file, directory / filename)
        self.assertEqual(log_file.read_text(), line1)

        # Verify by default is in append mode: we are adding to it
        line2 = "Second line\n"
        with self.task.open_debug_log_file(filename) as file:
            file.write(line2)

        self.assertEqual(log_file.read_text(), f"{line1}{line2}")

        self.task._debug_log_files_directory.cleanup()

    def test_open_debug_log_file_mode_is_used(self):
        """BaseTask.open_debug_log_file() use the specified mode."""
        filename = "test.log"

        with self.task.open_debug_log_file(filename) as file:
            file.write("something")

        line = "new line"
        with self.task.open_debug_log_file(filename, mode="w") as file:
            file.write(line)

        file = Path(self.task._debug_log_files_directory.name) / filename
        self.assertEqual(file.read_text(), line)

    def patch_task__execute(self) -> MagicMock:
        """Return mocked object of self.task._execute()."""
        patcher = mock.patch.object(self.task, "_execute", autospec=True)
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def patch_task__upload_work_request_debug_logs(self):
        """Return mocked object of self.task.upload_work_request_debug_logs."""
        patcher = mock.patch.object(
            self.task, "_upload_work_request_debug_logs", autospec=True
        )
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def task_execute_and_assert(self, expected_value: bool):
        """
        Call task.execute() and assert return value is expected_value.

        Set up mocks and ensure that mocks are used as expected.
        """
        _execute_mocked = self.patch_task__execute()
        _execute_mocked.return_value = expected_value
        upload_work_request_debug_logs = (
            self.patch_task__upload_work_request_debug_logs()
        )

        self.assertEqual(self.task.execute(), expected_value)

        _execute_mocked.assert_called_with()
        upload_work_request_debug_logs.assert_called_with()

    def test_execute_return_true(self):
        """BaseTask.execute() returns True and calls method upload logs."""
        self.task_execute_and_assert(True)

    def test_execute_return_false(self):
        """BaseTask.execute() returns False and calls method upload logs."""
        self.task_execute_and_assert(False)

    def test_abortion_flag(self):
        """BaseTask.abort() sets the aborted flag."""
        self.assertFalse(self.task.aborted)
        self.task.abort()
        self.assertTrue(self.task.aborted)

    def test_is_valid_task_name(self):
        """BaseTask.is_valid_task_name() with a valid name."""
        self.assertTrue(self.task.is_valid_task_name("sbuild"))

    def test_is_valid_task_name_lowercases(self):
        """BaseTask.is_valid_task_name() with a valid name with upper case."""
        self.assertTrue(self.task.is_valid_task_name("Sbuild"))

    def test_is_valid_task_name_invalid(self):
        """BaseTask.is_valid_task_name() with an invalid name."""
        self.assertFalse(self.task.is_valid_task_name("not_a_real_task"))


class TestBaseExternalTask(BaseExternalTask[BaseTaskData]):
    """Sample class to test BaseExternalTask class."""


class TestBaseExternalTask2(BaseExternalTask[TestBaseTask2Data]):
    """Test BaseExternalTask class with data validation."""

    TASK_VERSION = 1


class BaseExternalTaskTests(TestHelpersMixin, TestCase):
    """Unit tests for :class:`BaseExternalTask`."""

    def setUp(self) -> None:
        """Create the shared attributes."""
        self.task = TestBaseExternalTask({})
        self.task2 = TestBaseExternalTask2({"foo": "bar"})
        self.worker_metadata = {"system:worker_type": WorkerType.EXTERNAL}

    def test_can_run_on_no_version(self):
        """Ensure can_run_on returns True if no version is specified."""
        self.assertIsNone(self.task.TASK_VERSION)
        metadata = {**self.worker_metadata, **self.task.analyze_worker()}
        self.assertEqual(self.task.can_run_on(metadata), True)

    def test_can_run_on_with_different_versions(self):
        """Ensure can_run_on returns False if versions differ."""
        self.assertIsNone(self.task.TASK_VERSION)
        metadata = {**self.worker_metadata, **self.task.analyze_worker()}
        metadata["testbaseexternaltask:version"] = 1
        self.assertFalse(self.task.can_run_on(metadata))

    def test_can_run_on_type_mismatch(self):
        """Ensure can_run_on returns False if the type & internal mismatch."""
        metadata = {**self.worker_metadata, **self.task.analyze_worker()}
        metadata["system:worker_type"] = WorkerType.CELERY
        self.assertEqual(self.task.can_run_on(metadata), False)

    def setup_upload_work_request_debug_logs(
        self, source_artifacts_ids: Optional[list[int]] = None
    ):
        """Setup for upload_work_request_debug_logs tests."""  # noqa: D401
        self.task.debusine = mock.create_autospec(spec=Debusine)

        # Add a file to be uploaded
        with self.task.open_debug_log_file("test.log") as file:
            file.write("log")

        self.task.workspace = "System"
        self.task.work_request = 5

        if source_artifacts_ids is None:
            self.task._source_artifacts_ids = []
        else:
            self.task._source_artifacts_ids = source_artifacts_ids

    def test_upload_work_request_debug_logs_with_relation(self):
        """
        Artifact is created and uploaded. Relation is created.

        The relation is from the debug logs artifact to the source_artifact_id.
        """
        remote_artifact_id = 10
        source_artifact_id = 2

        self.setup_upload_work_request_debug_logs([source_artifact_id])

        self.task.debusine.upload_artifact.return_value = MagicMock(
            id=remote_artifact_id
        )

        work_request_debug_logs_artifact = WorkRequestDebugLogs.create(
            files=Path(self.task._debug_log_files_directory.name).glob("*")
        )
        self.task._upload_work_request_debug_logs()

        self.task.debusine.upload_artifact.assert_called_with(
            work_request_debug_logs_artifact,
            workspace=self.task.workspace,
            work_request=self.task.work_request,
        )

        self.task.debusine.relation_create.assert_called_with(
            remote_artifact_id, source_artifact_id, "relates-to"
        )

    def test_upload_work_request_debug_logs(self):
        """Artifact is created and uploaded."""
        self.setup_upload_work_request_debug_logs()

        work_request_debug_logs_artifact = WorkRequestDebugLogs.create(
            files=Path(self.task._debug_log_files_directory.name).glob("*")
        )

        self.task._upload_work_request_debug_logs()

        self.task.debusine.upload_artifact.assert_called_with(
            work_request_debug_logs_artifact,
            workspace=self.task.workspace,
            work_request=self.task.work_request,
        )

    def test_upload_work_request_no_log_files(self):
        """No log files: no artifact created."""
        self.task.debusine = mock.create_autospec(spec=Debusine)

        self.task._upload_work_request_debug_logs()

        self.task.debusine.upload_artifact.assert_not_called()


class TestsTaskConfigError(TestCase):
    """Tests for TaskConfigError."""

    def test_without_original_exception(self):
        """Test TaskConfigError (init and str) without original exception."""
        msg = "This is an error message"

        error = TaskConfigError(msg)

        self.assertEqual(str(error), msg)
        self.assertIsNone(error.original_exception)

    def test_str_with_original_exception(self):
        """Test TaskConfigError (init and str) with original exception."""
        msg = "This is an error message"
        original_exc = Exception("something")

        error = TaskConfigError(msg, original_exception=original_exc)

        self.assertEqual(
            str(error), f"{msg} (Original exception: {original_exc})"
        )
        self.assertIs(error.original_exception, original_exc)


class RunCommandTaskForTesting(RunCommandTask[BaseTaskData]):
    """Used in TaskTests."""

    def __init__(
        self,
        abort_after_aborted_calls: Union[int, float] = math.inf,
        wait_popen_sigkill=False,
        pids_directory=None,
        wait_for_sigusr1=True,
        send_signal_to_cmd=None,
    ):
        """
        Initialise object used in the tests.

        :param abort_after_aborted_calls: abort() method will return False
          until this number. Then will return True on each subsequent call
        :param wait_popen_sigkill: if True the first call to _wait_popen()
          will do super()._wait_popen() AND also kill the process group.
        :param pids_directory: the directory where the PID.log files are
          expected to appear.
        :param wait_for_sigusr1: if True on the first _wait_popen will
          block until SIGUSR1 is received
        :param send_signal_to_cmd: if it's a signal (SIGUSR1, SIGUSR2) will
          run the command normally. When self.aborted is accessed twice it
          will send the signal to the command
        """
        super().__init__({})

        self._abort_after_aborted_calls = abort_after_aborted_calls
        self._wait_popen_sigkill = wait_popen_sigkill
        self._pids_directory = pids_directory
        self._wait_for_sigusr1 = wait_for_sigusr1
        self._send_signal_to_cmd = send_signal_to_cmd

        self._children_initialized = False
        self._popen = None

        self.aborted_call_count = 0

    @property
    def aborted(self):
        """
        Return False (not aborted) or True.

        True when has been called more times than abort_after_aborted_calls
        parameter used in the __init__().

        If send_signal_to_cmd it will send the signal to the PID.
        """
        self.aborted_call_count += 1

        if self.aborted_call_count == 2 and self._send_signal_to_cmd:
            os.kill(self._popen.pid, self._send_signal_to_cmd)

        return self.aborted_call_count > self._abort_after_aborted_calls

    def _do_wait_for_sigusr1(self):
        """
        Will wait up to 5 seconds to receive SIGUSR1.

        SIGUSR1 is sent by the signal-logger.sh when the last process
        is spawned.
        """
        if self._children_initialized:
            return

        result = signal.sigtimedwait({signal.SIGUSR1}, 5)

        if result is None:  # pragma: no cover
            # No signal received after the timeout. This should never
            # happen: signal-logger.sh should have sent on time.
            # (the timeout is to fail faster in case that signal-logger.sh
            # is not working)
            raise RuntimeError("SIGUSR1 not received")

        self._children_initialized = True

    def wait_cmd_zombie_process(self):
        """
        Wait and get the returncode for cmd.

        TaskMixins SIGKILLed cmd. cmd probably was in a non-interruptable
        call in the Kernel and TaskMixins did not wait and cmd is not
        left zombie. Here it collects the returncode, so it is
        not zombie and avoids a Popen.__del__ ResourceWarning
        """
        self._popen.wait(timeout=5)

    def _wait_popen(self, popen, timeout):  # noqa: U100
        # Calls TaskMixins._wait_popen() with a short timeout

        # Depending on self._wait_popen_sigkill will send a SIGKILL
        # to the process group

        self._popen = popen  # Used by wait_cmd_zombie_process()

        if self._wait_for_sigusr1:
            self._do_wait_for_sigusr1()

        try:
            result = super()._wait_popen(popen, 0.001)
        except subprocess.TimeoutExpired as exc:
            result = exc

        if self._wait_popen_sigkill:
            pid = int(next(self._pids_directory.glob("*.pid")).stem)

            process_group = os.getpgid(pid)

            os.killpg(process_group, signal.SIGKILL)

            # If _wait_popen is called again: no killing again
            self._wait_popen_sigkill = False

        if isinstance(result, Exception):
            raise result
        else:
            return result


class RunCommandTaskTests(
    TestHelpersMixin,
    ExternalTaskHelperMixin[RunCommandTaskForTesting],
    TestCase,
):
    """Tests for RunCommandTask methods in isolation."""

    stdout_output = "Something written to stdout"
    stderr_output = "Something written to stderr"

    def setUp(self) -> None:
        """Set up tests."""
        self.temp_directory = self.create_temporary_directory()
        self.task = RunCommandTaskForTesting()

    def tearDown(self):
        """Delete temporary directory, if it exists."""
        if self.task._debug_log_files_directory:
            self.task._debug_log_files_directory.cleanup()

    def test_cmdline_as_root(self):
        """_cmdline_as_root return False."""
        self.assertFalse(self.task._cmdline_as_root())

    def test_temporary_directory(self):
        """
        _temporary_directory() return a directory.

        The directory is deleted when the context finishes.
        """
        with RunCommandTask._temporary_directory() as temp_dir:
            self.assertIsInstance(temp_dir, Path)
            self.assertTrue(temp_dir.is_dir())
            self.assertTrue(
                temp_dir.name.startswith("debusine-fetch-exec-upload-")
            )

        # Ensure the temporary directory is deleted after the context is exited
        self.assertFalse(temp_dir.exists())

    def test_fetch_artifact(self):
        """
        fetch_artifact() download the artifact id and update variables.

        Update: self._source_artifacts_ids
        """
        directory = self.create_temporary_directory()

        artifact_id = 5
        artifact_workspace = "test"

        self.task.debusine = mock.create_autospec(spec=Debusine)

        artifact_response = ArtifactResponse(
            workspace=artifact_workspace,
            id=5,
            category="debusine:test",
            created_at=datetime.datetime.now(),
            data={},
            download_tar_gz_url="https://example.com",
            files_to_upload=[],
        )

        self.task.debusine.download_artifact.return_value = artifact_response

        self.assertEqual(
            self.task.fetch_artifact(artifact_id, directory), artifact_response
        )

        self.task.debusine.download_artifact.assert_called_with(
            artifact_id, directory, tarball=False
        )

        self.assertEqual(self.task._source_artifacts_ids, [artifact_id])

    def test_fetch_artifact_without_configure_server_access(self):
        """fetch_artifact() asserts that self.debusine is set."""
        self.assertRaisesRegex(
            AssertionError,
            r"^self\.debusine not set",
            self.task.fetch_artifact,
            1,
            self.create_temporary_directory(),
        )

    def test_task_succeeded(self):
        """task_succeeded() in the Mixin return True by default."""
        directory = self.create_temporary_directory()
        self.assertTrue(self.task.task_succeeded(0, directory))

    def test_check_directory_for_consistency(self):
        """Default implementation return an empty list."""
        self.assertEqual(
            self.task.check_directory_for_consistency_errors(
                self.create_temporary_directory()
            ),
            [],
        )

    def test_upload_artifacts_raise_not_implemented(self):
        """Mixin raise NotImplementedError."""
        with self.assertRaises(NotImplementedError):
            self.task.upload_artifacts(
                self.create_temporary_directory(), execution_success=True
            )

    def test_cmdline_raise_not_implemented(self):
        """Mixin raise NotImplementedError."""
        with self.assertRaises(NotImplementedError):
            self.task._cmdline()

    def test_fetch_input_raise_not_implemented(self):
        """Mixin raise NotImplementedError."""
        with self.assertRaises(NotImplementedError):
            self.task.fetch_input(self.create_temporary_directory())

    def test_run_cmd_succeeded(self):
        """run_cmd_succeeded() return True/False depending on returncode."""
        self.assertTrue(self.task.run_cmd_succeeded(0))
        self.assertFalse(self.task.run_cmd_succeeded(1))

    def test_run_cmd_run_as_root_raise_value_error(self):
        """run_cmd() raise ValueError: run_as_root requires executor."""
        with self.create_temporary_file().open(
            "wb"
        ) as cmd_log, self.create_temporary_file().open("wb") as out_file:
            msg = r"^run_as_root requires an executor$"
            self.assertRaisesRegex(
                ValueError,
                msg,
                self.task._run_cmd,
                ["true"],
                self.create_temporary_directory(),
                run_as_root=True,
                cmd_log=cmd_log,
                out_file=out_file,
            )

    def test_execute_configure_for_execution_logs(self):
        """_execute(): if configure_for_execution() raise an exc: log it."""
        msg = "There is some exception"

        exc = Exception(msg)

        self.patch_task("fetch_input")
        configure_for_execution_mocked = self.patch_task(
            "configure_for_execution"
        )
        configure_for_execution_mocked.side_effect = exc

        self.assertFalse(self.task._execute())

        log = (
            Path(self.task._debug_log_files_directory.name)
            / "configure_for_execution.log"
        ).read_text()

        self.assertRegex(log, f"^Exception type: Exception\nMessage: {msg}")

        if sys.version_info >= (3, 10):  # pragma: no cover
            # traceback.format_exception(exc) works on Python => 3.10
            traceback_str = "".join(traceback.format_exception(exc))
            self.assertIn(traceback_str, log)

    def test_execute_cmdline_logs(self):
        """_execute(): if _cmdline() raise an exc: log it."""
        msg = "There is some exception"

        exc = Exception(msg)

        self.patch_task("fetch_input")
        self.patch_task("configure_for_execution")
        self.patch_task("_cmdline").side_effect = exc

        self.assertFalse(self.task._execute())

        log = (
            Path(self.task._debug_log_files_directory.name) / "execution.log"
        ).read_text()

        self.assertRegex(log, f"^Exception type: Exception\nMessage: {msg}")

        if sys.version_info >= (3, 10):  # pragma: no cover
            # traceback.format_exception(exc) works on Python => 3.10
            traceback_str = "".join(traceback.format_exception(exc))
            self.assertIn(traceback_str, log)

    def test_execute_check_directory_for_consistency_errors_logs(self):
        """_execute(): if check_directory_for_consistency_errors() fails."""
        msg = "There is some exception"

        exc = Exception(msg)

        self.patch_task("fetch_input")
        self.patch_task("configure_for_execution")
        self.patch_task("_cmdline")
        self.patch_task("run_cmd")
        self.patch_task("run_cmd_succeeded")
        self.patch_task(
            "check_directory_for_consistency_errors"
        ).side_effect = exc

        self.assertFalse(self.task._execute())

        log = (
            Path(self.task._debug_log_files_directory.name)
            / "post_execution.log"
        ).read_text()

        self.assertRegex(log, f"^Exception type: Exception\nMessage: {msg}")

        if sys.version_info >= (3, 10):  # pragma: no cover
            # traceback.format_exception(exc) works on Python => 3.10
            traceback_str = "".join(traceback.format_exception(exc))
            self.assertIn(traceback_str, log)

    def patch_task(self, method: str) -> MagicMock:
        """Patch self.task.{method}, return its mock."""
        patcher = mock.patch.object(self.task, method, autospec=True)
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def test_execute_call_directory_push(self):
        """_execute(): self.executor is not None: call directory_push()."""
        self.task.executor_instance = MagicMock(spec=InstanceInterface)

        fetch_input_mocked = self.patch_task("fetch_input")
        fetch_input_mocked.return_value = True
        self.patch_task("configure_for_execution")

        with mock.patch.object(
            self.task, "_cmdline", autospec=True, return_value=["ls"]
        ):
            with mock.patch.object(
                self.task, "upload_artifacts", autospec=True
            ):
                self.task._execute()

        download_directory = fetch_input_mocked.call_args[0][0]
        self.task.executor_instance.directory_push(
            download_directory, download_directory
        )

    def test_run_cmd_shuts_down_remaining_executors(self):
        """_execute() self.executor is not None: shut down executor."""
        self.task.executor_instance = MagicMock(spec=InstanceInterface)
        self.task.executor_instance.run.return_value.returncode = 0
        self.task.executor_instance.is_started.return_value = True

        fetch_input_mocked = self.patch_task("fetch_input")
        fetch_input_mocked.return_value = True
        self.patch_task("configure_for_execution")

        with mock.patch.object(
            self.task, "_cmdline", autospec=True, return_value=["ls"]
        ), mock.patch.object(self.task, "upload_artifacts", autospec=True):
            self.task._execute()

        self.task.executor_instance.stop.assert_called_once()

    def run_signal_logger(
        self, task: RunCommandTaskForTesting, options
    ) -> Optional[int]:
        """Run signal logger in task with options."""
        # Block SIGUSR1 signal in case that it is delivered before
        # _do_wait_for_sigusr1 collects it

        signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR1})

        self.addCleanup(
            signal.pthread_sigmask, signal.SIG_UNBLOCK, {signal.SIGUSR1}
        )

        script_path = self.write_signal_logger()

        return task.run_cmd(
            [
                str(script_path),
                str(self.temp_directory),
                str(os.getpid()),
                *options,
            ],
            self.temp_directory,
        )

    def run_success_failure(
        self, task: RunCommandTaskForTesting
    ) -> Optional[int]:
        """Run success/failure script in task."""
        # Block SIGUSR1 signal in case that it is delivered before
        # _do_wait_for_sigusr1 collects it

        signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR1})

        self.addCleanup(
            signal.pthread_sigmask, signal.SIG_UNBLOCK, {signal.SIGUSR1}
        )

        script_path = self.write_success_failure()

        return task.run_cmd(
            [str(script_path), str(os.getpid())], self.temp_directory
        )

    @staticmethod
    def pid_exist_not_zombie(pid):
        """Return True if the pid exist and is not a zombie process."""
        try:
            status = psutil.Process(pid).status()
            # no cover: depends on the timings of the processes being terminated
            # this line in the test is never hit
            return status != "zombie"  # pragma: no cover
        except psutil.NoSuchProcess:
            return False

    def assert_processes_are_terminated(
        self, expected_number_files_to_check: int
    ):
        """
        Assert that the PIDs in self.temp_directory don't exist or a zombie.

        :param expected_number_files_to_check: number of expected processes
           (PID files) to check.
        """
        start_time = time.time()
        time_out = 2

        files_to_check = list(self.temp_directory.glob("*.pid"))
        number_of_files_to_check = len(files_to_check)

        self.assertEqual(
            expected_number_files_to_check,
            number_of_files_to_check,
            f"Expected {expected_number_files_to_check} "
            f"processes found {number_of_files_to_check}",
        )

        while True:
            alive_pids = False
            elapsed = time.time() - start_time

            if elapsed > time_out:  # pragma: no cover
                # "no cover" because if the test pass this branch
                # is not used. Not having it the Test would be waiting
                # endless for the processes to disappear
                break

            for file in files_to_check:
                pid = int(file.stem)

                if self.pid_exist_not_zombie(pid):  # pragma: no cover
                    # Usually not triggered: the processes are killed very fast
                    # hence the no cover statement.
                    alive_pids = True
                else:  # pragma: no cover
                    # Usually triggered, "no cover" needed because of the issue
                    # explained in
                    # https://github.com/nedbat/coveragepy/issues/1025
                    pass

            if alive_pids:  # pragma: no cover
                # Usually not triggered because processes are killed very fast
                # hence the no cover statement.
                pass
            else:  # pragma: no cover
                # Usually triggered, "no cover" needed because of the issue
                # explained in https://github.com/nedbat/coveragepy/issues/1025
                break  # All processes are gone

        self.assertLess(
            elapsed, time_out, "Timeout waiting for processes to disappear"
        )

    def test_execute_cmd_cancelled_killed_with_sigterm(self):
        """Execute script, kills with SIGTERM. No processes running after."""
        self.task = RunCommandTaskForTesting(abort_after_aborted_calls=2)

        # Create three processes
        result = self.run_signal_logger(self.task, ["3"])

        # The processes were killed in RunCommandTask.run_cmd (the task
        # was aborted after two aborted calls)
        self.assert_processes_are_terminated(3)

        self.assertFalse(result)

    def test_execute_cmd_cancelled_killed_with_sigkill(self):
        """Execute script, two children, killed by SIGKILL (not by SIGTERM)."""
        self.task = RunCommandTaskForTesting(abort_after_aborted_calls=1)

        result = self.run_signal_logger(self.task, ["2", "TERM"])

        self.assert_processes_are_terminated(2)

        self.assertFalse(result)

        self.task.wait_cmd_zombie_process()

    def test_execute_cmd_finished_before_killpg_sigterm(self):
        """Execute script. SIGTERM is called but processes were already dead."""
        self.task = RunCommandTaskForTesting(
            abort_after_aborted_calls=1,
            wait_popen_sigkill=True,
            pids_directory=self.temp_directory,
        )

        result = self.run_signal_logger(self.task, ["2"])

        self.assert_processes_are_terminated(2)

        self.assertFalse(result)

    def test_execute_cmd_finished_before_killpg_sigkill(self):
        """Execute script. SIGKILL is called but processes were already dead."""
        self.task = RunCommandTaskForTesting(
            abort_after_aborted_calls=2,
            wait_popen_sigkill=True,
            pids_directory=self.temp_directory,
        )

        result = self.run_signal_logger(self.task, ["2", "TERM"])

        self.assert_processes_are_terminated(2)

        self.assertFalse(result)

        output_text = (
            Path(self.task._debug_log_files_directory.name)
            / self.task.CMD_LOG_FILENAME
        ).read_text()

        self.assertIn(
            textwrap.dedent(
                """\
            output (contains stdout and stderr):

            aborted: True
            returncode: -9
            """
            ),
            output_text,
        )
        self.assertTrue(output_text.startswith("cmd: "))

    def test_command_finish_with_success(self):
        """Execute script. No abort, cmd return code is 0 (success)."""
        self.task = RunCommandTaskForTesting(
            abort_after_aborted_calls=math.inf,
            send_signal_to_cmd=signal.SIGHUP,
        )

        returncode = self.run_success_failure(self.task)

        self.assertEqual(returncode, 0)
        self.assertTrue(
            (
                Path(self.task._debug_log_files_directory.name)
                / self.task.CMD_LOG_FILENAME
            )
            .read_text()
            .startswith("cmd: ")
        )

    def test_command_finish_with_failure(self):
        """Execute script. No abort, result and log file existence."""
        self.task = RunCommandTaskForTesting(
            abort_after_aborted_calls=math.inf,
            send_signal_to_cmd=signal.SIGUSR2,
        )

        returncode = self.run_success_failure(self.task)

        self.assertEqual(returncode, 1)
        self.assertTrue(
            (
                Path(self.task._debug_log_files_directory.name)
                / self.task.CMD_LOG_FILENAME
            )
            .read_text()
            .startswith("cmd: ")
        )

    def write_success_failure(self):
        """
        Write to self.temp_directory a script that traps SIGHUP and SIGUSR2.

        For SIGHUP exits with exitcode == 0, SIGUSR2 exits with exitcode == 1.

        :return: script path
        """
        script_file = Path(self.temp_directory) / "success-failure.sh"

        with script_file.open("w") as f:
            f.write(
                textwrap.dedent(
                    f"""\
                #!/bin/sh

                # Arguments:
                # $1: PID of the process to send a SIGUSR1 when the
                # busy-wait state is reached

                pid_to_notify="$1"

                trap "exit 0" HUP
                trap "exit 1" USR2

                echo "{self.stdout_output}"
                echo "{self.stderr_output}" >&2

                kill -s USR1 $pid_to_notify

                while true
                do
                    true
                    # with a sleep there is a delay until the script
                    # process the signals
                done
                """
                )
            )

        script_file.chmod(0o700)

        return script_file

    def write_signal_logger(self):
        """
        Write to self.temp_directory a script to test finishing of processes.

        :return: script path
        """
        script_file = Path(self.temp_directory) / "signal-logger.sh"

        with script_file.open("w") as f:
            f.write(
                textwrap.dedent(
                    """\
            #!/bin/sh

            # Arguments:
            # $1: output directory for the log files. Must exist
            # $2: PID of the process to send a SIGUSR1 when all children
            #     has been created
            # $3: number of generation of processes to launch
            # $4: (optional) If "TERM": traps TERM signal (logs it) and do not
            #                die

            log() {
                echo "$1" >> "$output_directory/$$.pid"
            }

            output_directory="$1"
            pid_to_notify="$2"
            to_spawn="$3"
            term="$4"

            if [ "$term" = "TERM" ]
            then
                trap "log SIGTERM" TERM
            fi

            log "Started $$"

            if [ "$to_spawn" -gt 1 ]
            then
                to_spawn=$((to_spawn-1))
                cmd="$0 $output_directory $pid_to_notify $to_spawn $term"
                $cmd &
                log "$$ launched $!"
            else
                kill -s USR1 $pid_to_notify
            fi

            while true
            do
                sleep 1
            done
            """
                )
            )

        script_file.chmod(0o700)

        return script_file

    def test_run_cmd_uses_executor(self):
        """_run_cmd() uses executor_instance to run the command."""
        cmd = ["echo", "test"]
        temp_directory = self.create_temporary_directory()
        expected_returncode = 5

        self.task.executor_instance = MagicMock(spec=InstanceInterface)
        self.task.executor_instance.run.return_value.returncode = (
            expected_returncode
        )

        with self.create_temporary_file().open(
            "wb"
        ) as cmd_log, self.create_temporary_file().open("wb") as out_file:
            actual_returncode = self.task._run_cmd(
                cmd,
                temp_directory,
                run_as_root=False,
                cmd_log=cmd_log,
                out_file=out_file,
            )

            self.task.executor_instance.run.assert_called_with(
                cmd,
                cwd=temp_directory,
                run_as_root=self.task._cmdline_as_root(),
                stderr=cmd_log.fileno(),
                stdout=out_file.fileno(),
            )

            self.assertEqual(actual_returncode, expected_returncode)

    def test_run_cmd_large_stdout_stderr(self):
        """
        Ensure that a command generating large output is handled correctly.

        Generating more than 65536 bytes can be problematic depending on
        how subprocess.Popen's stdout/stderr is handed.
        """
        max_bytes = 70_000
        command = [
            "bash",
            "-c",
            f"for ((i=0;i<{max_bytes};i++))\n"
            "do echo -n 'o' && echo -n 'e' >&2; done",
        ]

        self.task = RunCommandTaskForTesting(wait_for_sigusr1=False)

        returncode = self.task.run_cmd(command, self.temp_directory)

        self.assertEqual(returncode, 0)
        log_file = (
            Path(self.task._debug_log_files_directory.name)
            / self.task.CMD_LOG_FILENAME
        )
        self.assertGreater(log_file.stat().st_size, max_bytes * 2)

    def test_run_cmd_output_appended(self):
        """If two run commands are used: output log is appended."""
        self.task = RunCommandTaskForTesting(wait_for_sigusr1=False)

        self.task.run_cmd(["true"], self.temp_directory)
        self.task.run_cmd(["false"], self.temp_directory)

        log_file = (
            Path(self.task._debug_log_files_directory.name)
            / self.task.CMD_LOG_FILENAME
        )
        contents = log_file.read_text()

        self.assertIn("cmd: true", contents)
        self.assertIn("cmd: false", contents)

    def test_run_cmd_capture_output(self):
        """When the parameter capture_output=True: stdout is in result.out."""
        stdout_output = "this is the output\nsecond line"
        stderr_output = "this is stderr\nsecond line"
        command = [
            "bash",
            "-c",
            f"echo -e '{stdout_output}'; echo -e '{stderr_output}' >&2",
        ]
        self.task = RunCommandTaskForTesting(wait_for_sigusr1=False)

        stdout_filename = "stdout.txt"

        returncode = self.task.run_cmd(
            command,
            self.temp_directory,
            capture_stdout_filename=stdout_filename,
        )

        self.assertEqual(returncode, 0)

        stdout_file = self.temp_directory / stdout_filename

        self.assertEqual(stdout_file.read_text(), stdout_output + "\n")

        command_quoted = shlex.join(command)

        expected_log = (
            f"cmd: {command_quoted}\n"
            f"output (contains stderr only, stdout was captured):\n"
            f"{stderr_output}\n\n"
            f"aborted: False\n"
            f"returncode: 0\n"
            f"\n"
            f"Files in working directory:\n"
            f"stdout.txt\n"
            f"{RunCommandTask.CMD_LOG_SEPARATOR}\n"
            f"this is the output\n"
            f"second line\n"
            f"\n"
            f"{RunCommandTask.CMD_LOG_SEPARATOR}\n"
        )

        log_file = (
            Path(self.task._debug_log_files_directory.name)
            / self.task.CMD_LOG_FILENAME
        )

        self.assertEqual(log_file.read_text(), expected_log)


class BaseTaskWithExecutorTests(TestHelpersMixin, TestCase):
    """Unit tests for BaseTaskWithExecutor class."""

    def setUp(self) -> None:
        """Create the shared attributes."""
        self.task = TestBaseTaskWithExecutor({})

    def patch_executor(self) -> MagicMock:
        """Patch debusine.tasks._task.executor(), return its mock."""
        patcher = mock.patch("debusine.tasks._task.executor", autospec=True)
        mocked = patcher.start()
        self.addCleanup(patcher.stop)
        return mocked

    def setup_task_with_mocked_executor(self) -> MagicMock:
        """
        Set up common task environment for preparing executor tests.

        Return mock of executor().
        """
        backend = BackendType.UNSHARE
        environment_id = 89

        self.task.data.backend = backend
        self.task.data.environment_id = environment_id
        self.task.debusine = MagicMock(spec=Debusine)
        return self.patch_executor()

    def test_backend_auto_default_backend(self):
        """If backend is "auto": BaseTask.backend return DEFAULT_BACKEND."""
        self.task.data.backend = "auto"

        self.assertEqual(self.task.backend, self.task.DEFAULT_BACKEND)

    def test_backend_return_backend(self):
        """BaseTask.backend return the backend."""
        backend = "schroot"
        self.task.data.backend = backend

        self.assertEqual(self.task.backend, backend)

    def test_prepare_executor(self):
        """BaseTask._prepare_executor() create an executor, download image."""
        executor_mocked = self.setup_task_with_mocked_executor()
        executor_instance_mock = executor_mocked.return_value

        self.task._prepare_executor()

        # Function executor() is called with the right parameters
        executor_mocked.assert_called_with(
            self.task.debusine,
            self.task.data.backend,
            self.task.data.environment_id,
        )

        # Executor's download_image() is called
        executor_instance_mock.download_image.assert_called_with()

        # self.task.executor is the executor
        self.assertIs(self.task.executor, executor_instance_mock)

    def test_prepare_executor_instance_executor_is_none(self):
        """
        BaseTask._prepare_executor_instance() create an instance, set it.

        Task.executor was None: create the executor as well.
        """
        self.task.executor = None
        executor_mocked = self.setup_task_with_mocked_executor()

        # executor_mocked.create() will be called, set up mock to verify
        executor_instance_mock = MagicMock(spec=InstanceInterface)
        executor_mocked.return_value.create.return_value = (
            executor_instance_mock
        )

        self.task._prepare_executor_instance()

        executor_mocked.assert_called()
        self.assertIs(self.task.executor_instance, executor_instance_mock)
        executor_instance_mock.start.assert_called_with()

    def test_prepare_executor_instance(self):
        """BaseTask._prepare_executor_instance() create an instance, set it."""
        self.task.executor = MagicMock(spec=ExecutorInterface)
        executor_instance_mock = MagicMock(spec=InstanceInterface)
        self.task.executor.create.return_value = executor_instance_mock

        self.task._prepare_executor_instance()

        # In this case, executor_mocked should not be called
        self.task.executor.assert_not_called()

        self.assertIs(self.task.executor_instance, executor_instance_mock)
        executor_instance_mock.start.assert_called_with()
