From 8c5c0138d71023079e9f0ac45f2f01a7e96784bc Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Thu, 29 Jun 2023 22:35:29 +0200 Subject: test: shuffle files a bit This should hopefully reduce clutter in the test/ directory. Side note: if I leave the __init__.py file in the new py/ directory, pytest fails with import errors. To make it work, I need to either delete it or keep the __init__.py file in both test/ and py/. No idea why. --- .gitignore | 3 + test/.gitignore | 2 - test/CMakeLists.txt | 10 +-- test/__init__.py | 0 test/conftest.py | 170 --------------------------------------- test/lib/__init__.py | 0 test/lib/process.py | 191 -------------------------------------------- test/lib/test_repo.py | 48 ----------- test/lib/test_repo/ci.sh | 24 ------ test/py/conftest.py | 170 +++++++++++++++++++++++++++++++++++++++ test/py/lib/__init__.py | 0 test/py/lib/process.py | 191 ++++++++++++++++++++++++++++++++++++++++++++ test/py/lib/test_repo.py | 48 +++++++++++ test/py/lib/test_repo/ci.sh | 24 ++++++ test/py/test_basic.py | 50 ++++++++++++ test/test_basic.py | 50 ------------ 16 files changed, 491 insertions(+), 490 deletions(-) delete mode 100644 test/.gitignore delete mode 100644 test/__init__.py delete mode 100644 test/conftest.py delete mode 100644 test/lib/__init__.py delete mode 100644 test/lib/process.py delete mode 100644 test/lib/test_repo.py delete mode 100755 test/lib/test_repo/ci.sh create mode 100644 test/py/conftest.py create mode 100644 test/py/lib/__init__.py create mode 100644 test/py/lib/process.py create mode 100644 test/py/lib/test_repo.py create mode 100755 test/py/lib/test_repo/ci.sh create mode 100644 test/py/test_basic.py delete mode 100644 test/test_basic.py diff --git a/.gitignore b/.gitignore index 84c048a..0a8bc72 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /build/ + +.pytest_cache/ +__pycache__/ diff --git a/test/.gitignore b/test/.gitignore deleted file mode 100644 index e6af855..0000000 --- a/test/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.pytest_cache/ -__pycache__/ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 326a50b..18b49be 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -2,23 +2,23 @@ find_package(Python3 REQUIRED COMPONENTS Interpreter) set(python_test_args --no-header -v - "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/py" --server-binary "$" --worker-binary "$" --client-binary "$" --project-version "${PROJECT_VERSION}") -function(add_my_test name) +function(add_python_tests name) list(POP_FRONT ARGV) add_test(NAME "${name}" - WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/py" COMMAND ${ARGV}) set_tests_properties("${name}" PROPERTIES TIMEOUT 60) endfunction() -add_my_test(python_tests +add_python_tests(python_tests Python3::Interpreter -m pytest ${python_test_args}) -add_my_test(python_tests_valgrind +add_python_tests(python_tests_valgrind Python3::Interpreter -m pytest ${python_test_args} --valgrind-binary "${CMAKE_CURRENT_SOURCE_DIR}/../src/valgrind.sh") diff --git a/test/__init__.py b/test/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/conftest.py b/test/conftest.py deleted file mode 100644 index 9e81afd..0000000 --- a/test/conftest.py +++ /dev/null @@ -1,170 +0,0 @@ -# Copyright (c) 2023 Egor Tensin -# This file is part of the "cimple" project. -# For details, see https://github.com/egor-tensin/cimple. -# Distributed under the MIT License. - -import logging -import os -import random - -from pytest import fixture - -from .lib.process import CmdLine -from .lib.test_repo import TestRepo - - -class Param: - def __init__(self, codename, help_string, required=True): - self.codename = codename - self.help_string = help_string - self.required = required - - @property - def cmd_line(self): - return f"--{self.codename.replace('_', '-')}" - - def add_to_parser(self, parser): - parser.addoption(self.cmd_line, required=self.required, help=self.help_string) - - -class ParamBinary(Param): - def __init__(self, name, **kwargs): - self.name = name - super().__init__(self.get_code_name(), self.get_help_string(), **kwargs) - - def get_code_name(self): - return f'{self.name}_binary' - - @property - def basename(self): - return f'cimple-{self.name}' - - def get_help_string(self): - return f'{self.basename} binary path' - - -BINARY_PARAMS = [ - ParamBinary(name) for name in ('server', 'worker', 'client') -] - -PARAM_VALGRIND = ParamBinary('valgrind', required=False) - - -class ParamVersion(Param): - def __init__(self): - super().__init__('project_version', 'project version') - - -PARAM_VERSION = ParamVersion() - -PARAMS = list(BINARY_PARAMS) -PARAMS += [ - PARAM_VALGRIND, - PARAM_VERSION, -] - - -def pytest_addoption(parser): - for opt in PARAMS: - opt.add_to_parser(parser) - - -def pytest_generate_tests(metafunc): - for opt in PARAMS: - if opt.codename in metafunc.fixturenames: - metafunc.parametrize(opt.codename, metafunc.config.getoption(opt.codename)) - - -@fixture(scope='session') -def rng(): - random.seed() - - -class Paths: - def __init__(self, pytestconfig): - for opt in BINARY_PARAMS: - setattr(self, opt.codename, None) - for opt in BINARY_PARAMS: - path = pytestconfig.getoption(opt.codename) - if path is None: - continue - logging.info('%s path: %s', opt.basename, path) - setattr(self, opt.codename, path) - - -@fixture(scope='session') -def paths(pytestconfig): - return Paths(pytestconfig) - - -@fixture(scope='session') -def base_cmd_line(pytestconfig): - cmd_line = CmdLine.unbuffered() - valgrind = pytestconfig.getoption(PARAM_VALGRIND.codename) - if valgrind is not None: - # Signal to Valgrind that ci.sh should obviously be exempt from memory - # leak checking: - cmd_line = CmdLine.wrap(CmdLine(valgrind, '--trace-children-skip=*/ci.sh', '--'), cmd_line) - return cmd_line - - -@fixture(scope='session') -def version(pytestconfig): - return pytestconfig.getoption(PARAM_VERSION.codename) - - -@fixture -def server_port(rng): - return str(random.randint(2000, 50000)) - - -@fixture -def sqlite_path(tmp_path): - return os.path.join(tmp_path, 'cimple.sqlite') - - -class CmdLineServer(CmdLine): - def log_line_means_process_ready(self, line): - return line.endswith('Waiting for new connections') - - -class CmdLineWorker(CmdLine): - def log_line_means_process_ready(self, line): - return line.endswith('Waiting for a new command') - - -@fixture -def server(base_cmd_line, paths, server_port, sqlite_path): - args = ['--port', server_port, '--sqlite', sqlite_path] - cmd_line = CmdLineServer.wrap(base_cmd_line, CmdLine(paths.server_binary, *args)) - with cmd_line.run_async() as server: - yield server - assert server.returncode == 0 - - -@fixture -def workers(base_cmd_line, paths, server_port): - args = ['--host', '127.0.0.1', '--port', server_port] - cmd_line = CmdLineWorker.wrap(base_cmd_line, CmdLine(paths.worker_binary, *args)) - with cmd_line.run_async() as worker1, \ - cmd_line.run_async() as worker2: - yield [worker1, worker2] - assert worker1.returncode == 0 - assert worker2.returncode == 0 - - -@fixture -def server_and_workers(server, workers): - yield server, workers - - -@fixture -def client(base_cmd_line, paths, server_port): - args = ['--host', '127.0.0.1', '--port', server_port] - cmd_line = CmdLine.wrap(base_cmd_line, CmdLine(paths.client_binary, *args)) - return cmd_line - - -@fixture -def test_repo(tmp_path): - return TestRepo(tmp_path) diff --git a/test/lib/__init__.py b/test/lib/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/lib/process.py b/test/lib/process.py deleted file mode 100644 index 3436280..0000000 --- a/test/lib/process.py +++ /dev/null @@ -1,191 +0,0 @@ -# Copyright (c) 2023 Egor Tensin -# This file is part of the "cimple" project. -# For details, see https://github.com/egor-tensin/cimple. -# Distributed under the MIT License. - -from contextlib import contextmanager -import logging -import os -import shutil -import subprocess -from threading import Event, Lock, Thread - - -class LoggingEvent(Event): - def __init__(self, timeout=10): - self.timeout = timeout - super().__init__() - - def log_line_matches(self, line): - return False - - def wait(self): - if not super().wait(self.timeout): - raise RuntimeError('timed out while waiting for an event') - - -class LoggingThread(Thread): - def __init__(self, process, events=None): - self.process = process - self.events_lock = Lock() - if events is None: - events = [] - self.events = events - - super().__init__(target=lambda: self.process_output_lines()) - self.start() - - def add_event(self, event): - with self.events_lock: - self.events.append(event) - - def process_output_lines(self): - for line in self.process.stdout: - line = line.removesuffix('\n') - logging.info('%s: %s', self.process.log_id, line) - with self.events_lock: - for event in self.events: - if event.is_set(): - continue - if not event.log_line_matches(line): - continue - event.set() - self.events = [event for event in self.events if not event.is_set()] - - -class CmdLine: - @staticmethod - def which(binary): - if os.path.split(binary)[0]: - # shutil.which('bin/bash') doesn't work. - return os.path.abspath(binary) - path = shutil.which(binary) - if path is None: - raise RuntimeError("couldn't find a binary: " + binary) - return path - - @staticmethod - def unbuffered(): - return CmdLine('stdbuf', '-o0') - - def __init__(self, binary, *args, name=None): - binary = self.which(binary) - argv = [binary] + list(args) - - self.binary = binary - self.argv = argv - - if name is None: - name = os.path.basename(binary) - self.process_name = name - - def log_line_means_process_ready(self, line): - return True - - @classmethod - def wrap(cls, outer, inner): - return cls(outer.argv[0], *outer.argv[1:], *inner.argv, name=inner.process_name) - - def run(self, *argv): - return Process.run(*self.argv, *argv) - - @contextmanager - def run_async(self, *argv): - with Process(self, *argv) as process: - yield process - - -class LoggingEventProcessReady(LoggingEvent): - def __init__(self, process): - self.process = process - super().__init__() - - def set(self): - logging.info('Process %s is ready', self.process.log_id) - super().set() - - def log_line_matches(self, line): - return self.process.cmd_line.log_line_means_process_ready(line) - - -class Process(subprocess.Popen): - _COMMON_ARGS = { - 'text': True, - 'stdin': subprocess.DEVNULL, - 'stdout': subprocess.PIPE, - 'stderr': subprocess.STDOUT, - } - - @staticmethod - def _log_process_start(argv): - logging.info('Executing command: %s', argv) - - @staticmethod - def _log_process_end(argv, ec, output): - log = logging.info - if ec: - log = logging.error - if ec: - log('Command %s exited with code %s', argv, ec) - else: - log('Command %s completed successfully', argv) - if output: - log('Output:\n%s', output) - - @staticmethod - def run(*args, **kwargs): - argv = list(args) - Process._log_process_start(argv) - try: - result = subprocess.run(argv, check=True, **Process._COMMON_ARGS, **kwargs) - ec, output = result.returncode, result.stdout - Process._log_process_end(argv, ec, output) - return output - except subprocess.CalledProcessError as e: - ec, output = e.returncode, e.stdout - Process._log_process_end(argv, ec, output) - raise - - def __init__(self, cmd_line, *args): - self.cmd_line = cmd_line - - argv = cmd_line.argv + list(args) - self._log_process_start(argv) - - super().__init__(argv, **Process._COMMON_ARGS) - logging.info('Process %s has started', self.log_id) - - ready_event = LoggingEventProcessReady(self) - self.logger = LoggingThread(self, [ready_event]) - ready_event.wait() - - @property - def log_id(self): - return f'{self.pid}/{self.cmd_line.process_name}' - - def __exit__(self, *args): - try: - self.shut_down() - self.logger.join() - except Exception as e: - logging.exception(e) - # Postpone closing the pipes until after the logging thread is finished - # so that it doesn't attempt to read from closed descriptors. - super().__exit__(*args) - - SHUT_DOWN_TIMEOUT_SEC = 3 - - def shut_down(self): - ec = self.poll() - if ec is not None: - return - logging.info('Terminating process %s', self.log_id) - self.terminate() - try: - self.wait(timeout=Process.SHUT_DOWN_TIMEOUT_SEC) - return - except subprocess.TimeoutExpired: - pass - logging.info('Process %s failed to terminate in time, killing it', self.log_id) - self.kill() - self.wait(timeout=Process.SHUT_DOWN_TIMEOUT_SEC) diff --git a/test/lib/test_repo.py b/test/lib/test_repo.py deleted file mode 100644 index 1922245..0000000 --- a/test/lib/test_repo.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (c) 2023 Egor Tensin -# This file is part of the "cimple" project. -# For details, see https://github.com/egor-tensin/cimple. -# Distributed under the MIT License. - -import logging -import os -import shutil - -from .process import Process - - -class Repo: - BRANCH = 'main' - - def __init__(self, path): - self.path = os.path.abspath(path) - os.makedirs(path, exist_ok=True) - self.run('git', 'init', '-q', f'--initial-branch={Repo.BRANCH}') - self.run('git', 'config', 'user.name', 'Test User') - self.run('git', 'config', 'user.email', 'test@example.com') - - def run(self, *args, **kwargs): - Process.run(*args, cwd=self.path, **kwargs) - - -class TestRepo(Repo): - # Prevent Pytest from discovering test cases in this class: - __test__ = False - - DATA_DIR = 'test_repo' - CI_SCRIPT = 'ci.sh' - OUTPUT_DIR = 'output' - - @staticmethod - def get_ci_script(): - return os.path.join(os.path.dirname(__file__), TestRepo.DATA_DIR, TestRepo.CI_SCRIPT) - - def __init__(self, path): - super().__init__(path) - shutil.copy(self.get_ci_script(), self.path) - self.run('git', 'add', '.') - self.run('git', 'commit', '-q', '-m', 'add CI script') - self.output_dir = os.path.join(self.path, TestRepo.OUTPUT_DIR) - os.makedirs(self.output_dir, exist_ok=True) - - def count_ci_output_files(self): - return len([name for name in os.listdir(self.output_dir) if os.path.isfile(os.path.join(self.output_dir, name))]) diff --git a/test/lib/test_repo/ci.sh b/test/lib/test_repo/ci.sh deleted file mode 100755 index 804cdb3..0000000 --- a/test/lib/test_repo/ci.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env bash - -set -o errexit -o nounset -o pipefail -shopt -s inherit_errexit lastpipe - -script_dir="$( dirname -- "${BASH_SOURCE[0]}" )" -script_dir="$( cd -- "$script_dir" && pwd )" -readonly script_dir - -ci_output_dir="$( git remote get-url origin )" -ci_output_dir="$ci_output_dir/output" -readonly ci_output_dir - -mkdir -p -- "$ci_output_dir" - -readonly ci_output_template=ci_XXXXXX - -ci_output_path="$( mktemp --tmpdir="$ci_output_dir" "$ci_output_template" )" -readonly ci_output_path - -timestamp="$( date --iso-8601=ns )" -readonly timestamp - -echo "A CI run happened at $timestamp" | tee -- "$ci_output_path" diff --git a/test/py/conftest.py b/test/py/conftest.py new file mode 100644 index 0000000..355bd83 --- /dev/null +++ b/test/py/conftest.py @@ -0,0 +1,170 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +import logging +import os +import random + +from pytest import fixture + +from lib.process import CmdLine +from lib.test_repo import TestRepo + + +class Param: + def __init__(self, codename, help_string, required=True): + self.codename = codename + self.help_string = help_string + self.required = required + + @property + def cmd_line(self): + return f"--{self.codename.replace('_', '-')}" + + def add_to_parser(self, parser): + parser.addoption(self.cmd_line, required=self.required, help=self.help_string) + + +class ParamBinary(Param): + def __init__(self, name, **kwargs): + self.name = name + super().__init__(self.get_code_name(), self.get_help_string(), **kwargs) + + def get_code_name(self): + return f'{self.name}_binary' + + @property + def basename(self): + return f'cimple-{self.name}' + + def get_help_string(self): + return f'{self.basename} binary path' + + +BINARY_PARAMS = [ + ParamBinary(name) for name in ('server', 'worker', 'client') +] + +PARAM_VALGRIND = ParamBinary('valgrind', required=False) + + +class ParamVersion(Param): + def __init__(self): + super().__init__('project_version', 'project version') + + +PARAM_VERSION = ParamVersion() + +PARAMS = list(BINARY_PARAMS) +PARAMS += [ + PARAM_VALGRIND, + PARAM_VERSION, +] + + +def pytest_addoption(parser): + for opt in PARAMS: + opt.add_to_parser(parser) + + +def pytest_generate_tests(metafunc): + for opt in PARAMS: + if opt.codename in metafunc.fixturenames: + metafunc.parametrize(opt.codename, metafunc.config.getoption(opt.codename)) + + +@fixture(scope='session') +def rng(): + random.seed() + + +class Paths: + def __init__(self, pytestconfig): + for opt in BINARY_PARAMS: + setattr(self, opt.codename, None) + for opt in BINARY_PARAMS: + path = pytestconfig.getoption(opt.codename) + if path is None: + continue + logging.info('%s path: %s', opt.basename, path) + setattr(self, opt.codename, path) + + +@fixture(scope='session') +def paths(pytestconfig): + return Paths(pytestconfig) + + +@fixture(scope='session') +def base_cmd_line(pytestconfig): + cmd_line = CmdLine.unbuffered() + valgrind = pytestconfig.getoption(PARAM_VALGRIND.codename) + if valgrind is not None: + # Signal to Valgrind that ci.sh should obviously be exempt from memory + # leak checking: + cmd_line = CmdLine.wrap(CmdLine(valgrind, '--trace-children-skip=*/ci.sh', '--'), cmd_line) + return cmd_line + + +@fixture(scope='session') +def version(pytestconfig): + return pytestconfig.getoption(PARAM_VERSION.codename) + + +@fixture +def server_port(rng): + return str(random.randint(2000, 50000)) + + +@fixture +def sqlite_path(tmp_path): + return os.path.join(tmp_path, 'cimple.sqlite') + + +class CmdLineServer(CmdLine): + def log_line_means_process_ready(self, line): + return line.endswith('Waiting for new connections') + + +class CmdLineWorker(CmdLine): + def log_line_means_process_ready(self, line): + return line.endswith('Waiting for a new command') + + +@fixture +def server(base_cmd_line, paths, server_port, sqlite_path): + args = ['--port', server_port, '--sqlite', sqlite_path] + cmd_line = CmdLineServer.wrap(base_cmd_line, CmdLine(paths.server_binary, *args)) + with cmd_line.run_async() as server: + yield server + assert server.returncode == 0 + + +@fixture +def workers(base_cmd_line, paths, server_port): + args = ['--host', '127.0.0.1', '--port', server_port] + cmd_line = CmdLineWorker.wrap(base_cmd_line, CmdLine(paths.worker_binary, *args)) + with cmd_line.run_async() as worker1, \ + cmd_line.run_async() as worker2: + yield [worker1, worker2] + assert worker1.returncode == 0 + assert worker2.returncode == 0 + + +@fixture +def server_and_workers(server, workers): + yield server, workers + + +@fixture +def client(base_cmd_line, paths, server_port): + args = ['--host', '127.0.0.1', '--port', server_port] + cmd_line = CmdLine.wrap(base_cmd_line, CmdLine(paths.client_binary, *args)) + return cmd_line + + +@fixture +def test_repo(tmp_path): + return TestRepo(tmp_path) diff --git a/test/py/lib/__init__.py b/test/py/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/py/lib/process.py b/test/py/lib/process.py new file mode 100644 index 0000000..3436280 --- /dev/null +++ b/test/py/lib/process.py @@ -0,0 +1,191 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +from contextlib import contextmanager +import logging +import os +import shutil +import subprocess +from threading import Event, Lock, Thread + + +class LoggingEvent(Event): + def __init__(self, timeout=10): + self.timeout = timeout + super().__init__() + + def log_line_matches(self, line): + return False + + def wait(self): + if not super().wait(self.timeout): + raise RuntimeError('timed out while waiting for an event') + + +class LoggingThread(Thread): + def __init__(self, process, events=None): + self.process = process + self.events_lock = Lock() + if events is None: + events = [] + self.events = events + + super().__init__(target=lambda: self.process_output_lines()) + self.start() + + def add_event(self, event): + with self.events_lock: + self.events.append(event) + + def process_output_lines(self): + for line in self.process.stdout: + line = line.removesuffix('\n') + logging.info('%s: %s', self.process.log_id, line) + with self.events_lock: + for event in self.events: + if event.is_set(): + continue + if not event.log_line_matches(line): + continue + event.set() + self.events = [event for event in self.events if not event.is_set()] + + +class CmdLine: + @staticmethod + def which(binary): + if os.path.split(binary)[0]: + # shutil.which('bin/bash') doesn't work. + return os.path.abspath(binary) + path = shutil.which(binary) + if path is None: + raise RuntimeError("couldn't find a binary: " + binary) + return path + + @staticmethod + def unbuffered(): + return CmdLine('stdbuf', '-o0') + + def __init__(self, binary, *args, name=None): + binary = self.which(binary) + argv = [binary] + list(args) + + self.binary = binary + self.argv = argv + + if name is None: + name = os.path.basename(binary) + self.process_name = name + + def log_line_means_process_ready(self, line): + return True + + @classmethod + def wrap(cls, outer, inner): + return cls(outer.argv[0], *outer.argv[1:], *inner.argv, name=inner.process_name) + + def run(self, *argv): + return Process.run(*self.argv, *argv) + + @contextmanager + def run_async(self, *argv): + with Process(self, *argv) as process: + yield process + + +class LoggingEventProcessReady(LoggingEvent): + def __init__(self, process): + self.process = process + super().__init__() + + def set(self): + logging.info('Process %s is ready', self.process.log_id) + super().set() + + def log_line_matches(self, line): + return self.process.cmd_line.log_line_means_process_ready(line) + + +class Process(subprocess.Popen): + _COMMON_ARGS = { + 'text': True, + 'stdin': subprocess.DEVNULL, + 'stdout': subprocess.PIPE, + 'stderr': subprocess.STDOUT, + } + + @staticmethod + def _log_process_start(argv): + logging.info('Executing command: %s', argv) + + @staticmethod + def _log_process_end(argv, ec, output): + log = logging.info + if ec: + log = logging.error + if ec: + log('Command %s exited with code %s', argv, ec) + else: + log('Command %s completed successfully', argv) + if output: + log('Output:\n%s', output) + + @staticmethod + def run(*args, **kwargs): + argv = list(args) + Process._log_process_start(argv) + try: + result = subprocess.run(argv, check=True, **Process._COMMON_ARGS, **kwargs) + ec, output = result.returncode, result.stdout + Process._log_process_end(argv, ec, output) + return output + except subprocess.CalledProcessError as e: + ec, output = e.returncode, e.stdout + Process._log_process_end(argv, ec, output) + raise + + def __init__(self, cmd_line, *args): + self.cmd_line = cmd_line + + argv = cmd_line.argv + list(args) + self._log_process_start(argv) + + super().__init__(argv, **Process._COMMON_ARGS) + logging.info('Process %s has started', self.log_id) + + ready_event = LoggingEventProcessReady(self) + self.logger = LoggingThread(self, [ready_event]) + ready_event.wait() + + @property + def log_id(self): + return f'{self.pid}/{self.cmd_line.process_name}' + + def __exit__(self, *args): + try: + self.shut_down() + self.logger.join() + except Exception as e: + logging.exception(e) + # Postpone closing the pipes until after the logging thread is finished + # so that it doesn't attempt to read from closed descriptors. + super().__exit__(*args) + + SHUT_DOWN_TIMEOUT_SEC = 3 + + def shut_down(self): + ec = self.poll() + if ec is not None: + return + logging.info('Terminating process %s', self.log_id) + self.terminate() + try: + self.wait(timeout=Process.SHUT_DOWN_TIMEOUT_SEC) + return + except subprocess.TimeoutExpired: + pass + logging.info('Process %s failed to terminate in time, killing it', self.log_id) + self.kill() + self.wait(timeout=Process.SHUT_DOWN_TIMEOUT_SEC) diff --git a/test/py/lib/test_repo.py b/test/py/lib/test_repo.py new file mode 100644 index 0000000..1922245 --- /dev/null +++ b/test/py/lib/test_repo.py @@ -0,0 +1,48 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +import logging +import os +import shutil + +from .process import Process + + +class Repo: + BRANCH = 'main' + + def __init__(self, path): + self.path = os.path.abspath(path) + os.makedirs(path, exist_ok=True) + self.run('git', 'init', '-q', f'--initial-branch={Repo.BRANCH}') + self.run('git', 'config', 'user.name', 'Test User') + self.run('git', 'config', 'user.email', 'test@example.com') + + def run(self, *args, **kwargs): + Process.run(*args, cwd=self.path, **kwargs) + + +class TestRepo(Repo): + # Prevent Pytest from discovering test cases in this class: + __test__ = False + + DATA_DIR = 'test_repo' + CI_SCRIPT = 'ci.sh' + OUTPUT_DIR = 'output' + + @staticmethod + def get_ci_script(): + return os.path.join(os.path.dirname(__file__), TestRepo.DATA_DIR, TestRepo.CI_SCRIPT) + + def __init__(self, path): + super().__init__(path) + shutil.copy(self.get_ci_script(), self.path) + self.run('git', 'add', '.') + self.run('git', 'commit', '-q', '-m', 'add CI script') + self.output_dir = os.path.join(self.path, TestRepo.OUTPUT_DIR) + os.makedirs(self.output_dir, exist_ok=True) + + def count_ci_output_files(self): + return len([name for name in os.listdir(self.output_dir) if os.path.isfile(os.path.join(self.output_dir, name))]) diff --git a/test/py/lib/test_repo/ci.sh b/test/py/lib/test_repo/ci.sh new file mode 100755 index 0000000..804cdb3 --- /dev/null +++ b/test/py/lib/test_repo/ci.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -o errexit -o nounset -o pipefail +shopt -s inherit_errexit lastpipe + +script_dir="$( dirname -- "${BASH_SOURCE[0]}" )" +script_dir="$( cd -- "$script_dir" && pwd )" +readonly script_dir + +ci_output_dir="$( git remote get-url origin )" +ci_output_dir="$ci_output_dir/output" +readonly ci_output_dir + +mkdir -p -- "$ci_output_dir" + +readonly ci_output_template=ci_XXXXXX + +ci_output_path="$( mktemp --tmpdir="$ci_output_dir" "$ci_output_template" )" +readonly ci_output_path + +timestamp="$( date --iso-8601=ns )" +readonly timestamp + +echo "A CI run happened at $timestamp" | tee -- "$ci_output_path" diff --git a/test/py/test_basic.py b/test/py/test_basic.py new file mode 100644 index 0000000..e8a00e1 --- /dev/null +++ b/test/py/test_basic.py @@ -0,0 +1,50 @@ +# Copyright (c) 2023 Egor Tensin +# This file is part of the "cimple" project. +# For details, see https://github.com/egor-tensin/cimple. +# Distributed under the MIT License. + +import pytest + +from lib.process import LoggingEvent + + +def test_server_and_workers_run(server_and_workers): + pass + + +def test_client_version(client, version): + output = client.run('--version') + assert output.endswith(version + '\n') + + +class LoggingEventRunComplete(LoggingEvent): + def __init__(self, target): + self.counter = 0 + self.target = target + super().__init__(timeout=60) + + def log_line_matches(self, line): + return 'Received a "run complete" message from worker' in line + + def set(self): + self.counter += 1 + if self.counter == self.target: + super().set() + + +def _test_repo_internal(server_and_workers, test_repo, client, numof_runs): + server, workers = server_and_workers + event = LoggingEventRunComplete(numof_runs) + server.logger.add_event(event) + for i in range(numof_runs): + client.run('run', test_repo.path, 'HEAD') + event.wait() + assert numof_runs == test_repo.count_ci_output_files() + + +def test_repo(server_and_workers, test_repo, client): + _test_repo_internal(server_and_workers, test_repo, client, 1) + + +def test_repo_10(server_and_workers, test_repo, client): + _test_repo_internal(server_and_workers, test_repo, client, 10) diff --git a/test/test_basic.py b/test/test_basic.py deleted file mode 100644 index 3544a11..0000000 --- a/test/test_basic.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright (c) 2023 Egor Tensin -# This file is part of the "cimple" project. -# For details, see https://github.com/egor-tensin/cimple. -# Distributed under the MIT License. - -import pytest - -from .lib.process import LoggingEvent - - -def test_server_and_workers_run(server_and_workers): - pass - - -def test_client_version(client, version): - output = client.run('--version') - assert output.endswith(version + '\n') - - -class LoggingEventRunComplete(LoggingEvent): - def __init__(self, target): - self.counter = 0 - self.target = target - super().__init__(timeout=60) - - def log_line_matches(self, line): - return 'Received a "run complete" message from worker' in line - - def set(self): - self.counter += 1 - if self.counter == self.target: - super().set() - - -def _test_repo_internal(server_and_workers, test_repo, client, numof_runs): - server, workers = server_and_workers - event = LoggingEventRunComplete(numof_runs) - server.logger.add_event(event) - for i in range(numof_runs): - client.run('run', test_repo.path, 'HEAD') - event.wait() - assert numof_runs == test_repo.count_ci_output_files() - - -def test_repo(server_and_workers, test_repo, client): - _test_repo_internal(server_and_workers, test_repo, client, 1) - - -def test_repo_10(server_and_workers, test_repo, client): - _test_repo_internal(server_and_workers, test_repo, client, 10) -- cgit v1.2.3