From e6a589c68f2575b115db97b529f69cb6610926ff Mon Sep 17 00:00:00 2001 From: Jakub Zaborowski Date: Fri, 13 Mar 2026 13:36:33 +0100 Subject: [PATCH] Add methods for async execution on target Add async exec to docker & qemu targets Move common exec tests to a single file --- score/itf/core/process/BUILD | 4 +- score/itf/core/process/async_process.py | 61 ++++++++ score/itf/core/process/wrapped_process.py | 123 ++++++++++++++++ score/itf/core/target/target.py | 34 ++++- score/itf/plugins/docker.py | 128 ++++++++++++++++- score/itf/plugins/qemu/qemu_target.py | 162 +++++++++++++++++++++- test/BUILD | 4 + test/test_async_exec.py | 161 +++++++++++++++++++++ test/test_docker.py | 24 +++- 9 files changed, 693 insertions(+), 8 deletions(-) create mode 100644 score/itf/core/process/async_process.py create mode 100644 score/itf/core/process/wrapped_process.py create mode 100644 test/test_async_exec.py diff --git a/score/itf/core/process/BUILD b/score/itf/core/process/BUILD index e202a8a..54dc4a5 100644 --- a/score/itf/core/process/BUILD +++ b/score/itf/core/process/BUILD @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -18,8 +18,10 @@ py_library( name = "process", srcs = [ "__init__.py", + "async_process.py", "console.py", "process_wrapper.py", + "wrapped_process.py", ], visibility = ["//visibility:public"], deps = [ diff --git a/score/itf/core/process/async_process.py b/score/itf/core/process/async_process.py new file mode 100644 index 0000000..55b2c7e --- /dev/null +++ b/score/itf/core/process/async_process.py @@ -0,0 +1,61 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from abc import ABC, abstractmethod + + +class AsyncProcess(ABC): + """Common interface for a non-blocking process execution handle. + + Target implementation must conform to this contract so that :class:`WrappedProcess` can + manage process lifecycles regardless of the underlying execution backend. + """ + + @abstractmethod + def pid(self) -> int: + """Return the PID of the running process.""" + + @abstractmethod + def is_running(self) -> bool: + """Return ``True`` if the process is still executing.""" + + @abstractmethod + def get_exit_code(self) -> int: + """Return the exit code of the finished process. + + The result is only meaningful after the process has stopped. + """ + + @abstractmethod + def stop(self) -> int: + """Terminate the running process, escalating to ``SIGKILL`` if needed. + + :return: exit code of the stopped process. + """ + + @abstractmethod + def wait(self, timeout_s: float = 15) -> int: + """Block until the process finishes or *timeout_s* elapses. + + :param timeout_s: maximum seconds to wait. + :return: exit code of the process. + :raises RuntimeError: on timeout. + """ + + @abstractmethod + def get_output(self) -> str: + """Return the captured stdout of the process. + + Output is accumulated as the process runs. It is safe to call + while the process is still executing (returns what has been + captured so far) or after it has finished. + """ diff --git a/score/itf/core/process/wrapped_process.py b/score/itf/core/process/wrapped_process.py new file mode 100644 index 0000000..6525a5e --- /dev/null +++ b/score/itf/core/process/wrapped_process.py @@ -0,0 +1,123 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging +import os +import signal + +from score.itf.core.process.async_process import AsyncProcess + + +logger = logging.getLogger(__name__) + + +class WrappedProcess: + """Unified process wrapper that works with any Target implementation. + + Manages the lifecycle of a binary executed asynchronously through the + ``Target.execute_async()`` → ``AsyncProcess`` interface. + """ + + # pylint: disable=too-many-instance-attributes + # pylint: disable=too-many-arguments + + def __init__( + self, + target, + binary_path, + args=None, + cwd="/", + wait_on_exit=False, + wait_timeout=15, + enforce_clean_shutdown=False, + expected_exit_code=0, + **kwargs, + ): + self.target = target + self.binary_path = binary_path + self.args = args if args is not None else [] + self.cwd = cwd + + self.ret_code = None + self.process = None + + self._wait_on_exit = wait_on_exit + self._wait_timeout = wait_timeout + self.enforce_clean_shutdown = enforce_clean_shutdown + self.expected_exit_code = expected_exit_code + self.kwargs = kwargs + + def __enter__(self): + self.process = self.target.execute_async(self.binary_path, args=self.args, cwd=self.cwd, **self.kwargs) + return self + + def __exit__(self, exception_type, exception_value, traceback): + self.ret_code = self._handle_process_exit() + logger.debug(f"Application [{os.path.basename(self.binary_path)}] exit code: [{self.ret_code}]") + self._check_process_exit_code() + + def pid(self): + return self.process.pid() + + def is_running(self): + return self.process.is_running() + + def get_exit_code(self): + return self.process.get_exit_code() + + def stop(self): + return self.process.stop() + + def wait(self, timeout_s=15): + return self.process.wait(timeout_s) + + def get_output(self): + """Return the captured stdout of the process.""" + return self.process.get_output() + + def _handle_process_exit(self): + if self._wait_on_exit: + return self.process.wait(self._wait_timeout) + # don't wait for process natural finish, just terminate it + if self.process.is_running(): + return self.process.stop() + return self.process.get_exit_code() + + def _check_process_exit_code(self): + signal_base = 128 + acceptable_exit_codes = { + 0, + signal_base + signal.SIGTERM, + self.expected_exit_code, + } + + # If clean shutdown is not enforced, then SIGKILL is an acceptable exit code + if not self.enforce_clean_shutdown: + acceptable_exit_codes.add(signal_base + signal.SIGKILL) + + if self.ret_code not in acceptable_exit_codes: + if self.ret_code == 55: + raise RuntimeError("Sanitizers failed") + if self.ret_code == signal_base + signal.SIGKILL: + raise RuntimeError( + f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates it was stopped with SIGKILL," + " so it did not shut down gracefully, but enforce_clean_shutdown is flagged as True" + ) + if self.ret_code == signal_base + signal.SIGSEGV: + raise RuntimeError( + f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates SIGSEGV occurred." + ) + if self.ret_code == signal_base + signal.SIGABRT: + raise RuntimeError( + f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates SIGABRT occurred." + ) + raise RuntimeError(f"Application [{self.binary_path}] exit code: [{self.ret_code}] indicates an error.") diff --git a/score/itf/core/target/target.py b/score/itf/core/target/target.py index 46bbe23..ab49716 100644 --- a/score/itf/core/target/target.py +++ b/score/itf/core/target/target.py @@ -12,7 +12,10 @@ # ******************************************************************************* from abc import ABC, abstractmethod -from typing import Set, Optional, Tuple +from typing import List, Set, Optional, Tuple + +from score.itf.core.process.async_process import AsyncProcess +from score.itf.core.process.wrapped_process import WrappedProcess class Target(ABC): @@ -90,6 +93,32 @@ def remove_capability(self, capability: str) -> None: def execute(self, command: str) -> Tuple[int, bytes]: """Execute a command on the target.""" + @abstractmethod + def execute_async( + self, + binary_path: str, + args: Optional[List[str]] = None, + cwd: str = "/", + ) -> AsyncProcess: + """Start a binary without blocking and return an :class:`AsyncProcess` handle. + + :param binary_path: path to the binary to execute. + :param args: list of string arguments for the binary (default: ``None``). + :param cwd: working directory inside the target environment. + :return: an :class:`AsyncProcess` instance. + """ + + def wrap_exec( + self, + *args, + **kwargs, + ) -> WrappedProcess: + return WrappedProcess( + self, + *args, + **kwargs, + ) + @abstractmethod def upload(self, local_path: str, remote_path: str) -> None: """Upload a file from the test host to the target.""" @@ -111,6 +140,9 @@ class UnsupportedTarget(Target): def execute(self, command: str) -> Tuple[int, bytes]: raise NotImplementedError("No target plugin selected: exec is unavailable") + def execute_async(self, binary_path: str, args: Optional[List[str]] = None, cwd: str = "/") -> AsyncProcess: + raise NotImplementedError("No target plugin selected: exec is unavailable") + def upload(self, local_path: str, remote_path: str) -> None: raise NotImplementedError("No target plugin selected: upload is unavailable") diff --git a/score/itf/plugins/docker.py b/score/itf/plugins/docker.py index e0ca715..45b0bad 100644 --- a/score/itf/plugins/docker.py +++ b/score/itf/plugins/docker.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -14,16 +14,19 @@ import subprocess import io import os +import shlex import tarfile +import threading +import time import docker as pypi_docker import pytest -import shlex + +from score.itf.core.com.ssh import Ssh +from score.itf.core.process.async_process import AsyncProcess from score.itf.plugins.core import determine_target_scope from score.itf.plugins.core import Target -from score.itf.core.com.ssh import Ssh - logger = logging.getLogger(__name__) @@ -43,10 +46,80 @@ def pytest_addoption(parser): ) +class DockerAsyncProcess(AsyncProcess): + """Handle for a non-blocking command execution inside a Docker container.""" + + def __init__(self, container, client, exec_id, pid, output_thread, output_lines): + self._container = container + self._client = client + self.exec_id = exec_id + self._pid = pid + self._output_thread = output_thread + self._output_lines = output_lines + self._logger = logging.getLogger(f"async_exec.{pid}") + + def pid(self) -> int: + """Return the PID of the running command.""" + return self._pid + + def is_running(self) -> bool: + """Return *True* if the command is still executing.""" + return self._client.api.exec_inspect(self.exec_id)["Running"] + + def get_exit_code(self) -> int: + """Return the exit code of the finished command.""" + return self._client.api.exec_inspect(self.exec_id)["ExitCode"] + + def wait(self, timeout_s: float = 15) -> int: + """Block until the command finishes or *timeout_s* elapses. + + :param timeout_s: maximum seconds to wait. + :return: exit code of the command. + :raises RuntimeError: on timeout. + """ + start_time = time.time() + while self.is_running(): + if time.time() - start_time > timeout_s: + raise RuntimeError( + f"Waiting for process with PID [{self._pid}] to terminate timed out after {timeout_s} seconds" + ) + time.sleep(0.1) + self._output_thread.join() + return self.get_exit_code() + + def stop(self) -> int: + """Terminate the running command, escalating to SIGKILL if needed. + + :return: exit code of the stopped command. + """ + self._terminate() + for _ in range(5): + time.sleep(1) + if not self.is_running(): + break + if self.is_running(): + self._logger.error(f"Process with PID [{self._pid}] did not terminate properly, sending SIGKILL.") + self._kill() + self.wait() + self._output_thread.join() + return self.get_exit_code() + + def _terminate(self): + self._container.exec_run(f"kill {self._pid}") + + def _kill(self): + self._container.exec_run(f"kill -9 {self._pid}") + + def get_output(self) -> str: + """Return the captured stdout of the command.""" + return "\n".join(self._output_lines) + ("\n" if self._output_lines else "") + + class DockerTarget(Target): def __init__(self, container): super().__init__() self.container = container + self._client = pypi_docker.from_env() def __getattr__(self, name): return getattr(self.container, name) @@ -54,6 +127,51 @@ def __getattr__(self, name): def execute(self, command: str): return self.container.exec_run(f"/bin/sh -c {shlex.quote(command)}") + def execute_async(self, binary_path, args=None, cwd="/", **kwargs) -> DockerAsyncProcess: + """Start a binary without blocking and return a :class:`DockerAsyncProcess` handle. + + The command is wrapped in a shell that prints its PID first, + then runs the real command so that the PID can be used for later signal delivery. + + :param binary_path: path to the binary to execute. + :param args: list of string arguments for the binary. + :param cwd: working directory inside the container. + :return: a :class:`DockerAsyncProcess` instance for lifecycle management. + """ + if args is None: + args = [] + command = f"{binary_path} {' '.join(shlex.quote(a) for a in args)}" + # Use a list form so Docker calls execve directly — no outer shell + # quoting to worry about. The first bash prints its PID and then + # exec's a second bash that runs the (possibly compound) command. + # shlex.quote() safely wraps the user command for the inner -c arg. + exec_instance = self._client.api.exec_create( + self.container.id, + cmd=[ + "/bin/bash", + "-c", + f"echo $$; exec /bin/bash -c {shlex.quote(command)}", + ], + workdir=cwd, + ) + exec_id = exec_instance["Id"] + stream = self._client.api.exec_start(exec_id, stream=True) + pid = int(next(stream).decode().strip()) + + cmd_logger = logging.getLogger(os.path.basename(command.split()[0])) + output_lines = [] + + def _async_log(log_stream): + for chunk in log_stream: + for line in chunk.decode().strip().split("\n"): + cmd_logger.info(line) + output_lines.append(line) + + output_thread = threading.Thread(target=_async_log, args=(stream,), daemon=True) + output_thread.start() + + return DockerAsyncProcess(self.container, self._client, exec_id, pid, output_thread, output_lines) + def upload(self, local_path: str, remote_path: str) -> None: if not os.path.isfile(local_path): raise FileNotFoundError(local_path) @@ -127,6 +245,7 @@ def _docker_configuration(docker_configuration): "environment": {}, "command": "sleep infinity", "init": True, + "volumes": {}, } merged_configuration = {**configuration, **docker_configuration} @@ -151,6 +270,7 @@ def target_init(request, _docker_configuration): auto_remove=False, init=_docker_configuration["init"], environment=_docker_configuration["environment"], + volumes=_docker_configuration["volumes"], ) try: yield DockerTarget(container) diff --git a/score/itf/plugins/qemu/qemu_target.py b/score/itf/plugins/qemu/qemu_target.py index 6728536..e075151 100644 --- a/score/itf/plugins/qemu/qemu_target.py +++ b/score/itf/plugins/qemu/qemu_target.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -10,8 +10,14 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import logging +import os +import shlex +import threading +import time from contextlib import contextmanager, nullcontext +from score.itf.core.process.async_process import AsyncProcess from score.itf.plugins.core import Target from score.itf.plugins.qemu.qemu_process import QemuProcess @@ -20,9 +26,93 @@ from score.itf.core.com.ping import ping, ping_lost +logger = logging.getLogger(__name__) + QEMU_CAPABILITIES = ["ssh", "sftp"] +class QemuAsyncProcess(AsyncProcess): + """Handle for a non-blocking command execution on a QEMU target via SSH.""" + + def __init__(self, target, ssh_ctx, channel, pid, output_thread, output_lines): + self._target = target + self._ssh_ctx = ssh_ctx + self._channel = channel + self._pid = pid + self._output_thread = output_thread + self._output_lines = output_lines + self._logger = logging.getLogger(f"async_exec.{pid}") + self._closed = False + + def pid(self) -> int: + """Return the PID of the running command.""" + return self._pid + + def is_running(self) -> bool: + """Return *True* if the command is still executing.""" + return not self._channel.exit_status_ready() + + def get_exit_code(self) -> int: + """Return the exit code of the finished command.""" + return self._channel.recv_exit_status() + + def wait(self, timeout_s: float = 15) -> int: + """Block until the command finishes or *timeout_s* elapses. + + :param timeout_s: maximum seconds to wait. + :return: exit code of the command. + :raises RuntimeError: on timeout. + """ + start_time = time.time() + while self.is_running(): + if time.time() - start_time > timeout_s: + raise RuntimeError( + f"Waiting for process with PID [{self._pid}] to terminate timed out after {timeout_s} seconds" + ) + time.sleep(0.1) + self._output_thread.join() + exit_code = self.get_exit_code() + self._close_ssh() + return exit_code + + def stop(self) -> int: + """Terminate the running command, escalating to SIGKILL if needed. + + :return: exit code of the stopped command. + """ + self._terminate() + for _ in range(5): + time.sleep(1) + if not self.is_running(): + break + if self.is_running(): + self._logger.error(f"Process with PID [{self._pid}] did not terminate properly, sending SIGKILL.") + self._kill() + self.wait() + self._output_thread.join() + exit_code = self.get_exit_code() + self._close_ssh() + return exit_code + + def _close_ssh(self): + if not self._closed: + self._closed = True + try: + self._ssh_ctx.__exit__(None, None, None) + except Exception: + pass + + def _terminate(self): + self._target.execute(f"kill {self._pid}") + + def _kill(self): + self._target.execute(f"kill -9 {self._pid}") + + def get_output(self) -> str: + """Return the captured stdout of the command.""" + return "\n".join(self._output_lines) + ("\n" if self._output_lines else "") + + class QemuTarget(Target): def __init__(self, process, config): super().__init__(capabilities=QEMU_CAPABILITIES) @@ -61,6 +151,76 @@ def download(self, remote_path: str, local_path: str) -> None: with self.sftp() as sftp: sftp.download(remote_path, local_path) + def execute_async(self, binary_path, args=None, cwd="/", **kwargs) -> QemuAsyncProcess: + """Start a binary without blocking and return a :class:`QemuAsyncProcess` handle. + + The command is executed over a dedicated SSH session. A shell wrapper + prints the shell PID first, then runs the command. The PID is used + for later signal delivery via ``kill``. + + :param binary_path: path to the binary to execute. + :param args: list of string arguments for the binary. + :param cwd: working directory inside the target. + :return: a :class:`QemuAsyncProcess` instance for lifecycle management. + """ + if args is None: + args = [] + command = f"{binary_path} {' '.join(shlex.quote(a) for a in args)}" + + ssh_ctx = self.ssh(timeout=30, n_retries=5) + ssh_ctx.__enter__() + try: + transport = ssh_ctx.get_paramiko_client().get_transport() + channel = transport.open_session() + inner = ( + f"[ -r /etc/profile ] && . /etc/profile >/dev/null 2>&1; echo $$; cd {shlex.quote(cwd)} && {command}" + ) + channel.exec_command(f"sh -lc {shlex.quote(inner)}") + + # Read the PID from the first line of output. + channel.settimeout(30) + pid_line = b"" + while True: + byte = channel.recv(1) + if not byte or byte == b"\n": + break + pid_line += byte + channel.settimeout(None) + pid = int(pid_line.decode().strip()) + + cmd_logger = logging.getLogger(os.path.basename(command.split()[0])) + output_lines = [] + + def _async_log(): + def _recv_and_process(): + data = channel.recv(4096) + if not data: + return False + for line in data.decode(errors="replace").strip().split("\n"): + cmd_logger.info(line) + output_lines.append(line) + return True + + while True: + if channel.recv_ready(): + if not _recv_and_process(): + break + elif channel.exit_status_ready(): + while channel.recv_ready(): + if not _recv_and_process(): + break + break + else: + time.sleep(0.1) + + output_thread = threading.Thread(target=_async_log, daemon=True) + output_thread.start() + + return QemuAsyncProcess(self, ssh_ctx, channel, pid, output_thread, output_lines) + except Exception: + ssh_ctx.__exit__(None, None, None) + raise + def ssh( self, timeout: int = 15, diff --git a/test/BUILD b/test/BUILD index ca61476..6654bb5 100644 --- a/test/BUILD +++ b/test/BUILD @@ -23,6 +23,7 @@ py_itf_test( py_itf_test( name = "test_docker", srcs = [ + "test_async_exec.py", "test_docker.py", ], args = [ @@ -102,6 +103,7 @@ py_itf_test( py_itf_test( name = "test_qemu_bridge_network", srcs = [ + "test_async_exec.py", "test_qemu.py", ], args = [ @@ -126,6 +128,7 @@ py_itf_test( py_itf_test( name = "test_qemu_bridge_network_no_dlt", srcs = [ + "test_async_exec.py", "test_qemu.py", ], args = [ @@ -147,6 +150,7 @@ py_itf_test( py_itf_test( name = "test_qemu_port_forwarding", srcs = [ + "test_async_exec.py", "test_qemu.py", ], args = [ diff --git a/test/test_async_exec.py b/test/test_async_exec.py new file mode 100644 index 0000000..fcbb9f9 --- /dev/null +++ b/test/test_async_exec.py @@ -0,0 +1,161 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import time + + +def test_async_exec(target): + ready_signal = "/tmp/p2_ready" + target.execute(f"rm -f {ready_signal}") + + recv_cmd = f"echo [P1] start; while [ ! -f {ready_signal} ]; do echo [P1] wait; sleep 0.1; done; echo [P1] done;" + send_cmd = f"echo [P2] start; sleep 0.5; touch {ready_signal}; echo [P2] done;" + + with target.wrap_exec(recv_cmd, wait_on_exit=True): + with target.wrap_exec(send_cmd, wait_on_exit=True): + pass + + +def test_execute_async_pid_and_is_running(target): + """Verify pid() returns a valid PID and is_running() tracks state.""" + proc = target.execute_async("sleep 10") + try: + pid = proc.pid() + assert isinstance(pid, int) + assert pid > 0 + assert proc.is_running() + finally: + proc.stop() + assert not proc.is_running() + + +def test_execute_async_wait(target): + """Verify wait() blocks until the process finishes and returns exit code 0.""" + proc = target.execute_async("sleep 1") + exit_code = proc.wait(timeout_s=30) + assert exit_code == 0 + assert not proc.is_running() + + +def test_execute_async_exit_code(target): + """Verify get_exit_code() reflects the real exit status.""" + proc = target.execute_async("exit 42") + proc.wait(timeout_s=30) + assert proc.get_exit_code() == 42 + + +def test_execute_async_stop(target): + """Verify stop() terminates a long-running process.""" + proc = target.execute_async("sleep 300") + assert proc.is_running() + exit_code = proc.stop() + assert not proc.is_running() + # SIGTERM (143) or SIGKILL (137) are expected + assert exit_code in (143, 137) + + +def test_execute_async_with_cwd(target): + """Verify the cwd parameter is honoured.""" + marker_file = "itf_cwd_marker" + target.execute(f"rm -f /tmp/{marker_file}") + proc = target.execute_async(f"touch {marker_file}", cwd="/tmp") + proc.wait(timeout_s=30) + assert proc.get_exit_code() == 0 + exit_code, _ = target.execute(f"ls /tmp/{marker_file}") + assert exit_code == 0 + target.execute(f"rm -f /tmp/{marker_file}") + + +def test_wrap_exec_stop_on_exit(target): + """wrap_exec without wait_on_exit should stop the process when the block exits.""" + with target.wrap_exec("sleep 300") as wp: + assert wp.is_running() + # After the block, the process should have been stopped. + assert not wp.is_running() + + +def test_wrap_exec_wait_on_exit(target): + """wrap_exec with wait_on_exit should wait for natural completion.""" + with target.wrap_exec("sleep 1", wait_on_exit=True) as wp: + assert wp.is_running() or True # may finish fast + assert wp.ret_code == 0 + + +def test_wrap_exec_expected_exit_code(target): + """wrap_exec should accept a non-zero expected exit code without raising.""" + with target.wrap_exec("exit 42", wait_on_exit=True, expected_exit_code=42) as wp: + pass + assert wp.ret_code == 42 + + +def test_execute_async_with_args(target): + """Verify args are passed correctly to the binary.""" + proc = target.execute_async("echo", args=["-n", "hello"]) + exit_code = proc.wait(timeout_s=30) + assert exit_code == 0 + + +def test_execute_async_args_with_spaces(target): + """Verify args containing spaces are preserved as single arguments. + + Without proper per-arg quoting, 'hello world' would be split into two + arguments and touch would create two files instead of one. + """ + target.execute("rm -f '/tmp/hello world' /tmp/hello /tmp/world") + proc = target.execute_async("touch", args=["hello world"], cwd="/tmp") + proc.wait(timeout_s=30) + assert proc.get_exit_code() == 0 + # The single file with a space in the name must exist. + exit_code, _ = target.execute("ls '/tmp/hello world'") + assert exit_code == 0, "Arg with space was split into separate arguments" + target.execute("rm -f '/tmp/hello world'") + + +def test_execute_async_absolute_binary_path(target): + """Verify absolute binary paths are not mangled (regression for lstrip bug).""" + # Dynamically find the absolute path to 'echo' — differs between Linux and QNX. + exit_code, output = target.execute("which echo") + assert exit_code == 0, "Cannot locate echo binary on target" + echo_path = output.decode().strip() + assert echo_path.startswith("/"), f"Expected absolute path, got: {echo_path}" + proc = target.execute_async(echo_path, args=["async_abs_path_ok"]) + exit_code = proc.wait(timeout_s=30) + assert exit_code == 0 + + +def test_wrap_exec_crashed_process_reports_real_exit_code(target): + """wrap_exec without wait_on_exit should report the real exit code of a crashed process, + not silently return 0.""" + with target.wrap_exec("exit 7", expected_exit_code=7) as wp: + # Give the process time to exit before the with block ends. + time.sleep(2) + assert wp.ret_code == 7 + + +def test_wrap_exec_get_output(target): + """Verify get_output() is accessible via WrappedProcess.""" + with target.wrap_exec("echo line1; echo line2; echo line3", wait_on_exit=True) as wp: + pass + output = wp.get_output() + assert "line1" in output + assert "line2" in output + assert "line3" in output + + +def test_execute_async_get_output(target): + """Verify get_output() captures multiple lines of output.""" + proc = target.execute_async("echo line1; echo line2; echo line3") + proc.wait(timeout_s=30) + output = proc.get_output() + assert "line1" in output + assert "line2" in output + assert "line3" in output diff --git a/test/test_docker.py b/test/test_docker.py index 3da95ef..ec82cb8 100644 --- a/test/test_docker.py +++ b/test/test_docker.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -10,6 +10,8 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +import os +import pytest import score.itf @@ -59,3 +61,23 @@ def test_restart(target, tmp_path): exit_code, output = target.execute("echo -n restarted") assert exit_code == 0 assert output == b"restarted" + + +CONTAINER_EXTRA_MNT_PATH = "/extra/mount/directory" + + +@pytest.fixture(scope="session") +def docker_configuration(): + return { + "volumes": { + os.path.dirname(os.path.abspath(__file__)): { + "bind": CONTAINER_EXTRA_MNT_PATH, + "mode": "rw", + } + } + } + + +def test_extra_mount(target): + exit_code, _ = target.execute(f"ls -al {CONTAINER_EXTRA_MNT_PATH}") + assert exit_code == 0, "Extra volume not mounted!"