From c52e9a6429e9293265ed7912565ee3d1c5fd3217 Mon Sep 17 00:00:00 2001 From: Egor Tensin Date: Wed, 28 Jun 2023 12:56:18 +0200 Subject: test: add test for actual CI runs --- Dockerfile | 2 +- test/conftest.py | 18 +++++++---- test/lib/process.py | 78 ++++++++++++++++++++++++++++++++++++++---------- test/lib/test_repo.py | 48 +++++++++++++++++++++++++++++ test/lib/test_repo/ci.sh | 24 +++++++++++++++ test/test_basic.py | 35 ++++++++++++++++++++++ 6 files changed, 183 insertions(+), 22 deletions(-) create mode 100644 test/lib/test_repo.py create mode 100755 test/lib/test_repo/ci.sh diff --git a/Dockerfile b/Dockerfile index 17d6e5d..e55faad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG install_dir="/app/install" FROM base AS builder -RUN build_deps='bash bsd-compat-headers build-base clang cmake coreutils libgit2-dev py3-pytest sqlite-dev valgrind' && \ +RUN build_deps='bash bsd-compat-headers build-base clang cmake coreutils git libgit2-dev py3-pytest sqlite-dev valgrind' && \ apk add -q --no-cache $build_deps ARG COMPILER=clang diff --git a/test/conftest.py b/test/conftest.py index 65d8a7e..a3c8e6a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -10,6 +10,7 @@ import random from pytest import fixture from .lib.process import CmdLine, CmdLineRunner, Runner +from .lib.test_repo import TestRepo class Param: @@ -121,12 +122,12 @@ def sqlite_path(tmp_path): class CmdLineServer(CmdLine): - def log_line_means_ready(self, line): + def log_line_means_process_ready(self, line): return line.endswith('Waiting for new connections') class CmdLineWorker(CmdLine): - def log_line_means_ready(self, line): + def log_line_means_process_ready(self, line): return line.endswith('Waiting for a new command') @@ -135,7 +136,7 @@ def server(process_runner, paths, server_port, sqlite_path): args = ['--port', server_port, '--sqlite', sqlite_path] cmd_line = CmdLineServer(paths.server_binary, *args) with process_runner.run_async(cmd_line) as server: - yield + yield server assert server.returncode == 0 @@ -145,18 +146,23 @@ def workers(process_runner, paths, server_port): cmd_line = CmdLineWorker(paths.worker_binary, *args) with process_runner.run_async(cmd_line) as worker1, \ process_runner.run_async(cmd_line) as worker2: - yield + yield [worker1, worker2] assert worker1.returncode == 0 assert worker2.returncode == 0 @fixture def server_and_workers(server, workers): - yield + yield server, workers @fixture def client(process_runner, paths, server_port): - args = ['--port', server_port] + args = ['--host', '127.0.0.1', '--port', server_port] cmd_line = CmdLineRunner(process_runner, paths.client_binary, *args) return cmd_line + + +@fixture +def test_repo(tmp_path): + return TestRepo(tmp_path) diff --git a/test/lib/process.py b/test/lib/process.py index bdfe3c3..be2dc92 100644 --- a/test/lib/process.py +++ b/test/lib/process.py @@ -8,7 +8,7 @@ import logging import os import shutil import subprocess -from threading import Event, Thread +from threading import Event, Lock, Thread _COMMON_ARGS = { @@ -19,24 +19,55 @@ _COMMON_ARGS = { } +def run(*args, **kwargs): + try: + return subprocess.run(list(args), check=True, **_COMMON_ARGS, **kwargs) + except subprocess.CalledProcessError as e: + logging.error('Command %s exited with code %s', e.cmd, e.returncode) + logging.error('Output:\n%s', e.output) + raise + + +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): + def __init__(self, process, events=None): self.process = process - self.ready_event = Event() - - target = lambda pipe: self.consume(pipe) - super().__init__(target=target, args=[process.stdout]) + self.events_lock = Lock() + if events is None: + events = [] + self.events = events + super().__init__(target=lambda: self.process_output_lines()) self.start() - self.ready_event.wait() - def consume(self, pipe): - for line in pipe: + 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) - if not self.ready_event.is_set() and self.process.cmd_line.log_line_means_ready(line): - logging.info('Process %s is ready', self.process.log_id) - self.ready_event.set() + 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: @@ -61,7 +92,7 @@ class CmdLine: name = os.path.basename(binary) self.process_name = name - def log_line_means_ready(self, line): + def log_line_means_process_ready(self, line): return True @classmethod @@ -75,12 +106,29 @@ class CmdLine: logging.info('Executing binary %s', self.binary) +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): def __init__(self, cmd_line): self.cmd_line = cmd_line + super().__init__(cmd_line.argv, **_COMMON_ARGS) logging.info('Process %s has started', self.log_id) - self.logger = LoggingThread(self) + + ready_event = LoggingEventProcessReady(self) + self.logger = LoggingThread(self, [ready_event]) + ready_event.wait() @property def log_id(self): @@ -135,7 +183,7 @@ class Runner: cmd_line = self._wrap(cmd_line) cmd_line.log_process_start() - result = subprocess.run(cmd_line.argv, **_COMMON_ARGS) + result = run(*cmd_line.argv) return result.returncode, result.stdout @contextmanager diff --git a/test/lib/test_repo.py b/test/lib/test_repo.py new file mode 100644 index 0000000..f12b374 --- /dev/null +++ b/test/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 run + + +class Repo: + BRANCH = 'main' + + def __init__(self, path): + self.path = os.path.abspath(path) + os.makedirs(path, exist_ok=True) + self.run('git', 'init', 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): + 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', '-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 new file mode 100755 index 0000000..804cdb3 --- /dev/null +++ b/test/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/test_basic.py b/test/test_basic.py index b072384..7deca0f 100644 --- a/test/test_basic.py +++ b/test/test_basic.py @@ -5,6 +5,8 @@ import pytest +from .lib.process import LoggingEvent + def test_server_and_workers_run(server_and_workers): pass @@ -14,3 +16,36 @@ def test_client_version(client, version): ec, output = client.run('--version') assert ec == 0 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