aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/test/py
diff options
context:
space:
mode:
authorEgor Tensin <Egor.Tensin@gmail.com>2023-06-29 22:35:29 +0200
committerEgor Tensin <Egor.Tensin@gmail.com>2023-06-29 22:35:29 +0200
commit8c5c0138d71023079e9f0ac45f2f01a7e96784bc (patch)
treed15c4579c00fcb7d1f73a0d8f627b5ab398efbd1 /test/py
parentlog: minor refactoring (diff)
downloadcimple-8c5c0138d71023079e9f0ac45f2f01a7e96784bc.tar.gz
cimple-8c5c0138d71023079e9f0ac45f2f01a7e96784bc.zip
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.
Diffstat (limited to 'test/py')
-rw-r--r--test/py/conftest.py170
-rw-r--r--test/py/lib/__init__.py0
-rw-r--r--test/py/lib/process.py191
-rw-r--r--test/py/lib/test_repo.py48
-rwxr-xr-xtest/py/lib/test_repo/ci.sh24
-rw-r--r--test/py/test_basic.py50
6 files changed, 483 insertions, 0 deletions
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 <Egor.Tensin@gmail.com>
+# 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
--- /dev/null
+++ b/test/py/lib/__init__.py
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 <Egor.Tensin@gmail.com>
+# 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 <Egor.Tensin@gmail.com>
+# 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 <Egor.Tensin@gmail.com>
+# 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)