diff --git a/.bazelrc b/.bazelrc new file mode 100644 index 0000000..5f20d3e --- /dev/null +++ b/.bazelrc @@ -0,0 +1,10 @@ +build --java_language_version=17 +build --tool_java_language_version=17 +build --java_runtime_version=remotejdk_17 +build --tool_java_runtime_version=remotejdk_17 +build --@score-baselibs//score/json:base_library=nlohmann + +test --test_output=errors + +common --registry=https://raw.githubusercontent.com/eclipse-score/bazel_registry/main/ +common --registry=https://bcr.bazel.build diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 0000000..ba7f754 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +7.4.0 diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..dd4f592 --- /dev/null +++ b/.clang-format @@ -0,0 +1,4 @@ +BasedOnStyle: Google +DerivePointerAlignment: false +ColumnLimit: 100 +IndentWidth: 4 diff --git a/.gitignore b/.gitignore index 4be05d0..d952ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,10 @@ cython_debug/ # PyPI configuration file .pypirc + +.vscode/ +bazel-bin +bazel-out +bazel-testing_tools +bazel-testlogs +MODULE.bazel.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fa6f676 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,43 @@ +repos: + # Common language-agnostic checks. + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-merge-conflict + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + # Python checks. + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.7 + hooks: + - id: ruff-format + args: ["--diff"] + - id: ruff-check + + # Rust checks. + - repo: https://github.com/doublify/pre-commit-rust + rev: v1.0 + hooks: + - id: fmt + args: + ["--manifest-path", "test_scenarios_rust/Cargo.toml", "--", "--check"] + - id: cargo-check + args: ["--manifest-path", "test_scenarios_rust/Cargo.toml"] + - id: clippy + args: + [ + "--manifest-path", + "test_scenarios_rust/Cargo.toml", + "--all-targets", + "--all-features", + ] + + # C++ checks. + - repo: https://github.com/pocc/pre-commit-hooks + rev: v1.3.5 + hooks: + - id: clang-format diff --git a/.ruff.toml b/.ruff.toml index aa13f08..c87f215 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -28,11 +28,55 @@ line-length = 120 indent-width = 4 [lint] -# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. -# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or -# McCabe complexity (`C901`) by default. -select = ["E4", "E7", "E9", "F", "I"] -ignore = ["F401", "F811"] +select = [ + # flake8-boolean-trap + "FBT", + # flake8-bugbear + "B", + # flake8-builtins + "A", + # flake8-commas + "COM", + # flake8-comprehensions + "C4", + # flake8-fixme + "FIX", + # flake8-implicit-str-concat + "ISC", + # flake8-no-pep420 + "INP", + # flake8-pie + "PIE", + # flake8-print + "T20", + # flake8-pytest-style + "PT", + # flake8-raise + "RSE", + # flake8-return + "RET", + # flake8-self + "SLF", + # flake8-simplify + "SIM", + # flake8-type-checking + "TC", + # flake8-unused-arguments + "ARG", + # flake8-use-pathlib + "PTH", + + # isort + "I", + + # pycodestyle error + "E", + # Pyflakes + "F", + # pyupgrade + "UP", +] +ignore = ["F401", "PTH123", "ARG002"] # Allow fix for all enabled rules (when `--fix`) is provided. fixable = ["ALL"] diff --git a/BUILD b/BUILD new file mode 100644 index 0000000..e69de29 diff --git a/MODULE.bazel b/MODULE.bazel new file mode 100644 index 0000000..4b4549f --- /dev/null +++ b/MODULE.bazel @@ -0,0 +1,60 @@ +""" +'testing-utils' module. +""" + +module( + name = "testing-utils", + version = "0.2.0", +) + +# Python rules. +bazel_dep(name = "rules_python", version = "1.0.0") + +PYTHON_VERSION = "3.12" + +python = use_extension("@rules_python//python/extensions:python.bzl", "python") +python.toolchain( + is_default = True, + python_version = PYTHON_VERSION, +) +use_repo(python) + +# C++ GoogleTest dependencies. +bazel_dep(name = "googletest", version = "1.14.0") + +# Rust rules. +bazel_dep(name = "rules_rust", version = "0.56.0") + +# C/C++ rules. +bazel_dep(name = "rules_cc", version = "0.1.1") + +# Rust module dependencies. +rust = use_extension("@rules_rust//rust:extensions.bzl", "rust") +rust.toolchain( + edition = "2021", + versions = ["1.85.0"], +) + +crate = use_extension("@rules_rust//crate_universe:extensions.bzl", "crate") +crate.from_cargo( + name = "test_scenarios_rust_crates", + cargo_lockfile = "//test_scenarios_rust:Cargo.lock", + manifests = [ + "//test_scenarios_rust:Cargo.toml", + ], +) +use_repo(crate, "test_scenarios_rust_crates") + +# C++ base libs. +archive_override( + module_name = "rules_boost", + strip_prefix = "rules_boost-master", + urls = ["https://github.com/nelhage/rules_boost/archive/refs/heads/master.tar.gz"], +) + +bazel_dep(name = "score-baselibs", version = "0.0.0") +git_override( + module_name = "score-baselibs", + commit = "f0a394a602986ddf7abac6a238b9d44535a4b597", + remote = "https://github.com/eclipse-score/baselibs.git", +) diff --git a/README.md b/README.md index a7ad522..6091d5e 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ Test framework tools and helpers for performance stack project. ## Overview -This package provides utility classes and functions to assist with test automation, log handling, and result parsing. -It is designed to be used as a helper library for test frameworks or custom test runners. +This repository provided utilities to assist with test automation, log handling, and result parsing. +It is designed to be a set of helper libraries for test frameworks or custom test runners. ## Features -- **Cargo tools**: Utilities for interacting with Cargo. +- **Test scenarios libraries**: Rust and C++ libraries for implementing test scenarios. +- **Build tools**: Utilities for interacting with Bazel and Cargo. - **Log container**: A container for storing and querying logs. - **`ResultEntry`** and subclasses: Structured representation of test log entries. - **Scenario**: Utilities for defining and running test scenarios. @@ -40,7 +41,17 @@ pip install -e .[dev] --config-settings editable_mode=strict ## Usage -### Cargo tools +### Test scenarios utilities + +Libraries should be included as a dependency in `Cargo.toml` (Rust) or `BUILD` (C++). + +Main components: + +- `TestContext` - responsible for listing and running scenarios. +- `Scenario` and `ScenarioGroup` - base classes for defining scenarios and groups. +- `run_cli_app` - runs CLI application based on provided arguments and test context. + +### Build tools #### Get Cargo metadata @@ -54,17 +65,17 @@ from testing_utils import cargo_metadata metadata: dict[str, Any] = cargo_metadata() ``` -#### Find executable path +#### Find target path -Path is obtained from Cargo metadata. -CWD must be set to Cargo project. +Find path to executable based on provided target name. ```python from pathlib import Path -from testing_utils import find_bin_path +from testing_utils import BazelTools -bin_name = "executable_name" -bin_path: Path = find_bin_path(bin_name) +target_name = "target_name" +build_tools = BazelTools() +target_path: Path = build_tools.find_target_path(target_name) ... ``` @@ -73,6 +84,7 @@ bin_path: Path = find_bin_path(bin_name) This feature is to ensure flexible usage in pytest context. Additional configuration is required. +Expected flags depend on implementation, refer to `select_target_path` docs. Add options to `conftest.py`: ```python @@ -80,15 +92,15 @@ from pathlib import Path def pytest_addoption(parser): parser.addoption( - "--bin-path", + "--target-path", type=Path, help="Path to test scenarios executable. Search is performed by default.", ) parser.addoption( - "--bin-name", + "--target-name", type=str, default="rust_test_scenarios", - help='Test scenario executable name. Overwritten by "--bin-path".', + help='Test scenario executable name. Overwritten by "--target-path".', ) ``` @@ -96,24 +108,25 @@ Usage: ```python from pathlib import Path -from pytest import FeatureRequest -from testing_utils import select_bin_path +import pytest +from testing_utils import BazelTools -def test_example(request: FeatureRequest) -> None: - bin_path: Path = select_bin_path(request.config) +def test_example(request: pytest.FeatureRequest) -> None: + build_tools = BazelTools() + target_path: Path = build_tools.select_target_path(request.config) ... ``` -#### Run Cargo build +#### Run build -Run build based on manifest located in CWD. -CWD must be set to Cargo project. +Run build for selected target. ```python -from testing_utils import cargo_build +from testing_utils import BazelTools -bin_name = "rust_executable" -bin_path: Path = cargo_build(bin_name) +target_name = "target_name" +build_tools = BazelTools() +target_path: Path = build_tools.build(target_name) ... ``` @@ -169,15 +182,19 @@ Test execution results are provided using two fixtures: - `results` - executable run results - `logs` - logs from run -`scenario_name` and `test_config` are marked as abstract and must be implemented. +`build_tools`, `scenario_name` and `test_config` are marked as abstract and must be implemented. Example implementation: ```python import pytest -from testing_utils import Scenario, ScenarioResult, LogContainer +from testing_utils import Scenario, ScenarioResult, LogContainer, CargoTools, BuildTools class TestExample(Scenario): + @pytest.fixture(scope="class") + def build_tools(self) -> BuildTools: + return CargoTools() + @pytest.fixture(scope="class") def scenario_name(self) -> str: return "example_scenario_name" @@ -203,7 +220,7 @@ class TestExample(Scenario): ... ``` -Methods can be overrridden to utilize test-specific fixtures: +Methods can be overridden to utilize test-specific fixtures: ```python import pytest @@ -229,5 +246,20 @@ class TestExample(Scenario) To run the tests, use: ```bash -pytest -vs . +pytest -vs +``` + +### `pre-commit` + +Install `pre-commit` and set it up in this repository: + +```bash +pip install pre-commit +pre-commit install +``` + +Run `pre-commit`: + +```bash +pre-commit run -a ``` diff --git a/pyproject.toml b/pyproject.toml index e67183c..54135fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "testing-utils" -version = "0.1.1" -dependencies = ["pytest"] +version = "0.2.0" +dependencies = ["pytest==8.4.1", "pytest-html==4.1.1", "pytest-repeat==0.9.4"] requires-python = ">=3.12" authors = [ { name = "Igor Ostrowski", email = "igor.ostrowski.ext@qorix.ai" }, @@ -15,5 +15,12 @@ readme = "README.md" requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" +[tool.setuptools] +packages = ["testing_utils"] + +[tool.pytest.ini_options] +minversion = "6.0" +testpaths = ["tests"] + [project.optional-dependencies] dev = ["ruff"] diff --git a/test_scenarios_cpp/BUILD b/test_scenarios_cpp/BUILD new file mode 100644 index 0000000..131bf89 --- /dev/null +++ b/test_scenarios_cpp/BUILD @@ -0,0 +1,42 @@ +load("@rules_cc//cc:cc_library.bzl", "cc_library") +load("@rules_cc//cc:cc_test.bzl", "cc_test") + +cc_library( + name = "test_scenarios_cpp", + # 'glob' not used due to unhelpful nature of C++ errors. + srcs = [ + "src/cli.cpp", + "src/monotonic_clock.cpp", + "src/scenario.cpp", + "src/string_utils.cpp", + "src/string_utils.hpp", + "src/test_context.cpp", + "src/tracing.cpp", + ], + # Only public headers should be defined here. + hdrs = [ + "include/cli.hpp", + "include/monotonic_clock.hpp", + "include/scenario.hpp", + "include/test_context.hpp", + "include/tracing.hpp", + ], + includes = ["include/"], + visibility = ["//visibility:public"], + deps = ["@score-baselibs//score/json:json_parser"], +) + +cc_test( + name = "tests", + srcs = [ + "tests/common.hpp", + "tests/test_cli.cpp", + "tests/test_scenario.cpp", + "tests/test_test_context.cpp", + ], + visibility = ["//visibility:private"], + deps = [ + ":test_scenarios_cpp", + "@googletest//:gtest_main", + ], +) diff --git a/test_scenarios_cpp/include/cli.hpp b/test_scenarios_cpp/include/cli.hpp new file mode 100644 index 0000000..05351db --- /dev/null +++ b/test_scenarios_cpp/include/cli.hpp @@ -0,0 +1,34 @@ +#pragma once + +#include "test_context.hpp" + +/// @brief Test scenario arguments. +struct ScenarioArguments { + /// @brief Test scenario name. + std::optional name; + + /// @brief Test scenario input. + std::optional input; +}; + +/// @brief CLI arguments. +struct CliArguments { + /// @brief Test scenario arguments. + ScenarioArguments scenario_arguments; + + /// @brief List scenarios. + bool list_scenarios; + + /// @brief Show help. + bool help; +}; + +/// @brief Parse CLI arguments. +/// @param raw_arguments Collected arguments from `argv`. +/// @return Parsed CLI arguments. +CliArguments parse_cli_arguments(const std::vector& raw_arguments); + +/// @brief Runs CLI application based on provided arguments and test context. +/// @param raw_arguments Collected arguments from `argv`. +/// @param test_context Test context to use. +void run_cli_app(const std::vector& raw_arguments, const TestContext& test_context); diff --git a/test_scenarios_cpp/include/monotonic_clock.hpp b/test_scenarios_cpp/include/monotonic_clock.hpp new file mode 100644 index 0000000..61eb878 --- /dev/null +++ b/test_scenarios_cpp/include/monotonic_clock.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include +#include + +/// @brief Timestamp provider using monotonic clock. +class MonotonicClock { + public: + MonotonicClock(); + + /// @brief Measure and write out the current time. + /// @return Timestamp as string. + std::string format_time() const; + + private: + using ClockT = std::chrono::high_resolution_clock; + using TimePointT = std::chrono::time_point; + + TimePointT start_; +}; diff --git a/test_scenarios_cpp/include/scenario.hpp b/test_scenarios_cpp/include/scenario.hpp new file mode 100644 index 0000000..984045f --- /dev/null +++ b/test_scenarios_cpp/include/scenario.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include +#include + +/// @brief Scenario definition. +class Scenario { + public: + /// @brief `shared_ptr` alias. + using Ptr = std::shared_ptr; + + virtual ~Scenario() = 0; + + /// @brief Get scenario name. + /// @return Scenario name. + virtual std::string name() const = 0; + + /// @brief Runt test scenario. + /// @param input Optional test input. + virtual void run(const std::optional& input) const = 0; +}; + +/// @brief Scenario group definition. +class ScenarioGroup { + public: + /// @brief `shared_ptr` alias. + using Ptr = std::shared_ptr; + + virtual ~ScenarioGroup() = 0; + + /// @brief Get scenario group name. + /// @return Scenario group name. + virtual std::string name() const = 0; + + /// @brief List groups from this group. + /// @return Groups. + virtual const std::vector& groups() const = 0; + + /// @brief List scenarios from this group. + /// @return Scenarios. + virtual const std::vector& scenarios() const = 0; + + /// @brief Find scenario by name. + /// @param name Name of scenario to find. + /// @return Found scenario. Empty if not found. + virtual std::optional find_scenario(const std::string& name) const = 0; +}; + +/// @brief Common scenario group definition. +class ScenarioGroupImpl : public ScenarioGroup { + public: + ScenarioGroupImpl(const std::string& name, const std::vector& scenarios, + const std::vector& groups); + + ~ScenarioGroupImpl() override; + + virtual std::string name() const override; + + virtual const std::vector& groups() const override; + + virtual const std::vector& scenarios() const override; + + virtual std::optional find_scenario(const std::string& name) const override; + + protected: + std::string name_; + std::vector scenarios_; + std::vector groups_; +}; diff --git a/test_scenarios_cpp/include/test_context.hpp b/test_scenarios_cpp/include/test_context.hpp new file mode 100644 index 0000000..3f9fbf4 --- /dev/null +++ b/test_scenarios_cpp/include/test_context.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +#include "scenario.hpp" + +/// @brief Test context. Responsible for listing and running scenarios. +class TestContext { + public: + /// @brief Create test context. + /// @param root_group Root test scenario group. + explicit TestContext(ScenarioGroup::Ptr root_group); + + /// @brief Run test scenario. + /// @param name Test scenario name. + /// @param input Test scenario input. + void run(const std::string& name, const std::optional& input) const; + + /// @brief List available scenarios. + /// @return Names of available scenarios. + std::vector list_scenarios() const; + + private: + ScenarioGroup::Ptr root_group_; +}; diff --git a/test_scenarios_cpp/include/tracing.hpp b/test_scenarios_cpp/include/tracing.hpp new file mode 100644 index 0000000..02f6e8a --- /dev/null +++ b/test_scenarios_cpp/include/tracing.hpp @@ -0,0 +1,75 @@ +#pragma once + +/// Tracing. +/// This module is not a direct replacement to Rust `tracing` library. +/// It is able to display structured traces to stderr. + +#include +#include + +#include "monotonic_clock.hpp" +#include "score/json/json_writer.h" + +#define _TRACING(target, level, fields...) \ + do { \ + tracing::global_subscriber().event(target, level, fields); \ + } while (false) + +#define TRACING_TRACE(target, fields...) _TRACING({target}, tracing::Level::Trace, fields) +#define TRACING_DEBUG(target, fields...) _TRACING({target}, tracing::Level::Debug, fields) +#define TRACING_INFO(target, fields...) _TRACING({target}, tracing::Level::Info, fields) +#define TRACING_WARN(target, fields...) _TRACING({target}, tracing::Level::Warn, fields) +#define TRACING_ERROR(target, fields...) _TRACING({target}, tracing::Level::Error, fields) + +#define TRACING_TRACE_WO_TARGET(fields...) _TRACING({}, tracing::Level::Trace, fields) +#define TRACING_DEBUG_WO_TARGET(fields...) _TRACING({}, tracing::Level::Debug, fields) +#define TRACING_INFO_WO_TARGET(fields...) _TRACING({}, tracing::Level::Info, fields) +#define TRACING_WARN_WO_TARGET(fields...) _TRACING({}, tracing::Level::Warn, fields) +#define TRACING_ERROR_WO_TARGET(fields...) _TRACING({}, tracing::Level::Error, fields) + +namespace tracing { + +enum class Level { + Trace = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, +}; + +std::string level_to_string(const Level& level); + +class Subscriber { + public: + Subscriber(const Level& max_level, bool thread_ids); + + template + void event(const std::optional& target, const Level& level, + std::pair... fields) const { + using namespace score::json; + Object fields_object{object_create(fields...)}; + handle_event(target, level, std::move(fields_object)); + } + + private: + Level max_level_; + bool thread_ids_; + MonotonicClock timer_; + + void handle_event(const std::optional& target, const Level& level, + score::json::Object&& fields) const; + + score::json::Object object_create() const { return score::json::Object{}; } + + template + score::json::Object object_create(std::pair field, + std::pair... fields) const { + auto object{object_create(fields...)}; + object.insert(field); + return object; + } +}; + +const Subscriber& global_subscriber(); + +} // namespace tracing diff --git a/test_scenarios_cpp/src/cli.cpp b/test_scenarios_cpp/src/cli.cpp new file mode 100644 index 0000000..0c0cb32 --- /dev/null +++ b/test_scenarios_cpp/src/cli.cpp @@ -0,0 +1,71 @@ +#include "cli.hpp" + +#include +#include +#include +#include +#include + +CliArguments parse_cli_arguments(const std::vector& raw_arguments) { + CliArguments cli_arguments{}; + + // Process arguments. + // First argument (executable name) is skipped. + auto args_it = raw_arguments.begin(); + while (++args_it < raw_arguments.end()) { + auto arg{*args_it}; + if (arg == "-n" || arg == "--name") { + if (++args_it != raw_arguments.end()) { + cli_arguments.scenario_arguments.name = *args_it; + } else { + throw std::runtime_error{"Failed to read name parameter"}; + } + } else if (arg == "-i" || arg == "--input") { + if (++args_it != raw_arguments.end()) { + cli_arguments.scenario_arguments.input = *args_it; + } else { + throw std::runtime_error{"Failed to read input parameter"}; + } + } else if (arg == "-l" || arg == "--list-scenarios") { + cli_arguments.list_scenarios = true; + } else if (arg == "-h" || arg == "--help") { + cli_arguments.help = true; + } else { + throw std::runtime_error{"Unknown argument provided"}; + } + } + + return cli_arguments; +} + +void run_cli_app(const std::vector& raw_arguments, const TestContext& test_context) { + // Parse CLI arguments. + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + // Show help and return. + if (cli_arguments.help) { + std::cerr << "Test scenario runner" << std::endl; + std::cerr << "'-n', '--name' - test scenario name" << std::endl; + std::cerr << "'-i', '--input' - test scenario input" << std::endl; + std::cerr << "'-l', '--list-scenarios' - list available scenarios" << std::endl; + std::cerr << "'-h', '--help' - show help" << std::endl; + return; + } + + // List scenarios and return. + if (cli_arguments.list_scenarios) { + auto scenario_names{test_context.list_scenarios()}; + for (auto&& scenario_name : scenario_names) { + std::cout << scenario_name << std::endl; + } + return; + } + + // Find scenario. + auto scenario{cli_arguments.scenario_arguments}; + if (!scenario.name.has_value() || (scenario.name.has_value() && scenario.name->empty())) { + throw std::runtime_error{"Test scenario name must be provided"}; + } + + test_context.run(*scenario.name, scenario.input); +} diff --git a/test_scenarios_cpp/src/monotonic_clock.cpp b/test_scenarios_cpp/src/monotonic_clock.cpp new file mode 100644 index 0000000..ff0229d --- /dev/null +++ b/test_scenarios_cpp/src/monotonic_clock.cpp @@ -0,0 +1,11 @@ +#include "monotonic_clock.hpp" + +#include + +MonotonicClock::MonotonicClock() : start_{ClockT::now()} {} + +std::string MonotonicClock::format_time() const { + auto elapsed{ClockT::now() - start_}; + auto elapsed_us{std::chrono::duration_cast(elapsed)}; + return std::to_string(elapsed_us.count()); +} diff --git a/test_scenarios_cpp/src/scenario.cpp b/test_scenarios_cpp/src/scenario.cpp new file mode 100644 index 0000000..52f2c88 --- /dev/null +++ b/test_scenarios_cpp/src/scenario.cpp @@ -0,0 +1,40 @@ +#include "scenario.hpp" + +#include "string_utils.hpp" + +Scenario::~Scenario() {} + +ScenarioGroup::~ScenarioGroup() {} + +ScenarioGroupImpl::ScenarioGroupImpl(const std::string& name, + const std::vector& scenarios, + const std::vector& groups) + : name_{name}, scenarios_{scenarios}, groups_{groups} {} + +ScenarioGroupImpl::~ScenarioGroupImpl() {} + +std::string ScenarioGroupImpl::name() const { return name_; } + +const std::vector& ScenarioGroupImpl::groups() const { return groups_; } + +const std::vector& ScenarioGroupImpl::scenarios() const { return scenarios_; } + +std::optional ScenarioGroupImpl::find_scenario(const std::string& name) const { + auto split{string_utils::split(name, ".")}; + if (split.size() == 1) { + for (auto&& scenario : scenarios_) { + if (scenario->name() == name) { + return {scenario}; + } + } + } else { + for (auto&& group : groups_) { + if (group->name() == split[0]) { + std::vector parts{split.begin() + 1, split.end()}; + return group->find_scenario(string_utils::join(parts, ".")); + } + } + } + + return {}; +} diff --git a/test_scenarios_cpp/src/string_utils.cpp b/test_scenarios_cpp/src/string_utils.cpp new file mode 100644 index 0000000..e2261f2 --- /dev/null +++ b/test_scenarios_cpp/src/string_utils.cpp @@ -0,0 +1,49 @@ +#include "string_utils.hpp" + +#include +#include + +std::vector string_utils::split(const std::string& str, const std::string& delimiter) { + const auto delimiter_size{delimiter.length()}; + std::vector parts; + size_t pos_begin{0}; + size_t pos_end{0}; + + while (true) { + // Find next delimiter occurrence. + pos_end = str.find(delimiter, pos_begin); + + // Make next substring. + std::string part{str.substr(pos_begin, pos_end - pos_begin)}; + parts.push_back(part); + + // End execution if end of string reached. + if (pos_end == std::string::npos) { + break; + } + + // Recalculate string start pos - pos_end is the position of delimiter. + pos_begin = pos_end + delimiter_size; + } + + return parts; +} + +std::string string_utils::join(const std::vector& parts, + const std::string& delimiter) { + std::stringstream ss; + for (size_t i = 0; i < parts.size(); ++i) { + ss << parts[i]; + if (i < parts.size() - 1) { + ss << "."; + } + } + return ss.str(); +} + +std::string string_utils::trim(const std::string& str) { + auto fn = [](int c) { return !std::isspace(c); }; + auto left_pos{std::find_if(str.begin(), str.end(), fn)}; + auto right_pos{std::find_if(str.rbegin(), str.rend(), fn).base()}; + return std::string{left_pos, right_pos}; +} diff --git a/test_scenarios_cpp/src/string_utils.hpp b/test_scenarios_cpp/src/string_utils.hpp new file mode 100644 index 0000000..c9e721e --- /dev/null +++ b/test_scenarios_cpp/src/string_utils.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace string_utils { + +/// @brief Split string by the delimiter. +/// @param str Input string. +/// @param delimiter Delimiter. +/// @return List of string parts. E.g., ("1;2;3", ";") -> ["1", "2", "3"]. +std::vector split(const std::string& str, const std::string& delimiter); + +/// @brief Join string by the delimiter. +/// @param parts Input string parts. +/// @param delimiter Delimiter. +/// @return Joined string. E.g., (["1", "2", "3"], ";") -> "1;2;3". +std::string join(const std::vector& parts, const std::string& delimiter); + +/// @brief Trim surrounding whitespace. +/// @param str Input string. +/// @return Trimmed string. E.g., " 123 " -> "123". +std::string trim(const std::string& str); + +} // namespace string_utils diff --git a/test_scenarios_cpp/src/test_context.cpp b/test_scenarios_cpp/src/test_context.cpp new file mode 100644 index 0000000..e2e0a76 --- /dev/null +++ b/test_scenarios_cpp/src/test_context.cpp @@ -0,0 +1,51 @@ +#include "test_context.hpp" + +#include + +namespace { + +std::string join_name(const std::string& left, const std::string& right) { + if (!left.empty()) { + std::stringstream ss; + ss << left << "." << right; + return ss.str(); + } else { + return right; + } +} + +std::vector list_scenarios_recursive(ScenarioGroup::Ptr group, std::string prefix) { + std::vector names; + + auto groups{group->groups()}; + for (auto&& group : groups) { + auto new_prefix{join_name(prefix, group->name())}; + auto result{list_scenarios_recursive(group, new_prefix)}; + names.insert(names.end(), result.begin(), result.end()); + } + + auto scenarios{group->scenarios()}; + for (auto&& scenario : scenarios) { + auto scenario_name{join_name(prefix, scenario->name())}; + names.push_back(scenario_name); + } + + return names; +} +} // namespace + +TestContext::TestContext(ScenarioGroup::Ptr root_group) : root_group_{root_group} {} + +void TestContext::run(const std::string& name, const std::optional& input) const { + auto scenario{root_group_->find_scenario(name)}; + if (!scenario.has_value()) { + std::stringstream ss; + ss << "Scenario " << name << " not found"; + throw std::runtime_error{ss.str()}; + } + scenario->get()->run(input); +} + +std::vector TestContext::list_scenarios() const { + return list_scenarios_recursive(root_group_, ""); +} diff --git a/test_scenarios_cpp/src/tracing.cpp b/test_scenarios_cpp/src/tracing.cpp new file mode 100644 index 0000000..28ee990 --- /dev/null +++ b/test_scenarios_cpp/src/tracing.cpp @@ -0,0 +1,102 @@ +#include "tracing.hpp" + +#include +#include +#include + +#include "string_utils.hpp" + +using namespace tracing; + +namespace { +std::string minify_json(const std::string& input) { + bool in_str = false; + std::stringstream ss; + for (auto it = input.begin(); it != input.end(); ++it) { + auto c{*it}; + if (c == '\n') { + // Skip newlines. + } else if (!in_str && (c == ' ' || c == '\t')) { + // Skip whitespace outside of strings. + } else if (c == '"') { + // Flip inside of string flag. + in_str = !in_str; + ss << c; + } else { + ss << c; + } + } + return ss.str(); +} + +} // namespace + +std::string tracing::level_to_string(const Level& level) { + switch (level) { + case Level::Trace: + return "TRACE"; + case Level::Debug: + return "DEBUG"; + case Level::Info: + return "INFO"; + case Level::Warn: + return "WARN"; + case Level::Error: + return "ERROR"; + default: + throw std::runtime_error{"Invalid level"}; + } +} + +Subscriber::Subscriber(const Level& max_level, bool thread_ids) + : max_level_{max_level}, thread_ids_{thread_ids} {} + +void Subscriber::handle_event(const std::optional& target, const Level& level, + score::json::Object&& fields) const { + using namespace score::json; + + // Drop handling if below max level. + if (level < max_level_) { + return; + } + + Object event; + + // Add timestamp. + event.emplace("timestamp", timer_.format_time()); + + // Add level. + event.emplace("level", level_to_string(level)); + + // Add fields. + event.emplace("fields", std::move(fields)); + + // Add target. + if (target.has_value()) { + event.emplace("target", *target); + } + + // Add thread id. + if (thread_ids_) { + auto thread_id{std::this_thread::get_id()}; + std::stringstream ss; + ss << "ThreadId(" << thread_id << ")"; + event.emplace("threadId", ss.str()); + } + + // Make JSON string. + JsonWriter writer; + auto buffer_result{writer.ToBuffer(event)}; + if (!buffer_result) { + throw std::runtime_error{"Failed to stringify JSON"}; + } + std::cout << minify_json(*buffer_result) << std::endl; +} + +const Subscriber& tracing::global_subscriber() { + static std::unique_ptr subscriber{nullptr}; + if (!subscriber) { + subscriber = std::make_unique(Level::Trace, true); + } + return *subscriber; +} diff --git a/test_scenarios_cpp/tests/common.hpp b/test_scenarios_cpp/tests/common.hpp new file mode 100644 index 0000000..cfb9af6 --- /dev/null +++ b/test_scenarios_cpp/tests/common.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +#include "scenario.hpp" + +// Replacement for `should_panic` macro. +#define SHOULD_THROW(expression, expected_exception, expected_what) \ + do { \ + try { \ + expression; \ + FAIL(); \ + } catch (const expected_exception& ex) { \ + ASSERT_STREQ(ex.what(), expected_what); \ + } catch (...) { \ + FAIL(); \ + } \ + } while (false) + +// `SHOULD_THROW` for common `std::runtime_error`. +#define SHOULD_THROW_RE(expression, expected_what) \ + SHOULD_THROW(expression, std::runtime_error, expected_what) + +class ScenarioStub : public Scenario { + public: + ScenarioStub(const std::string& name) : name_{name} {} + ~ScenarioStub() {} + + std::string name() const override { return name_; } + + void run(const std::optional& input) const override { + if (input.has_value()) { + if (input == "ok") { + return; + } else if (input == "error") { + throw std::runtime_error{"Requested error"}; + } else { + throw std::runtime_error{"Unknown value"}; + } + } else { + throw std::runtime_error{"Missing input"}; + } + } + + private: + std::string name_; +}; diff --git a/test_scenarios_cpp/tests/test_cli.cpp b/test_scenarios_cpp/tests/test_cli.cpp new file mode 100644 index 0000000..47a1ffd --- /dev/null +++ b/test_scenarios_cpp/tests/test_cli.cpp @@ -0,0 +1,232 @@ +#include + +#include +#include +#include + +#include "cli.hpp" +#include "common.hpp" +#include "scenario.hpp" +#include "test_context.hpp" + +TEST(parse_cli_arguments, empty) { + std::vector raw_arguments; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + // Default values are expected. + ASSERT_FALSE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_FALSE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_FALSE(cli_arguments.list_scenarios); + ASSERT_FALSE(cli_arguments.help); +} + +TEST(parse_cli_arguments, executable_name_only) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name}; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + // Default values are expected. + ASSERT_FALSE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_FALSE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_FALSE(cli_arguments.list_scenarios); + ASSERT_FALSE(cli_arguments.help); +} + +TEST(parse_cli_arguments, name_ok) { + std::vector args{"-n", "--name"}; + for (auto&& arg : args) { + std::string exe_name{"exe_name"}; + std::string example_name{"example_name"}; + std::vector raw_arguments{exe_name, arg, example_name}; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + ASSERT_TRUE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_EQ(*cli_arguments.scenario_arguments.name, example_name); + ASSERT_FALSE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_FALSE(cli_arguments.list_scenarios); + ASSERT_FALSE(cli_arguments.help); + } +} + +TEST(parse_cli_arguments, name_missing) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, "--name"}; + SHOULD_THROW_RE(auto cli_arguments{parse_cli_arguments(raw_arguments)}, + "Failed to read name parameter"); +} + +TEST(parse_cli_arguments, input_ok) { + std::vector args{"-i", "--input"}; + for (auto&& arg : args) { + std::string exe_name{"exe_name"}; + std::string example_input{"example_input"}; + std::vector raw_arguments{exe_name, arg, example_input}; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + ASSERT_FALSE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_TRUE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_EQ(*cli_arguments.scenario_arguments.input, example_input); + ASSERT_FALSE(cli_arguments.list_scenarios); + ASSERT_FALSE(cli_arguments.help); + } +} + +TEST(parse_cli_arguments, input_missing) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, "--input"}; + SHOULD_THROW_RE(auto cli_arguments{parse_cli_arguments(raw_arguments)}, + "Failed to read input parameter"); +} + +TEST(parse_cli_arguments, list_scenarios) { + std::vector args{"-l", "--list-scenarios"}; + for (auto&& arg : args) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, arg}; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + ASSERT_FALSE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_FALSE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_TRUE(cli_arguments.list_scenarios); + ASSERT_FALSE(cli_arguments.help); + } +} + +TEST(parse_cli_arguments, help) { + std::vector args{"-h", "--help"}; + for (auto&& arg : args) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, arg}; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + ASSERT_FALSE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_FALSE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_FALSE(cli_arguments.list_scenarios); + ASSERT_TRUE(cli_arguments.help); + } +} + +TEST(parse_cli_arguments, unknown_argument) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, "--invalid-arg"}; + SHOULD_THROW_RE(auto cli_arguments{parse_cli_arguments(raw_arguments)}, + "Unknown argument provided"); +} + +TEST(parse_cli_arguments, all) { + std::string exe_name{"exe_name"}; + std::string example_name{"example_name"}; + std::string example_input{"example_input"}; + std::vector raw_arguments{ + exe_name, "--help", "--list-scenarios", "--input", example_input, "--name", example_name, + }; + auto cli_arguments{parse_cli_arguments(raw_arguments)}; + + ASSERT_TRUE(cli_arguments.scenario_arguments.name.has_value()); + ASSERT_EQ(*cli_arguments.scenario_arguments.name, example_name); + ASSERT_TRUE(cli_arguments.scenario_arguments.input.has_value()); + ASSERT_EQ(*cli_arguments.scenario_arguments.input, example_input); + ASSERT_TRUE(cli_arguments.list_scenarios); + ASSERT_TRUE(cli_arguments.help); +} + +TEST(run_cli_app, show_help) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, "--help"}; + std::vector scenarios; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + run_cli_app(raw_arguments, test_context); + // TODO: capture stderr. +} + +TEST(run_cli_app, list_scenarios) { + std::string exe_name{"exe_name"}; + std::vector raw_arguments{exe_name, "--list-scenarios"}; + std::vector scenarios; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + run_cli_app(raw_arguments, test_context); + // TODO: capture stderr. +} + +TEST(run_cli_app, ok) { + std::string exe_name{"exe_name"}; + std::string scenario_name{"example_scenario"}; + std::vector raw_arguments{ + exe_name, "--name", scenario_name, "--input", "ok", + }; + Scenario::Ptr scenario{new ScenarioStub{scenario_name}}; + std::vector scenarios{scenario}; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + run_cli_app(raw_arguments, test_context); +} + +TEST(run_cli_app, error) { + std::string exe_name{"exe_name"}; + std::string scenario_name{"example_scenario"}; + std::vector raw_arguments{ + exe_name, "--name", scenario_name, "--input", "error", + }; + Scenario::Ptr scenario{new ScenarioStub{scenario_name}}; + std::vector scenarios{scenario}; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + // It's expected that test will fail due to error from `ScenarioStub`, not from `run_cli_app`. + SHOULD_THROW_RE(run_cli_app(raw_arguments, test_context), "Requested error"); +} + +TEST(run_cli_app, missing_input) { + std::string exe_name{"exe_name"}; + std::string scenario_name{"example_scenario"}; + std::vector raw_arguments{exe_name, "--name", scenario_name}; + Scenario::Ptr scenario{new ScenarioStub{scenario_name}}; + std::vector scenarios{scenario}; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + // It's expected that test will fail due to error from `ScenarioStub`, not from `run_cli_app`. + SHOULD_THROW_RE(run_cli_app(raw_arguments, test_context), "Missing input"); +} + +TEST(run_cli_app, missing_name) { + std::string exe_name{"exe_name"}; + std::string scenario_name{"example_scenario"}; + std::vector raw_arguments{exe_name}; + Scenario::Ptr scenario{new ScenarioStub{scenario_name}}; + std::vector scenarios{scenario}; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + SHOULD_THROW_RE(run_cli_app(raw_arguments, test_context), + "Test scenario name must be provided"); +} + +TEST(run_cli_app, invalid_name) { + std::string exe_name{"exe_name"}; + std::string scenario_name{"example_scenario"}; + std::vector raw_arguments{exe_name, "--name", "invalid_scenario"}; + Scenario::Ptr scenario{new ScenarioStub{scenario_name}}; + std::vector scenarios{scenario}; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext test_context{root_group}; + + // It's expected that test will fail due to error from `TestContext`, not from `run_cli_app`. + SHOULD_THROW_RE(run_cli_app(raw_arguments, test_context), + "Scenario invalid_scenario not found"); +} + +#undef SHOULD_THROW_RE +#undef SHOULD_THROW diff --git a/test_scenarios_cpp/tests/test_scenario.cpp b/test_scenarios_cpp/tests/test_scenario.cpp new file mode 100644 index 0000000..4e02a97 --- /dev/null +++ b/test_scenarios_cpp/tests/test_scenario.cpp @@ -0,0 +1,74 @@ +#include + +#include + +#include "common.hpp" +#include "scenario.hpp" + +namespace { + +ScenarioGroup::Ptr init_group() { + Scenario::Ptr scenario_inner{new ScenarioStub{"inner_scenario"}}; + std::vector scenarios_inner{scenario_inner}; + std::vector groups_inner; + ScenarioGroup::Ptr group_inner{ + new ScenarioGroupImpl{"inner_group", scenarios_inner, groups_inner}}; + + Scenario::Ptr scenario_outer{new ScenarioStub{"outer_scenario"}}; + std::vector scenarios_outer{scenario_outer}; + std::vector groups_outer{group_inner}; + ScenarioGroup::Ptr group_outer{ + new ScenarioGroupImpl{"outer_group", scenarios_outer, groups_outer}}; + + return group_outer; +} + +} // namespace + +TEST(group_impl, group_name_ok) { + auto group{init_group()}; + ASSERT_EQ(group->name(), "outer_group"); +} + +TEST(group_impl, groups_ok) { + auto group{init_group()}; + + auto groups_result{group->groups()}; + ASSERT_EQ(groups_result.size(), 1); + ASSERT_EQ(groups_result[0]->name(), "inner_group"); + + auto scenarios_result{groups_result[0]->scenarios()}; + ASSERT_EQ(scenarios_result.size(), 1); + ASSERT_EQ(scenarios_result[0]->name(), "inner_scenario"); +} + +TEST(group_impl, scenarios_ok) { + auto group{init_group()}; + + auto groups_result{group->groups()}; + auto scenarios_result{groups_result[0]->scenarios()}; + ASSERT_EQ(scenarios_result.size(), 1); + ASSERT_EQ(scenarios_result[0]->name(), "inner_scenario"); +} + +TEST(group_impl, find_scenario_ok) { + auto group{init_group()}; + auto scenario1{group->find_scenario("inner_group.inner_scenario")}; + ASSERT_TRUE(scenario1.has_value()); + ASSERT_EQ((*scenario1)->name(), "inner_scenario"); + auto scenario2{group->find_scenario("outer_scenario")}; + ASSERT_TRUE(scenario2.has_value()); + ASSERT_EQ((*scenario2)->name(), "outer_scenario"); +} + +TEST(group_impl, find_scenario_empty_input) { + auto group{init_group()}; + auto scenario{group->find_scenario("")}; + ASSERT_FALSE(scenario.has_value()); +} + +TEST(group_impl, find_scenario_invalid_name) { + auto group{init_group()}; + auto scenario{group->find_scenario("invalid_group.invalid_scenario")}; + ASSERT_FALSE(scenario.has_value()); +} diff --git a/test_scenarios_cpp/tests/test_test_context.cpp b/test_scenarios_cpp/tests/test_test_context.cpp new file mode 100644 index 0000000..3afd3c2 --- /dev/null +++ b/test_scenarios_cpp/tests/test_test_context.cpp @@ -0,0 +1,72 @@ +#include + +#include "common.hpp" +#include "scenario.hpp" +#include "test_context.hpp" + +namespace { + +ScenarioGroup::Ptr init_group() { + Scenario::Ptr scenario_inner{new ScenarioStub{"inner_scenario"}}; + std::vector scenarios_inner{scenario_inner}; + std::vector groups_inner; + ScenarioGroup::Ptr group_inner{ + new ScenarioGroupImpl{"inner_group", scenarios_inner, groups_inner}}; + + Scenario::Ptr scenario_outer{new ScenarioStub{"outer_scenario"}}; + std::vector scenarios_outer{scenario_outer}; + std::vector groups_outer{group_inner}; + ScenarioGroup::Ptr group_outer{ + new ScenarioGroupImpl{"outer_group", scenarios_outer, groups_outer}}; + + return group_outer; +} + +} // namespace + +TEST(test_context, run_none_input_err) { + auto root_group{init_group()}; + TestContext context{root_group}; + SHOULD_THROW_RE(context.run("inner_group.inner_scenario", {}), "Missing input"); +} + +TEST(test_context, run_some_input_ok) { + auto root_group{init_group()}; + TestContext context{root_group}; + context.run("inner_group.inner_scenario", {"ok"}); +} + +TEST(test_context, run_some_input_err) { + auto root_group{init_group()}; + TestContext context{root_group}; + SHOULD_THROW_RE(context.run("inner_group.inner_scenario", {"error"}), "Requested error"); +} + +TEST(test_context, run_not_found) { + auto root_group{init_group()}; + TestContext context{root_group}; + SHOULD_THROW_RE(context.run("some_scenario", {}), "Scenario some_scenario not found"); +} + +TEST(test_context, list_scenarios_ok) { + auto root_group{init_group()}; + TestContext context{root_group}; + auto result{context.list_scenarios()}; + + ASSERT_EQ(result.size(), 2); + ASSERT_EQ(result[0], "inner_group.inner_scenario"); + ASSERT_EQ(result[1], "outer_scenario"); +} + +TEST(test_context, list_scenarios_empty) { + std::vector scenarios; + std::vector groups; + ScenarioGroup::Ptr root_group{new ScenarioGroupImpl{"root", scenarios, groups}}; + TestContext context{root_group}; + auto result{context.list_scenarios()}; + + ASSERT_EQ(result.size(), 0); +} + +#undef SHOULD_THROW_RE +#undef SHOULD_THROW diff --git a/test_scenarios_rust/BUILD b/test_scenarios_rust/BUILD new file mode 100644 index 0000000..c1ff058 --- /dev/null +++ b/test_scenarios_rust/BUILD @@ -0,0 +1,16 @@ +load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test") +load("@test_scenarios_rust_crates//:defs.bzl", "all_crate_deps") + +rust_library( + name = "test_scenarios_rust", + srcs = glob(["src/**/*.rs"]), + visibility = ["//visibility:public"], + deps = all_crate_deps(normal = True), +) + +rust_test( + name = "tests", + crate = ":test_scenarios_rust", + visibility = ["//visibility:private"], + deps = all_crate_deps(normal = True), +) diff --git a/test_scenarios_rust/Cargo.lock b/test_scenarios_rust/Cargo.lock new file mode 100644 index 0000000..35830c6 --- /dev/null +++ b/test_scenarios_rust/Cargo.lock @@ -0,0 +1,264 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "proc-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.141" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "syn" +version = "2.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "test_scenarios_rust" +version = "0.2.0" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/test_scenarios_rust/Cargo.toml b/test_scenarios_rust/Cargo.toml new file mode 100644 index 0000000..2c38038 --- /dev/null +++ b/test_scenarios_rust/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "test_scenarios_rust" +version = "0.2.0" +edition = "2021" + +[dependencies] +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.19", features = ["json"] } diff --git a/test_scenarios_rust/src/cli.rs b/test_scenarios_rust/src/cli.rs new file mode 100644 index 0000000..507029f --- /dev/null +++ b/test_scenarios_rust/src/cli.rs @@ -0,0 +1,423 @@ +use crate::monotonic_clock::MonotonicClock; +use crate::test_context::TestContext; +use std::sync::Once; +use tracing::Level; +use tracing_subscriber::FmtSubscriber; + +/// Tracing subscriber should be initialized only once. +static TRACING_SUBSCRIBER_INIT: Once = Once::new(); + +fn init_tracing_subscriber() { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::TRACE) + .with_thread_ids(true) + .with_timer(MonotonicClock::new()) + .json() + .finish(); + + tracing::subscriber::set_global_default(subscriber) + .expect("Setting default subscriber failed!"); +} + +/// Test scenario arguments. +#[derive(Default)] +struct ScenarioArguments { + /// Test scenario name. + name: Option, + + /// Test scenario input. + input: Option, +} + +/// CLI arguments. +#[derive(Default)] +struct CliArguments { + /// Test scenario arguments. + scenario_arguments: ScenarioArguments, + + /// List scenarios. + list_scenarios: bool, + + /// Show help. + help: bool, +} + +/// Parse CLI arguments. +/// +/// * `raw_arguments` - Collected arguments from `std::env::args()`. +fn parse_cli_arguments(raw_arguments: &[String]) -> CliArguments { + let mut cli_arguments = CliArguments::default(); + + // Process arguments. + // First argument (executable name) is skipped. + let mut args_it = raw_arguments.iter().skip(1); + while let Some(arg) = args_it.next() { + match arg.as_str() { + "-n" | "--name" => { + if let Some(value) = args_it.next() { + cli_arguments.scenario_arguments.name = Some(value.clone()); + } else { + panic!("Failed to read name parameter"); + } + } + "-i" | "--input" => { + if let Some(value) = args_it.next() { + cli_arguments.scenario_arguments.input = Some(value.clone()); + } else { + panic!("Failed to read input parameter") + } + } + "-l" | "--list-scenarios" => { + cli_arguments.list_scenarios = true; + } + "-h" | "--help" => { + cli_arguments.help = true; + } + _ => { + panic!("Unknown argument provided"); + } + } + } + + cli_arguments +} + +/// Runs CLI application based on provided arguments and test context. +/// +/// * `raw_arguments` - Collected arguments from `std::env::args()`. +/// * `test_context` - Test context to use. +/// +/// # Examples +/// +/// ```rust +/// use test_scenarios_rust::test_context::TestContext; +/// use test_scenarios_rust::scenario::ScenarioGroupImpl; +/// use test_scenarios_rust::cli::run_cli_app; +/// +/// let raw_arguments = Vec::from(["example".to_string(), "--list-scenarios".to_string()]); +/// let root_group = ScenarioGroupImpl::new("root", Vec::new(), Vec::new()); +/// let test_context = TestContext::new(Box::new(root_group)); +/// +/// run_cli_app(&raw_arguments, &test_context); +/// ``` +pub fn run_cli_app(raw_arguments: &[String], test_context: &TestContext) { + // Parse CLI arguments. + let cli_arguments = parse_cli_arguments(raw_arguments); + + // Show help and return. + if cli_arguments.help { + eprintln!("Test scenario runner"); + eprintln!("'-n', '--name' - test scenario name"); + eprintln!("'-i', '--input' - test scenario input"); + eprintln!("'-l', '--list-scenarios' - list available scenarios"); + eprintln!("'-h', '--help' - show help"); + return; + } + + // List scenarios and return. + if cli_arguments.list_scenarios { + let scenario_names = test_context.list_scenarios(); + for scenario_name in scenario_names { + println!("{scenario_name}"); + } + return; + } + + // Find scenario. + let scenario = cli_arguments.scenario_arguments; + if scenario.name.is_none() || scenario.name.clone().is_some_and(|n| n.is_empty()) { + panic!("Test scenario name must be provided"); + } + + // Initialize tracing subscriber. + TRACING_SUBSCRIBER_INIT.call_once(|| { + init_tracing_subscriber(); + }); + + test_context + .run(scenario.name.unwrap().as_str(), scenario.input) + .unwrap(); +} + +#[cfg(test)] +mod tests { + use crate::cli::{parse_cli_arguments, run_cli_app}; + use crate::scenario::{Scenario, ScenarioGroupImpl}; + use crate::test_context::TestContext; + + struct ScenarioStub { + name: String, + } + + impl ScenarioStub { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + } + } + } + + impl Scenario for ScenarioStub { + fn name(&self) -> &str { + &self.name + } + + fn run(&self, input: Option) -> Result<(), String> { + if let Some(input) = input { + match input.as_str() { + "ok" => Ok(()), + "error" => Err("Requested error".to_string()), + _ => Err("Unknown value".to_string()), + } + } else { + Err("Missing input".to_string()) + } + } + } + + #[test] + fn test_parse_cli_arguments_empty() { + let raw_arguments = vec![]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + // Default values are expected. + assert!(cli_arguments.scenario_arguments.name.is_none()); + assert!(cli_arguments.scenario_arguments.input.is_none()); + assert!(!cli_arguments.list_scenarios); + assert!(!cli_arguments.help); + } + + #[test] + fn test_parse_cli_arguments_executable_name_only() { + let exe_name = "exe_name".to_string(); + let raw_arguments = vec![exe_name]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + // Default values are expected. + assert!(cli_arguments.scenario_arguments.name.is_none()); + assert!(cli_arguments.scenario_arguments.input.is_none()); + assert!(!cli_arguments.list_scenarios); + assert!(!cli_arguments.help); + } + + #[test] + fn test_parse_cli_arguments_name_ok() { + let exe_name = "exe_name".to_string(); + for arg in ["-n", "--name"] { + let example_name = "example_name".to_string(); + let raw_arguments = vec![exe_name.clone(), arg.to_string(), example_name.clone()]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + assert!(cli_arguments + .scenario_arguments + .name + .is_some_and(|n| n == example_name)); + assert!(cli_arguments.scenario_arguments.input.is_none()); + assert!(!cli_arguments.list_scenarios); + assert!(!cli_arguments.help); + } + } + + #[test] + #[should_panic(expected = "Failed to read name parameter")] + fn test_parse_cli_arguments_name_missing() { + let exe_name = "exe_name".to_string(); + let raw_arguments = [exe_name, "--name".to_string()]; + let _ = parse_cli_arguments(&raw_arguments); + } + + #[test] + fn test_parse_cli_arguments_input_ok() { + for arg in ["-i", "--input"] { + let exe_name = "exe_name".to_string(); + let example_input = "example_input".to_string(); + let raw_arguments = [exe_name.clone(), arg.to_string(), example_input.clone()]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + assert!(cli_arguments.scenario_arguments.name.is_none()); + assert!(cli_arguments + .scenario_arguments + .input + .is_some_and(|n| n == example_input)); + assert!(!cli_arguments.list_scenarios); + assert!(!cli_arguments.help); + } + } + + #[test] + #[should_panic(expected = "Failed to read input parameter")] + fn test_parse_cli_arguments_input_missing() { + let exe_name = "exe_name".to_string(); + let raw_arguments = [exe_name, "--input".to_string()]; + let _ = parse_cli_arguments(&raw_arguments); + } + + #[test] + fn test_parse_cli_arguments_list_scenarios() { + let exe_name = "exe_name".to_string(); + for arg in ["-l", "--list-scenarios"] { + let raw_arguments = [exe_name.clone(), arg.to_string()]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + assert!(cli_arguments.scenario_arguments.name.is_none()); + assert!(cli_arguments.scenario_arguments.input.is_none()); + assert!(cli_arguments.list_scenarios); + assert!(!cli_arguments.help); + } + } + + #[test] + fn test_parse_cli_arguments_help() { + let exe_name = "exe_name".to_string(); + for arg in ["-h", "--help"] { + let raw_arguments = [exe_name.clone(), arg.to_string()]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + assert!(cli_arguments.scenario_arguments.name.is_none()); + assert!(cli_arguments.scenario_arguments.input.is_none()); + assert!(!cli_arguments.list_scenarios); + assert!(cli_arguments.help); + } + } + + #[test] + #[should_panic(expected = "Unknown argument provided")] + fn test_parse_cli_arguments_unknown_argument() { + let exe_name = "exe_name".to_string(); + let raw_arguments = [exe_name, "--invalid-arg".to_string()]; + let _ = parse_cli_arguments(&raw_arguments); + } + + #[test] + fn test_parse_cli_arguments_all() { + let exe_name = "exe_name".to_string(); + let example_name = "example_name".to_string(); + let example_input = "example_input".to_string(); + let raw_arguments = [ + exe_name, + "--help".to_string(), + "--list-scenarios".to_string(), + "--input".to_string(), + example_input.clone(), + "--name".to_string(), + example_name.clone(), + ]; + let cli_arguments = parse_cli_arguments(&raw_arguments); + + assert!(cli_arguments + .scenario_arguments + .name + .is_some_and(|n| n == example_name)); + assert!(cli_arguments + .scenario_arguments + .input + .is_some_and(|n| n == example_input)); + assert!(cli_arguments.list_scenarios); + assert!(cli_arguments.help); + } + + #[test] + fn test_run_cli_app_show_help() { + let exe_name = "exe_name".to_string(); + let raw_arguments = vec![exe_name, "--help".to_string()]; + let root_group = ScenarioGroupImpl::new("root", vec![], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + run_cli_app(&raw_arguments, &test_context); + // It's not possible to check stderr without unstable feature. + } + + #[test] + fn test_run_cli_app_list_scenarios() { + let exe_name = "exe_name".to_string(); + let raw_arguments = vec![exe_name, "--list-scenarios".to_string()]; + let root_group = ScenarioGroupImpl::new("root", vec![], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + run_cli_app(&raw_arguments, &test_context); + // It's not possible to check stdout without unstable feature. + } + + #[test] + fn test_run_cli_app_ok() { + let exe_name = "exe_name".to_string(); + let scenario_name = "example_scenario"; + let raw_arguments = [ + exe_name, + "--name".to_string(), + scenario_name.to_string(), + "--input".to_string(), + "ok".to_string(), + ]; + let scenario = ScenarioStub::new(scenario_name); + let root_group = ScenarioGroupImpl::new("root", vec![Box::new(scenario)], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + run_cli_app(&raw_arguments, &test_context); + } + + #[test] + #[should_panic(expected = "Requested error")] + fn test_run_cli_app_error() { + let exe_name = "exe_name".to_string(); + let scenario_name = "example_scenario"; + let raw_arguments = [ + exe_name, + "--name".to_string(), + scenario_name.to_string(), + "--input".to_string(), + "error".to_string(), + ]; + let scenario = ScenarioStub::new(scenario_name); + let root_group = ScenarioGroupImpl::new("root", vec![Box::new(scenario)], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + // It's expected that test will fail due to error from `ScenarioStub`, not from `run_cli_app`. + run_cli_app(&raw_arguments, &test_context); + } + + #[test] + #[should_panic(expected = "Missing input")] + fn test_run_cli_app_missing_input() { + let exe_name = "exe_name".to_string(); + let scenario_name = "example_scenario"; + let raw_arguments = [exe_name, "--name".to_string(), scenario_name.to_string()]; + let scenario = ScenarioStub::new(scenario_name); + let root_group = ScenarioGroupImpl::new("root", vec![Box::new(scenario)], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + // It's expected that test will fail due to error from `ScenarioStub`, not from `run_cli_app`. + run_cli_app(&raw_arguments, &test_context); + } + + #[test] + #[should_panic(expected = "Test scenario name must be provided")] + fn test_run_cli_app_missing_name() { + let exe_name = "exe_name".to_string(); + let scenario_name = "example_scenario"; + let raw_arguments = vec![exe_name]; + let scenario = ScenarioStub::new(scenario_name); + let root_group = ScenarioGroupImpl::new("root", vec![Box::new(scenario)], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + run_cli_app(&raw_arguments, &test_context); + } + + #[test] + #[should_panic(expected = "Scenario invalid_scenario not found")] + fn test_run_cli_app_invalid_name() { + let exe_name = "exe_name".to_string(); + let scenario_name = "example_scenario"; + let raw_arguments = [ + exe_name, + "--name".to_string(), + "invalid_scenario".to_string(), + ]; + let scenario = ScenarioStub::new(scenario_name); + let root_group = ScenarioGroupImpl::new("root", vec![Box::new(scenario)], vec![]); + let test_context = TestContext::new(Box::new(root_group)); + + // It's expected that test will fail due to error from `TestContext`, not from `run_cli_app`. + run_cli_app(&raw_arguments, &test_context); + } +} diff --git a/test_scenarios_rust/src/lib.rs b/test_scenarios_rust/src/lib.rs new file mode 100644 index 0000000..2e7c77c --- /dev/null +++ b/test_scenarios_rust/src/lib.rs @@ -0,0 +1,6 @@ +//! Common implementation of test scenario runner for Rust. + +pub mod cli; +mod monotonic_clock; +pub mod scenario; +pub mod test_context; diff --git a/test_scenarios_rust/src/monotonic_clock.rs b/test_scenarios_rust/src/monotonic_clock.rs new file mode 100644 index 0000000..f0b3175 --- /dev/null +++ b/test_scenarios_rust/src/monotonic_clock.rs @@ -0,0 +1,23 @@ +use std::fmt; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::time::FormatTime; + +/// Timestamp provider using monotonic clock. +pub struct MonotonicClock { + start: std::time::Instant, +} + +impl MonotonicClock { + pub fn new() -> Self { + Self { + start: std::time::Instant::now(), + } + } +} + +impl FormatTime for MonotonicClock { + fn format_time(&self, w: &mut Writer<'_>) -> fmt::Result { + let elapsed = std::time::Instant::now() - self.start; + write!(w, "{}", elapsed.as_micros()) + } +} diff --git a/test_scenarios_rust/src/scenario.rs b/test_scenarios_rust/src/scenario.rs new file mode 100644 index 0000000..041a39a --- /dev/null +++ b/test_scenarios_rust/src/scenario.rs @@ -0,0 +1,175 @@ +/// Scenario definition. +pub trait Scenario { + /// Get scenario name. + fn name(&self) -> &str; + + /// Run test scenario. + /// + /// * `input` - Test scenario input. + fn run(&self, input: Option) -> Result<(), String>; +} + +/// Scenario group definition. +pub trait ScenarioGroup { + /// Get scenario group name. + fn name(&self) -> &str; + + /// List groups from this group. + fn groups(&self) -> &Vec>; + + /// List scenarios from this group. + fn scenarios(&self) -> &Vec>; + + /// Find scenario by name. + /// + /// * `name` - Name of the scenario to find. + fn find_scenario(&self, name: &str) -> Option<&dyn Scenario>; +} + +/// Common scenario group definition. +pub struct ScenarioGroupImpl { + name: String, + scenarios: Vec>, + groups: Vec>, +} + +impl ScenarioGroupImpl { + /// Create common scenario group definition. + /// + /// * `name` - Name of the scenario group. + /// * `scenario` - Scenarios in this group. + /// * `groups` - Groups in this group. + pub fn new( + name: &str, + scenarios: Vec>, + groups: Vec>, + ) -> Self { + ScenarioGroupImpl { + name: name.to_string(), + scenarios, + groups, + } + } +} + +impl ScenarioGroup for ScenarioGroupImpl { + fn name(&self) -> &str { + self.name.as_str() + } + + fn groups(&self) -> &Vec> { + &self.groups + } + + fn scenarios(&self) -> &Vec> { + &self.scenarios + } + + fn find_scenario(&self, name: &str) -> Option<&dyn Scenario> { + let split: Vec<&str> = name.split('.').collect(); + if split.len() == 1 { + for scenario in &self.scenarios { + if scenario.name() == name { + return Some(scenario.as_ref()); + } + } + } else { + for group in &self.groups { + if group.name() == split[0] { + return group.find_scenario(split[1..].join(".").as_str()); + } + } + } + + None + } +} + +#[cfg(test)] +mod tests { + use crate::scenario::{Scenario, ScenarioGroup, ScenarioGroupImpl}; + + struct ScenarioStub { + name: String, + } + + impl Scenario for ScenarioStub { + fn name(&self) -> &str { + &self.name + } + + fn run(&self, _input: Option) -> Result<(), String> { + Ok(()) + } + } + + fn init_group() -> Box { + let scenario_inner = ScenarioStub { + name: "inner_scenario".to_string(), + }; + let group_inner = + ScenarioGroupImpl::new("inner_group", vec![Box::new(scenario_inner)], vec![]); + let scenario_outer = ScenarioStub { + name: "outer_scenario".to_string(), + }; + let group_outer = ScenarioGroupImpl::new( + "outer_group", + vec![Box::new(scenario_outer)], + vec![Box::new(group_inner)], + ); + + Box::new(group_outer) + } + + #[test] + fn test_group_name_ok() { + let group = init_group(); + assert_eq!(group.name(), "outer_group"); + } + + #[test] + fn test_groups_ok() { + let group = init_group(); + + let groups_result = group.groups(); + assert_eq!(groups_result.len(), 1); + assert_eq!(groups_result[0].name(), "inner_group"); + + let scenarios_result = groups_result[0].scenarios(); + assert_eq!(scenarios_result.len(), 1); + assert_eq!(scenarios_result[0].name(), "inner_scenario"); + } + + #[test] + fn test_scenarios_ok() { + let group = init_group(); + + let groups_result = group.groups(); + let scenarios_result = groups_result[0].scenarios(); + assert_eq!(scenarios_result.len(), 1); + assert_eq!(scenarios_result[0].name(), "inner_scenario"); + } + + #[test] + fn test_find_scenario_ok() { + let group = init_group(); + let scenario1 = group.find_scenario("inner_group.inner_scenario"); + assert!(scenario1.is_some_and(|s| s.name() == "inner_scenario")); + let scenario2 = group.find_scenario("outer_scenario"); + assert!(scenario2.is_some_and(|s| s.name() == "outer_scenario")); + } + + #[test] + fn test_find_scenario_empty_input() { + let group = init_group(); + let scenario = group.find_scenario(""); + assert!(scenario.is_none()); + } + + #[test] + fn test_find_scenario_invalid_name() { + let group = init_group(); + let scenario = group.find_scenario("invalid_group.invalid_scenario"); + assert!(scenario.is_none()); + } +} diff --git a/test_scenarios_rust/src/test_context.rs b/test_scenarios_rust/src/test_context.rs new file mode 100644 index 0000000..27b3a36 --- /dev/null +++ b/test_scenarios_rust/src/test_context.rs @@ -0,0 +1,161 @@ +use crate::scenario::ScenarioGroup; + +fn join_name(left: &str, right: &str) -> String { + if !left.is_empty() { + format!("{left}.{right}") + } else { + right.to_string() + } +} + +fn list_scenarios_recursive(group: &dyn ScenarioGroup, prefix: String) -> Vec { + let mut names = Vec::new(); + + let groups = group.groups(); + for group in groups { + let new_prefix = join_name(&prefix, group.name()); + let result = list_scenarios_recursive(group.as_ref(), new_prefix); + names.extend(result); + } + + let scenarios = group.scenarios(); + for scenario in scenarios { + let scenario_name = join_name(&prefix, scenario.name()); + names.push(scenario_name); + } + + names +} + +/// Test context. Responsible for listing and running scenarios. +pub struct TestContext { + root_group: Box, +} + +impl TestContext { + /// Create test context. + /// + /// * `root_group` - Root test scenario group. + pub fn new(root_group: Box) -> Self { + TestContext { root_group } + } + + /// Run test scenario. + /// + /// * `name` - Name of the scenario to run. + /// * `input` - Test scenario input. + pub fn run(&self, name: &str, input: Option) -> Result<(), String> { + let scenario = self.root_group.find_scenario(name); + match scenario { + Some(scenario) => scenario.run(input), + None => Err(format!("Scenario {name} not found")), + } + } + + /// List available scenarios. + pub fn list_scenarios(&self) -> Vec { + list_scenarios_recursive(self.root_group.as_ref(), "".to_string()) + } +} + +#[cfg(test)] +mod tests { + use crate::scenario::{Scenario, ScenarioGroup, ScenarioGroupImpl}; + use crate::test_context::TestContext; + + struct ScenarioStub { + name: String, + } + + impl Scenario for ScenarioStub { + fn name(&self) -> &str { + &self.name + } + + fn run(&self, input: Option) -> Result<(), String> { + if let Some(value) = input { + match value.as_str() { + "ok" => Ok(()), + "error" => Err("Requested error".to_string()), + _ => Err("Missing input".to_string()), + } + } else { + Err("Missing input".to_string()) + } + } + } + + fn init_group() -> Box { + let scenario_inner = ScenarioStub { + name: "inner_scenario".to_string(), + }; + let group_inner = + ScenarioGroupImpl::new("inner_group", vec![Box::new(scenario_inner)], vec![]); + let scenario_outer = ScenarioStub { + name: "outer_scenario".to_string(), + }; + let group_outer = ScenarioGroupImpl::new( + "outer_group", + vec![Box::new(scenario_outer)], + vec![Box::new(group_inner)], + ); + + Box::new(group_outer) + } + + #[test] + fn test_run_none_input_err() { + let root_group = init_group(); + let context = TestContext::new(root_group); + let result = context.run("inner_group.inner_scenario", None); + + assert!(result.is_err_and(|e| e == "Missing input")); + } + + #[test] + fn test_run_some_input_ok() { + let root_group = init_group(); + let context = TestContext::new(root_group); + let result = context.run("inner_group.inner_scenario", Some("ok".to_string())); + + assert!(result.is_ok()); + } + + #[test] + fn test_run_some_input_err() { + let root_group = init_group(); + let context = TestContext::new(root_group); + let result = context.run("inner_group.inner_scenario", Some("error".to_string())); + + assert!(result.is_err_and(|e| e == "Requested error")); + } + + #[test] + fn test_run_not_found() { + let root_group = init_group(); + let context = TestContext::new(root_group); + let result = context.run("some_scenario", None); + + assert!(result.is_err_and(|e| e == "Scenario some_scenario not found")); + } + + #[test] + fn test_list_scenarios_ok() { + let root_group = init_group(); + let context = TestContext::new(root_group); + let result = context.list_scenarios(); + + assert_eq!(result.len(), 2); + assert_eq!(result[0], "inner_group.inner_scenario"); + assert_eq!(result[1], "outer_scenario"); + } + + #[test] + fn test_list_scenarios_empty() { + let root_group = ScenarioGroupImpl::new("root", vec![], vec![]); + let context = TestContext::new(Box::new(root_group)); + let result = context.list_scenarios(); + + assert_eq!(result.len(), 0); + } +} diff --git a/testing_utils/__init__.py b/testing_utils/__init__.py index 17d971c..cb33a33 100644 --- a/testing_utils/__init__.py +++ b/testing_utils/__init__.py @@ -3,12 +3,12 @@ """ __all__ = [ - "cargo_tools", + "build_tools", "log_container", "result_entry", "scenario", ] -from .cargo_tools import cargo_build, cargo_metadata, find_bin_path, select_bin_path +from .build_tools import BazelTools, BuildTools, CargoTools from .log_container import LogContainer from .result_entry import ResultEntry from .scenario import Scenario, ScenarioResult diff --git a/testing_utils/build_tools.py b/testing_utils/build_tools.py new file mode 100644 index 0000000..fa9eb73 --- /dev/null +++ b/testing_utils/build_tools.py @@ -0,0 +1,385 @@ +""" +Utilities for interacting with build systems. +""" + +__all__ = ["BuildTools", "CargoTools", "BazelTools"] + +import json +from abc import ABC, abstractmethod +from pathlib import Path +from subprocess import PIPE, Popen, TimeoutExpired +from typing import Any + +import pytest + +# region common + + +class BuildTools(ABC): + """ + Base class for build system interactions. + """ + + def __init__(self, command_timeout: float = 10.0, build_timeout: float = 180.0) -> None: + """ + Create tools instance. + + Parameters + ---------- + command_timeout : float + Common command timeout in seconds. + build_timeout : float + Build command timeout in seconds. + """ + self._command_timeout = command_timeout + self._build_timeout = build_timeout + + @property + def command_timeout(self) -> float: + """ + Common command timeout in seconds. + """ + return self._command_timeout + + @command_timeout.setter + def command_timeout(self, command_timeout: float) -> None: + self._command_timeout = command_timeout + + @property + def build_timeout(self) -> float: + """ + Build command timeout in seconds. + """ + return self._build_timeout + + @build_timeout.setter + def build_timeout(self, build_timeout: float) -> None: + self._build_timeout = build_timeout + + @abstractmethod + def find_target_path(self, target_name: str, *, expect_exists: bool) -> Path: + """ + Find path to executable. + + Parameters + ---------- + target_name : str + Target name. + expect_exists : bool + Check that executable exists. + """ + + @abstractmethod + def select_target_path(self, config: pytest.Config, *, expect_exists: bool) -> Path: + """ + Select executable path based on implementation specific options. + + Parameters + ---------- + config : Config + Pytest config object. + expect_exists : bool + Check that executable exists. + """ + + @abstractmethod + def build(self, target_name: str) -> Path: + """ + Run build for selected target. + Returns path to built executable. + + Parameters + ---------- + target_name : str + Name of the target to build. + """ + + +# endregion + +# region cargo tools + + +class CargoTools(BuildTools): + """ + Utilities for interacting with Cargo. + """ + + def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build_timeout: float = 180.0) -> None: + """ + Create Cargo tools instance. + + Parameters + ---------- + option_prefix : str + Prefix for options expected by 'select_target_path'. + - '' will expect '--target-path' and '--target-name'. + - 'rust' will expect '--rust-target-path' and '--rust-target-name'. + command_timeout : float + Common command timeout in seconds. + build_timeout : float + "cargo build" timeout in seconds. + """ + super().__init__(command_timeout, build_timeout) + if option_prefix: + self._target_path_flag = f"--{option_prefix}-target-path" + self._target_name_flag = f"--{option_prefix}-target-name" + else: + self._target_path_flag = "--target-path" + self._target_name_flag = "--target-name" + + def metadata(self) -> dict[str, Any]: + """ + Read Cargo metadata and return as dict. + CWD must be inside Cargo project. + """ + # Run command. + command = ["cargo", "metadata", "--format-version", "1"] + with Popen(command, stdout=PIPE, text=True) as p: + stdout, _ = p.communicate(timeout=self.command_timeout) + if p.returncode != 0: + raise RuntimeError(f"Failed to read Cargo metadata, returncode: {p.returncode}") + + # Load stdout as JSON data. + return json.loads(stdout) + + def find_target_path(self, target_name: str, *, expect_exists: bool = True) -> Path: + """ + Find path to executable. + Target directory is taken from Cargo metadata. + "debug" configuration is used. + + Parameters + ---------- + target_name : str + Target name. + expect_exists : bool + Check that executable exists. + """ + # Read metadata. + metadata = self.metadata() + + # Read target directory. + target_directory = Path(metadata["target_directory"]).resolve() + + # Check expected file exists. + target_path = target_directory / "debug" / target_name + if expect_exists and not target_path.exists(): + raise RuntimeError(f"Executable not found: {target_path}") + + return target_path + + def select_target_path(self, config: pytest.Config, *, expect_exists: bool) -> Path: + """ + Select executable path based on "--target-path" and "--target-name" options. + Execution order is following: + - if "--target-path" is set - use it + - if "--target-path" not set - search for an executable named "--target-name" + - if "--target-name" not set - error + + Parameters + ---------- + config : Config + Pytest config object. + expect_exists : bool + Check that executable exists. + """ + # Types are ignored due to 'default' being incorrectly set to 'notset' type. + if option_target_path := config.getoption(self._target_path_flag, default=None): # type: ignore + # Check path is valid. + if not isinstance(option_target_path, Path): + raise pytest.UsageError(f"Invalid executable path type: {type(option_target_path)}") + if expect_exists and not option_target_path.is_file(): + raise pytest.UsageError(f"Invalid executable path: {option_target_path}") + + return option_target_path + + if option_target_name := config.getoption(self._target_name_flag, default=None): # type: ignore + # Check name type is valid. + if not isinstance(option_target_name, str): + raise pytest.UsageError(f"Invalid executable name type: {type(option_target_name)}") + # Find path, rethrow as 'UsageError' on errors. + # Timeouts are rethrown as 'TimeoutExpired'. + try: + return self.find_target_path(option_target_name, expect_exists=expect_exists) + except TimeoutExpired as e: + raise e + except Exception as e: + raise pytest.UsageError from e + + raise pytest.UsageError(f'Either "{self._target_path_flag}" or "{self._target_name_flag}" must be set') + + def build(self, target_name: str) -> Path: + """ + Run build for selected target. + Manifest path is taken from Cargo metadata. + "debug" configuration is built. + + Parameters + ---------- + target_name : str + Name of the target to build. + """ + # Read metadata. + metadata = self.metadata() + + # Read manifest path from metadata. + pkg_entries = list(filter(lambda x: x["name"] == target_name, metadata["packages"])) + if len(pkg_entries) < 1: + raise RuntimeError(f"No data found for {target_name}") + if len(pkg_entries) > 1: + raise RuntimeError(f"Multiple data found for {target_name}") + pkg_entry = pkg_entries[0] + + manifest_path = Path(pkg_entry["manifest_path"]).resolve() + + # Run build. + command = ["cargo", "build", "--manifest-path", manifest_path] + with Popen(command, text=True) as p: + _, _ = p.communicate(timeout=self.build_timeout) + if p.returncode != 0: + raise RuntimeError(f"Failed to run build, returncode: {p.returncode}") + + return self.find_target_path(target_name, expect_exists=True) + + +# endregion + +# region bazel tools + + +class BazelTools(BuildTools): + """ + Utilities for interacting with Bazel. + """ + + def __init__(self, option_prefix: str = "", command_timeout: float = 10.0, build_timeout: float = 180.0) -> None: + """ + Create Bazel tools instance. + + Parameters + ---------- + option_prefix : str + Prefix for options expected by 'select_target_path'. + - '' will expect '--target-name'. + - 'cpp' will expect and '--cpp-target-name'. + command_timeout : float + Common command timeout in seconds. + build_timeout : float + "bazel build" timeout in seconds. + """ + super().__init__(command_timeout, build_timeout) + if option_prefix: + self._target_name_flag = f"--{option_prefix}-target-name" + else: + self._target_name_flag = "--target-name" + + def query(self, query: str = "//...") -> list[str]: + """ + Run query and return list of targets. + CWD must be inside Bazel project. + + Parameters + ---------- + query : str + Query to run. + """ + # Run command. + command = ["bazel", "query", query] + with Popen(command, stdout=PIPE, text=True) as p: + stdout, _ = p.communicate(timeout=self.command_timeout) + if p.returncode != 0: + raise RuntimeError(f"Failed to query Bazel, returncode: {p.returncode}") + + # Load stdout as list of strings. + return stdout.strip().split("\n") + + def find_target_path(self, target_name: str, *, expect_exists: bool = True) -> Path: + """ + Find path to executable. + Target directory is taken from Cargo metadata. + "debug" configuration is used. + + Parameters + ---------- + target_name : str + Target name. + expect_exists : bool + Check that executable exists. + """ + # Find workspace root. + ws_root_cmd = ["bazel", "info", "workspace"] + with Popen(ws_root_cmd, stdout=PIPE, text=True) as p: + ws_str, _ = p.communicate(timeout=self.command_timeout) + ws_str = ws_str.strip() + if p.returncode != 0: + raise RuntimeError(f"Failed to determine workspace root, returncode: {p.returncode}") + ws_path = Path(ws_str) + + # Find executable path relative to workspace root path. + command = [ + "bazel", + "cquery", + "--output=starlark", + "--starlark:expr=target.files_to_run.executable.path", + target_name, + ] + with Popen(command, stdout=PIPE, text=True) as p: + target_str, _ = p.communicate(timeout=self.command_timeout) + target_str = target_str.strip() + if p.returncode != 0: + raise RuntimeError(f"Failed to cquery Bazel, returncode: {p.returncode}") + + # Check expected file exists. + target_path = ws_path / target_str + if expect_exists and not target_path.exists(): + raise RuntimeError(f"Executable not found: {target_path}") + + return target_path + + def select_target_path(self, config: pytest.Config, *, expect_exists: bool) -> Path: + """ + Select executable path based on "--target-name" option. + + Parameters + ---------- + config : Config + Pytest config object. + expect_exists : bool + Check that executable exists. + """ + if option_target_name := config.getoption(self._target_name_flag, default=None): # type: ignore + # Check name type is valid. + if not isinstance(option_target_name, str): + raise pytest.UsageError(f"Invalid executable name type: {type(option_target_name)}") + # Find path, rethrow as 'UsageError' on errors. + # Timeouts are rethrown as 'TimeoutExpired'. + try: + return self.find_target_path(option_target_name, expect_exists=expect_exists) + except TimeoutExpired as e: + raise e + except Exception as e: + raise pytest.UsageError from e + + raise pytest.UsageError(f'"{self._target_name_flag}" must be set') + + def build(self, target_name: str) -> Path: + """ + Run build for selected target. + + Parameters + ---------- + target_name : str + Name of the target to build. + """ + # Run build. + command = ["bazel", "build", target_name] + with Popen(command, text=True) as p: + _, _ = p.communicate(timeout=self.build_timeout) + if p.returncode != 0: + raise RuntimeError(f"Failed to run build, returncode: {p.returncode}") + + return self.find_target_path(target_name, expect_exists=True) + + +# endregion diff --git a/testing_utils/cargo_tools.py b/testing_utils/cargo_tools.py deleted file mode 100644 index 5fa95de..0000000 --- a/testing_utils/cargo_tools.py +++ /dev/null @@ -1,148 +0,0 @@ -""" -Utilities for interacting with Cargo. -""" - -__all__ = ["cargo_metadata", "find_bin_path", "select_bin_path", "cargo_build"] - -import json -from pathlib import Path -from subprocess import PIPE, Popen, TimeoutExpired -from typing import Any - -from pytest import Config, UsageError - - -def cargo_metadata(metadata_timeout: float = 10.0) -> dict[str, Any]: - """ - Read Cargo metadata and return as dict. - CWD must be inside Cargo project. - - Parameters - ---------- - metadata_timeout : float - "cargo metadata" timeout in seconds. - """ - # Run command. - command = ["cargo", "metadata", "--format-version", "1"] - with Popen(command, stdout=PIPE, text=True) as p: - stdout, _ = p.communicate(timeout=metadata_timeout) - if p.returncode != 0: - raise RuntimeError(f"Failed to read Cargo metadata, returncode: {p.returncode}") - - # Load stdout as JSON data. - return json.loads(stdout) - - -def find_bin_path(bin_name: str, expect_exists: bool = True, metadata_timeout: float = 10.0) -> Path: - """ - Find path to executable. - Target directory is taken from Cargo metadata. - "debug" configuration is used. - - Returns path to executable. - - Parameters - ---------- - bin_name : str - Executable name. - expect_exists : bool - Check that executable exists. - metadata_timeout : float - "cargo metadata" timeout in seconds. - """ - # Read metadata. - metadata = cargo_metadata(metadata_timeout=metadata_timeout) - - # Read target directory. - target_directory = Path(metadata["target_directory"]).resolve() - - # Check expected file exists. - bin_path = target_directory / "debug" / bin_name - if expect_exists and not bin_path.exists(): - raise RuntimeError(f"Executable not found: {bin_path}") - - return bin_path - - -def select_bin_path(config: Config, expect_exists: bool = True, metadata_timeout: float = 10.0) -> Path: - """ - Select executable path based on "--bin-path" and "--bin-name" options. - Execution order is following: - - if "--bin-path" is set - use it - - if "--bin-path" not set - search for an executable named "--bin-name" - - if "--bin-name" not set - error - - Parameters - ---------- - config : Config - Pytest config object. - expect_exists : bool - Check that executable exists. - metadata_timeout : float - "cargo metadata" timeout in seconds. - Used only when "--bin-name" is set. - """ - # Types are ignored due to 'default' being incorrectly set to 'notset' type. - if option_bin_path := config.getoption("--bin-path", default=None): # type: ignore - # Check path is valid. - if not isinstance(option_bin_path, Path): - raise UsageError(f"Invalid executable path type: {type(option_bin_path)}") - if expect_exists and not option_bin_path.is_file(): - raise UsageError(f"Invalid executable path: {option_bin_path}") - - return option_bin_path - - if option_bin_name := config.getoption("--bin-name", default=None): # type: ignore - # Check name type is valid. - if not isinstance(option_bin_name, str): - raise UsageError(f"Invalid executable name type: {type(option_bin_name)}") - # Find path, rethrow as 'UsageError' on errors. - # Timeouts are rethrowed as 'TimeoutExpired'. - try: - return find_bin_path(option_bin_name, expect_exists, metadata_timeout) - except TimeoutExpired as e: - raise e - except Exception as e: - raise UsageError from e - - raise UsageError('Either "--bin-path" or "--bin-name" must be set') - - -def cargo_build(bin_name: str, metadata_timeout: float = 10.0, build_timeout: float = 180.0) -> Path: - """ - Run build. - Manifest path is taken from Cargo metadata. - "debug" configuration" is built. - - Returns path to executable. - - Parameters - ---------- - bin_name : str - Executable name. - metadata_timeout : float - "cargo metadata" timeout in seconds. - build_timeout : float - "cargo build" timeout in seconds. - """ - # Read metadata. - metadata = cargo_metadata(metadata_timeout) - - # Read manifest path from metadata. - pkg_entries = list(filter(lambda x: x["name"] == bin_name, metadata["packages"])) - if len(pkg_entries) < 1: - raise RuntimeError(f"No data found for {bin_name}") - if len(pkg_entries) > 1: - raise RuntimeError(f"Multiple data found for {bin_name}") - pkg_entry = pkg_entries[0] - - manifest_path = Path(pkg_entry["manifest_path"]).resolve() - - # Run build. - command = ["cargo", "build", "--manifest-path", manifest_path] - with Popen(command, text=True) as p: - _, _ = p.communicate(timeout=build_timeout) - if p.returncode != 0: - raise RuntimeError(f"Failed to run build, returncode: {p.returncode}") - - return find_bin_path(bin_name, metadata_timeout) diff --git a/testing_utils/log_container.py b/testing_utils/log_container.py index 083e2c5..4515ce4 100644 --- a/testing_utils/log_container.py +++ b/testing_utils/log_container.py @@ -48,7 +48,7 @@ def __next__(self): result = self._logs[self._index] self._index += 1 return result - raise StopIteration() + raise StopIteration def __len__(self): """ @@ -62,7 +62,7 @@ def __getitem__(self, subscript): """ return self._logs[subscript] - def _logs_by_field_regex_match(self, field: str, reverse: bool, pattern: str) -> list[ResultEntry]: + def _logs_by_field_regex_match(self, field: str, pattern: str, *, reverse: bool) -> list[ResultEntry]: """ Filter logs using regex matching. Underlying field value is casted to str. @@ -71,10 +71,10 @@ def _logs_by_field_regex_match(self, field: str, reverse: bool, pattern: str) -> ---------- field : str Name of the field to match. - reverse : bool - Return logs not matched. pattern : str | _NotSet Regex pattern to match. + reverse : bool + Return logs not matched. """ if not isinstance(pattern, str): raise TypeError("Pattern must be a string") @@ -95,7 +95,7 @@ def _logs_by_field_regex_match(self, field: str, reverse: bool, pattern: str) -> logs.append(log) return logs - def _logs_by_field_exact_match(self, field: str, reverse: bool, value: Any) -> list[ResultEntry]: + def _logs_by_field_exact_match(self, field: str, value: Any, *, reverse: bool) -> list[ResultEntry]: """ Filter logs using exact matching. @@ -103,10 +103,10 @@ def _logs_by_field_exact_match(self, field: str, reverse: bool, value: Any) -> l ---------- field : str Name of the field to match. - reverse : bool - Return logs not matched. value : Any Exact value to match. + reverse : bool + Return logs not matched. """ logs = [] for log in self._logs: @@ -124,7 +124,12 @@ def _logs_by_field_exact_match(self, field: str, reverse: bool, value: Any) -> l return logs def _logs_by_field( - self, field: str, reverse: bool, *, pattern: str | _NotSet = _not_set, value: Any | _NotSet = _not_set + self, + field: str, + *, + reverse: bool, + pattern: str | _NotSet = _not_set, + value: Any | _NotSet = _not_set, ) -> list[ResultEntry]: """ Select filtration method and filter logs. @@ -147,13 +152,12 @@ def _logs_by_field( value_set = not isinstance(value, _NotSet) if pattern_set and not value_set: - return self._logs_by_field_regex_match(field, reverse, pattern) - elif not pattern_set and value_set: - return self._logs_by_field_exact_match(field, reverse, value) - elif pattern_set and value_set: + return self._logs_by_field_regex_match(field, pattern, reverse=reverse) + if not pattern_set and value_set: + return self._logs_by_field_exact_match(field, value, reverse=reverse) + if pattern_set and value_set: raise RuntimeError("Pattern and value parameters are mutually exclusive") - else: - raise RuntimeError("Either pattern or value parameters must be set") + raise RuntimeError("Either pattern or value parameters must be set") def contains_log(self, field: str, *, pattern: str | _NotSet = _not_set, value: Any | _NotSet = _not_set) -> bool: """ @@ -174,7 +178,11 @@ def contains_log(self, field: str, *, pattern: str | _NotSet = _not_set, value: return len(self._logs_by_field(field, reverse=False, pattern=pattern, value=value)) > 0 def get_logs_by_field( - self, field: str, *, pattern: str | _NotSet = _not_set, value: Any | _NotSet = _not_set + self, + field: str, + *, + pattern: str | _NotSet = _not_set, + value: Any | _NotSet = _not_set, ) -> "LogContainer": """ Get all logs matching the given field and pattern or value. @@ -194,7 +202,11 @@ def get_logs_by_field( return LogContainer(self._logs_by_field(field, reverse=False, pattern=pattern, value=value)) def find_log( - self, field: str, *, pattern: str | _NotSet = _not_set, value: Any | _NotSet = _not_set + self, + field: str, + *, + pattern: str | _NotSet = _not_set, + value: Any | _NotSet = _not_set, ) -> ResultEntry | None: """ Find a log that matches the given field and pattern or value. @@ -244,7 +256,11 @@ def get_logs(self) -> list[ResultEntry]: return self._logs[:] def remove_logs( - self, field: str, *, pattern: str | _NotSet = _not_set, value: Any | _NotSet = _not_set + self, + field: str, + *, + pattern: str | _NotSet = _not_set, + value: Any | _NotSet = _not_set, ) -> "LogContainer": """ Remove all logs matching the given field and pattern or value. diff --git a/testing_utils/scenario.py b/testing_utils/scenario.py index 97ab35f..ec08020 100644 --- a/testing_utils/scenario.py +++ b/testing_utils/scenario.py @@ -13,9 +13,8 @@ from typing import Any import pytest -from pytest import FixtureRequest -from .cargo_tools import select_bin_path +from .build_tools import BuildTools from .log_container import LogContainer from .result_entry import ResultEntry @@ -46,6 +45,13 @@ class Scenario(ABC): Base test scenario definition. """ + @pytest.fixture(scope="class") + @abstractmethod + def build_tools(self, *args, **kwargs) -> BuildTools: + """ + Build tools used to handle test scenario. + """ + @pytest.fixture(scope="class") @abstractmethod def scenario_name(self, *args, **kwargs) -> str: @@ -61,7 +67,7 @@ def test_config(self, *args, **kwargs) -> dict[str, Any]: """ @pytest.fixture(scope="class") - def execution_timeout(self, request: FixtureRequest, *args, **kwargs) -> float: + def execution_timeout(self, request: pytest.FixtureRequest, *args, **kwargs) -> float: """ Test execution timeout in seconds. @@ -82,7 +88,7 @@ def capture_stderr(self, *args, **kwargs) -> bool: return False @pytest.fixture(scope="class") - def bin_path(self, request: FixtureRequest) -> Path: + def target_path(self, build_tools: BuildTools, request: pytest.FixtureRequest) -> Path: """ Return path to test scenario executable. @@ -91,12 +97,12 @@ def bin_path(self, request: FixtureRequest) -> Path: request : FixtureRequest Test request built-in fixture. """ - return select_bin_path(request.config) + return build_tools.select_target_path(request.config, expect_exists=True) @pytest.fixture(scope="class") def results( self, - bin_path: Path | str, + target_path: Path | str, scenario_name: str, test_config: dict[str, Any], execution_timeout: float, @@ -108,7 +114,7 @@ def results( Parameters ---------- - bin_path : Path | str + target_path : Path | str Path to test scenarios executable. scenario_name : str Name of a test scenario to run. @@ -123,11 +129,11 @@ def results( # Run scenario. hang = False - command = [bin_path, "--name", scenario_name] + command = [target_path, "--name", scenario_name, "--input", test_config_str] stderr_param = PIPE if self.capture_stderr() else None - with Popen(command, stdout=PIPE, stdin=PIPE, stderr=stderr_param, text=True) as p: + with Popen(command, stdout=PIPE, stderr=stderr_param, text=True) as p: try: - stdout, stderr = p.communicate(test_config_str, execution_timeout) + stdout, stderr = p.communicate(timeout=execution_timeout) except TimeoutExpired: hang = True p.kill() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_build_tools.py b/tests/test_build_tools.py new file mode 100644 index 0000000..e66ad84 --- /dev/null +++ b/tests/test_build_tools.py @@ -0,0 +1,470 @@ +""" +Tests for "build_tools" module. +""" + +import os +from abc import ABC, abstractmethod +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path +from subprocess import Popen, TimeoutExpired +from textwrap import dedent +from typing import Any + +import pytest + +from testing_utils import BazelTools, BuildTools, CargoTools + + +@pytest.fixture(scope="class") +def class_tmp_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + return tmp_path_factory.mktemp("temp") + + +@contextmanager +def cwd(new_cwd: Path | str) -> Generator[None, None, None]: + """ + Temporarily change working directory. + + Parameters + ---------- + new_cwd : Path + Directory to set as CWD. + """ + prev_cwd = Path.cwd() + os.chdir(new_cwd) + yield + os.chdir(prev_cwd) + + +class Notset: + def __repr__(self): + return "" + + +notset = Notset() + + +class MockConfig: + """ + "Config" object mock. + """ + + def __init__(self, options: dict[str, Any]) -> None: + self._options = options + + def getoption(self, name: str, default: Any = notset) -> Any: + value = self._options.get(name, default) + if value is notset: + raise ValueError(f"{name} is not set") + return value + + +class TestBuildTools(ABC): + """ + Base class containing common test cases for all build systems. + """ + + @pytest.fixture(scope="class") + @abstractmethod + def tools_type(self) -> type[BuildTools]: + """ + Build tools to test. + """ + + @pytest.fixture(scope="class") + @abstractmethod + def tmp_project(self, class_tmp_path: Path) -> tuple[str, Path]: + """ + Create temporary binary project. + Returns target name and path to project directory. + + Parameters + ---------- + class_tmp_path : Path + Temporary directory path. + """ + + @pytest.fixture(scope="class") + def built_tmp_project(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> tuple[str, Path]: + """ + Create and build temporary binary project. + Returns target name and path to project directory. + + Parameters + ---------- + tmp_project : Path + Path to temporary project directory. + """ + target_name, path = tmp_project + with cwd(path): + tools = tools_type() + _ = tools.build(target_name) + return tmp_project + + @pytest.fixture(scope="class") + @abstractmethod + def expected_target_path(self, tmp_project: tuple[str, Path]) -> Path: + """ + Expected target path. + + Parameters + ---------- + project_path : Path + Path to temporary project directory. + target_name : str + Target name. + """ + + class TestBuild: + def test_build_ok(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> None: + target_name, path = tmp_project + with cwd(path): + tools = tools_type() + target_path = tools.build(target_name) + + # Check executable exists. + assert target_path.exists() + + def test_metadata_timeout(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> None: + target_name, path = tmp_project + with cwd(path): + command_timeout = 0.00000001 + tools = tools_type(command_timeout=command_timeout) + with pytest.raises(TimeoutExpired): + _ = tools.build(target_name) + + def test_build_timeout(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> None: + target_name, path = tmp_project + with cwd(path): + build_timeout = 0.00000001 + tools = tools_type(build_timeout=build_timeout) + with pytest.raises(TimeoutExpired): + _ = tools.build(target_name) + + def test_invalid_target_name(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> None: + _, path = tmp_project + with cwd(path): + invalid_target_name = "xyz" + tools = tools_type() + with pytest.raises(RuntimeError): + _ = tools.build(invalid_target_name) + + def test_invalid_cwd(self, tools_type: type[BuildTools], tmp_project: tuple[str, Path]) -> None: + target_name, _ = tmp_project + invalid_project_path = "/tmp" + with cwd(invalid_project_path): + tools = tools_type() + with pytest.raises(RuntimeError): + _ = tools.build(target_name) + + class TestFindBinPath: + def test_ok( + self, + tools_type: type[BuildTools], + built_tmp_project: tuple[str, Path], + expected_target_path: Path, + ) -> None: + target_name, path = built_tmp_project + with cwd(path): + tools = tools_type() + act_target_path = tools.find_target_path(target_name, expect_exists=True) + + # Check returned path is as expected. + assert act_target_path == expected_target_path + + def test_timeout(self, tools_type: type[BuildTools], built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + command_timeout = 0.00000001 + tools = tools_type(command_timeout=command_timeout) + with pytest.raises(TimeoutExpired): + _ = tools.find_target_path(target_name, expect_exists=True) + + def test_invalid_target_name(self, tools_type: type[BuildTools], built_tmp_project: tuple[str, Path]) -> None: + _, path = built_tmp_project + with cwd(path): + invalid_target_name = "invalid_target_name" + tools = tools_type() + with pytest.raises(RuntimeError): + _ = tools.find_target_path(invalid_target_name, expect_exists=True) + + def test_invalid_cwd(self, tools_type: type[BuildTools], built_tmp_project: tuple[str, Path]) -> None: + target_name, _ = built_tmp_project + invalid_project_path = "/tmp" + with cwd(invalid_project_path): + tools = tools_type() + with pytest.raises(RuntimeError): + _ = tools.find_target_path(target_name, expect_exists=True) + + def test_not_expect_exists( + self, + tools_type: type[BuildTools], + tmp_project: tuple[str, Path], + expected_target_path: Path, + ) -> None: + target_name, path = tmp_project + with cwd(path): + tools = tools_type() + act_target_path = tools.find_target_path(target_name, expect_exists=False) + + # Check returned path is as expected. + assert act_target_path == expected_target_path + + +class TestCargoTools(TestBuildTools): + """ + Test cases for Cargo tools. + """ + + @pytest.fixture(scope="class") + def tools_type(self) -> type[BuildTools]: + return CargoTools + + @pytest.fixture(scope="class") + def tmp_project(self, class_tmp_path: Path) -> tuple[str, Path]: + # Target name and path to project. + target_name = "project" + project_path = class_tmp_path / target_name + + # Create binary project. + command = ["cargo", "new", "--bin", project_path] + with Popen(command, text=True) as p: + _, _ = p.communicate(timeout=30.0) + if p.returncode != 0: + raise RuntimeError("Failed to create temporary binary project") + + return (target_name, project_path) + + @pytest.fixture(scope="class") + def expected_target_path(self, tmp_project: tuple[str, Path]) -> Path: + target_name, project_path = tmp_project + return project_path / "target" / "debug" / target_name + + class TestMetadata: + def test_ok(self, tmp_project: tuple[str, Path]) -> None: + _, path = tmp_project + with cwd(path): + tools = CargoTools() + metadata = tools.metadata() + + # Check on "target_directory" if valid. + act_target_dir = Path(metadata["target_directory"]) + exp_target_dir = path / "target" + assert act_target_dir == exp_target_dir + + def test_timeout(self, tmp_project: tuple[str, Path]) -> None: + _, path = tmp_project + with cwd(path): + timeout = 0.00000001 + tools = CargoTools(command_timeout=timeout) + with pytest.raises(TimeoutExpired): + _ = tools.metadata() + + class TestSelectBinPath: + def test_target_path_set_ok(self, built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Find executable path. + exp_target_path = tools.find_target_path(target_name, expect_exists=True) + + # Create mock. + cfg = MockConfig({"--target-path": exp_target_path}) + + # Run. + act_target_path = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + # Check returned path is as expected. + assert act_target_path == exp_target_path + + def test_target_path_set_invalid_type(self, built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Find executable path. + exp_target_path = tools.find_target_path(target_name, expect_exists=True) + + # Create mock. + cfg = MockConfig({"--target-path": str(exp_target_path)}) + + # Run. + with pytest.raises(pytest.UsageError): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + def test_target_path_set_invalid_value(self, built_tmp_project: tuple[str, Path]) -> None: + _, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Create mock. + invalid_target_path = Path("/invalid/path") + cfg = MockConfig({"--target-path": invalid_target_path}) + + # Run. + with pytest.raises(pytest.UsageError): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + def test_target_path_not_expect_exists(self, tmp_project: tuple[str, Path]) -> None: + target_name, path = tmp_project + with cwd(path): + tools = CargoTools() + # Find executable path. + exp_target_path = tools.find_target_path(target_name, expect_exists=False) + + # Create mock. + cfg = MockConfig({"--target-path": exp_target_path}) + + # Run. + act_target_path = tools.select_target_path(cfg, expect_exists=False) # type: ignore + + # Check returned path is as expected. + assert act_target_path == exp_target_path + + def test_target_name_set_ok(self, built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Find executable path. + exp_target_path = tools.find_target_path(target_name, expect_exists=True) + + # Create mock. + cfg = MockConfig({"--target-name": target_name}) + + # Run. + act_target_path = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + # Check returned path is as expected. + assert act_target_path == exp_target_path + + def test_target_name_set_invalid_type(self, built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Create mock. + cfg = MockConfig({"--target-name": Path(target_name)}) + + # Run. + with pytest.raises(pytest.UsageError): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + def test_target_name_set_invalid_value(self, built_tmp_project: tuple[str, Path]) -> None: + _, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Create mock. + invalid_target_name = "invalid_target_name" + cfg = MockConfig({"--target-name": invalid_target_name}) + + # Run. + with pytest.raises(pytest.UsageError): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + def test_target_name_not_expect_exists(self, tmp_project: tuple[str, Path]) -> None: + target_name, path = tmp_project + with cwd(path): + tools = CargoTools() + # Find executable path. + exp_target_path = tools.find_target_path(target_name, expect_exists=False) + + # Create mock. + cfg = MockConfig({"--target-name": target_name}) + + # Run. + act_target_path = tools.select_target_path(cfg, expect_exists=False) # type: ignore + + # Check returned path is as expected. + assert act_target_path == exp_target_path + + def test_target_name_timeout(self, built_tmp_project: tuple[str, Path]) -> None: + target_name, path = built_tmp_project + with cwd(path): + command_timeout = 0.00000001 + tools = CargoTools(command_timeout=command_timeout) + # Create mock. + cfg = MockConfig({"--target-name": target_name}) + + # Run. + with pytest.raises(TimeoutExpired): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + def test_params_unset(self, built_tmp_project: tuple[str, Path]) -> None: + _, path = built_tmp_project + with cwd(path): + tools = CargoTools() + # Create mock. + cfg = MockConfig({}) + + # Run. + with pytest.raises(pytest.UsageError): + _ = tools.select_target_path(cfg, expect_exists=True) # type: ignore + + +class TestBazelTools(TestBuildTools): + """ + Test cases for Bazel tools. + """ + + @pytest.fixture(scope="class") + def tools_type(self) -> type[BuildTools]: + return BazelTools + + @pytest.fixture(scope="class") + def tmp_project(self, class_tmp_path: Path) -> tuple[str, Path]: + """ + Create temporary binary project using Bazel. + Returns target name and path to project directory. + + Parameters + ---------- + class_tmp_path : Path + Temporary directory path. + """ + + def _write_to_file(path: Path, content: str) -> None: + with open(path, mode="w", encoding="UTF-8") as file: + file.write(content) + + # Target name and path to project. + target_name = "project" + project_path = class_tmp_path / target_name + project_path.mkdir() + + # Create required files. + build_path = project_path / "BUILD" + build_content = dedent(""" + load("@rules_cc//cc:cc_binary.bzl", "cc_binary") + + cc_binary( + name = "project", + srcs = ["main.cpp"], + ) + + """) + _write_to_file(build_path, build_content) + + main_cpp_path = project_path / "main.cpp" + main_cpp_content = dedent(""" + #include + + int main() { std::cout << "Hello, world!" << std::endl; } + + """) + _write_to_file(main_cpp_path, main_cpp_content) + + module_path = project_path / "MODULE.bazel" + module_content = dedent(""" + module(name = "project") + bazel_dep(name = "rules_cc", version = "0.1.1") + + """) + _write_to_file(module_path, module_content) + + return (target_name, project_path) + + @pytest.fixture(scope="class") + def expected_target_path(self, tmp_project: tuple[str, Path]) -> Path: + target_name, project_path = tmp_project + return project_path / "bazel-out" / "k8-fastbuild" / "bin" / target_name + + # ruff: noqa: FIX002 + # TODO: add query tests. diff --git a/tests/test_cargo_tools.py b/tests/test_cargo_tools.py deleted file mode 100644 index f18065d..0000000 --- a/tests/test_cargo_tools.py +++ /dev/null @@ -1,342 +0,0 @@ -""" -Tests for "cargo_tools" module. -""" - -import os -from contextlib import contextmanager -from pathlib import Path -from subprocess import Popen, TimeoutExpired -from typing import Any, Generator - -from pytest import UsageError, fixture, raises - -from testing_utils import cargo_tools - - -@contextmanager -def cwd(new_cwd: Path | str) -> Generator[None, None, None]: - """ - Temporarily change working directory. - - Parameters - ---------- - new_cwd : Path - Directory to set as CWD. - """ - prev_cwd = os.getcwd() - os.chdir(new_cwd) - yield - os.chdir(prev_cwd) - - -@fixture -def tmp_project(tmp_path: Path) -> tuple[str, Path]: - """ - Create temporary binary project using Cargo. - Returns binary name and path to project directory. - - Parameters - ---------- - tmp_path : Path - Temporary directory path. - """ - # Binary name and path to project. - bin_name = "project" - project_path = tmp_path / bin_name - - # Create binary project. - command = ["cargo", "new", "--bin", project_path] - with Popen(command, text=True) as p: - _, _ = p.communicate(timeout=30.0) - if p.returncode != 0: - raise RuntimeError("Failed to create temporary binary project") - - return (bin_name, project_path) - - -@fixture -def built_tmp_project(tmp_project: tuple[str, Path]) -> tuple[str, Path]: - """ - Create and build temporary binary project using Cargo. - Returns binary name and path to project directory. - - Parameters - ---------- - tmp_project : Path - Path to temporary project directory. - """ - bin_name, path = tmp_project - with cwd(path): - _ = cargo_tools.cargo_build(bin_name) - return tmp_project - - -# region cargo_metadata tests - - -def test_cargo_metadata_ok(tmp_project: tuple[str, Path]) -> None: - _, path = tmp_project - with cwd(path): - metadata = cargo_tools.cargo_metadata() - - # Check on "target_directory" if valid. - act_target_dir = Path(metadata["target_directory"]) - exp_target_dir = path / "target" - assert act_target_dir == exp_target_dir - - -def test_cargo_metadata_timeout(tmp_project: tuple[str, Path]) -> None: - _, path = tmp_project - with cwd(path), raises(TimeoutExpired): - timeout = 0.00000001 - _ = cargo_tools.cargo_metadata(timeout) - - -# endregion - -# region cargo_build tests - - -def test_cargo_build_ok(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path): - bin_path = cargo_tools.cargo_build(bin_name) - - # Check executable exists. - assert bin_path.exists() - - -def test_cargo_build_metadata_timeout(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path), raises(TimeoutExpired): - metadata_timeout = 0.00000001 - _ = cargo_tools.cargo_build(bin_name, metadata_timeout=metadata_timeout) - - -def test_cargo_build_build_timeout(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path), raises(TimeoutExpired): - build_timeout = 0.00000001 - _ = cargo_tools.cargo_build(bin_name, build_timeout=build_timeout) - - -def test_cargo_build_invalid_bin_name(tmp_project: tuple[str, Path]) -> None: - _, path = tmp_project - with cwd(path), raises(RuntimeError): - invalid_bin_name = "xyz" - _ = cargo_tools.cargo_build(invalid_bin_name) - - -def test_cargo_build_invalid_cwd(tmp_project: tuple[str, Path]) -> None: - bin_name, _ = tmp_project - invalid_project_path = "/tmp" - with cwd(invalid_project_path), raises(RuntimeError): - _ = cargo_tools.cargo_build(bin_name) - - -# endregion - - -# region find_bin_path tests - - -def test_find_bin_path_ok(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path): - act_bin_path = cargo_tools.find_bin_path(bin_name) - - # Check returned path is as expected. - exp_bin_path = path / "target" / "debug" / bin_name - assert act_bin_path == exp_bin_path - - -def test_find_bin_path_timeout(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path), raises(TimeoutExpired): - metadata_timeout = 0.00000001 - _ = cargo_tools.find_bin_path(bin_name, metadata_timeout=metadata_timeout) - - -def test_find_bin_path_invalid_bin_name(built_tmp_project: tuple[str, Path]) -> None: - _, path = built_tmp_project - with cwd(path), raises(RuntimeError): - invalid_bin_name = "invalid_bin_name" - _ = cargo_tools.find_bin_path(invalid_bin_name) - - -def test_find_bin_path_invalid_cwd(built_tmp_project: tuple[str, Path]) -> None: - bin_name, _ = built_tmp_project - invalid_project_path = "/tmp" - with cwd(invalid_project_path), raises(RuntimeError): - _ = cargo_tools.find_bin_path(bin_name) - - -def test_find_bin_path_not_expect_exists(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path): - act_bin_path = cargo_tools.find_bin_path(bin_name, expect_exists=False) - - # Check returned path is as expected. - exp_bin_path = path / "target" / "debug" / bin_name - assert act_bin_path == exp_bin_path - - -# endregion - -# region select_bin_path tests - - -class Notset: - def __repr__(self): - return "" - - -notset = Notset() - - -class MockConfig: - """ - "Config" object mock. - """ - - def __init__(self, options: dict[str, Any]) -> None: - self._options = options - - def getoption(self, name: str, default: Any = notset) -> Any: - value = self._options.get(name, default) - if value is notset: - raise ValueError() - return value - - -def test_select_bin_path_bin_path_set_ok(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path): - # Find executable path. - exp_bin_path = cargo_tools.find_bin_path(bin_name) - - # Create mock. - cfg = MockConfig({"--bin-path": exp_bin_path}) - - # Run. - act_bin_path = cargo_tools.select_bin_path(cfg) # type: ignore - - # Check returned path is as expected. - assert act_bin_path == exp_bin_path - - -def test_select_bin_path_bin_path_set_invalid_type(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path), raises(UsageError): - # Find executable path. - exp_bin_path = cargo_tools.find_bin_path(bin_name) - - # Create mock. - cfg = MockConfig({"--bin-path": str(exp_bin_path)}) - - # Run. - _ = cargo_tools.select_bin_path(cfg) # type: ignore - - -def test_select_bin_path_bin_path_set_invalid_value(built_tmp_project: tuple[str, Path]) -> None: - _, path = built_tmp_project - with cwd(path), raises(UsageError): - # Create mock. - invalid_bin_path = Path("/invalid/path") - cfg = MockConfig({"--bin-path": invalid_bin_path}) - - # Run. - _ = cargo_tools.select_bin_path(cfg) # type: ignore - - -def test_select_bin_path_bin_path_not_expect_exists(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path): - # Find executable path. - exp_bin_path = cargo_tools.find_bin_path(bin_name, expect_exists=False) - - # Create mock. - cfg = MockConfig({"--bin-path": exp_bin_path}) - - # Run. - act_bin_path = cargo_tools.select_bin_path(cfg, expect_exists=False) # type: ignore - - # Check returned path is as expected. - assert act_bin_path == exp_bin_path - - -def test_select_bin_path_bin_name_set_ok(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path): - # Find executable path. - exp_bin_path = cargo_tools.find_bin_path(bin_name) - - # Create mock. - cfg = MockConfig({"--bin-name": bin_name}) - - # Run. - act_bin_path = cargo_tools.select_bin_path(cfg) # type: ignore - - # Check returned path is as expected. - assert act_bin_path == exp_bin_path - - -def test_select_bin_path_bin_name_set_invalid_type(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path), raises(UsageError): - # Create mock. - cfg = MockConfig({"--bin-name": Path(bin_name)}) - - # Run. - _ = cargo_tools.select_bin_path(cfg) # type: ignore - - -def test_select_bin_path_bin_name_set_invalid_value(built_tmp_project: tuple[str, Path]) -> None: - _, path = built_tmp_project - with cwd(path), raises(UsageError): - # Create mock. - invalid_bin_name = "invalid_bin_name" - cfg = MockConfig({"--bin-name": invalid_bin_name}) - - # Run. - _ = cargo_tools.select_bin_path(cfg) # type: ignore - - -def test_select_bin_path_bin_name_not_expect_exists(tmp_project: tuple[str, Path]) -> None: - bin_name, path = tmp_project - with cwd(path): - # Find executable path. - exp_bin_path = cargo_tools.find_bin_path(bin_name, expect_exists=False) - - # Create mock. - cfg = MockConfig({"--bin-name": bin_name}) - - # Run. - act_bin_path = cargo_tools.select_bin_path(cfg, expect_exists=False) # type: ignore - - # Check returned path is as expected. - assert act_bin_path == exp_bin_path - - -def test_select_bin_path_bin_name_timeout(built_tmp_project: tuple[str, Path]) -> None: - bin_name, path = built_tmp_project - with cwd(path), raises(TimeoutExpired): - # Create mock. - cfg = MockConfig({"--bin-name": bin_name}) - - # Run. - metadata_timeout = 0.00000001 - _ = cargo_tools.select_bin_path(cfg, metadata_timeout=metadata_timeout) # type: ignore - - -def test_select_bin_path_params_unset(built_tmp_project: tuple[str, Path]) -> None: - _, path = built_tmp_project - with cwd(path), raises(UsageError): - # Create mock. - cfg = MockConfig({}) - - # Run. - _ = cargo_tools.select_bin_path(cfg) # type: ignore - - -# endregion diff --git a/tests/test_log_container.py b/tests/test_log_container.py index c8d1331..8a2d7e3 100644 --- a/tests/test_log_container.py +++ b/tests/test_log_container.py @@ -73,7 +73,7 @@ def test_ok(self, lc_basic: LogContainer): def test_empty(self): lc = LogContainer() for _ in lc: - assert False, "Statement shouldn't be reached" + raise RuntimeError("Statement shouldn't be reached") class TestLen: @@ -129,7 +129,7 @@ def test_pattern_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.contains_log("level", pattern=r"WARN|INFO") @@ -144,7 +144,7 @@ def test_pattern_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) assert lc.contains_log("some_id", pattern=r"^1$") @@ -157,7 +157,7 @@ def test_pattern_cast_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) assert lc.contains_log("some_id", pattern="^0$") assert lc.contains_log("some_id", pattern="0") @@ -169,7 +169,7 @@ def test_pattern_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert not lc.contains_log("invalid", pattern="WARN") @@ -180,7 +180,7 @@ def test_pattern_invalid_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert not lc.contains_log("level", pattern="invalid") @@ -191,7 +191,7 @@ def test_value_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.contains_log("level", value="INFO") @@ -206,7 +206,7 @@ def test_value_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) assert lc.contains_log("some_id", value=1) @@ -219,7 +219,7 @@ def test_value_none_ok(self): ResultEntry({"level": "INFO", "someId": None}), ResultEntry({"level": "WARN", "someId": 2}), ResultEntry({"level": "INFO", "someId": 1}), - ] + ], ) assert lc.contains_log("some_id", value=None) @@ -232,7 +232,7 @@ def test_value_filter_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) assert lc.contains_log("some_id", value="0") assert lc.contains_log("some_id", value=0) @@ -244,7 +244,7 @@ def test_value_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert not lc.contains_log("invalid", value="WARN") @@ -255,7 +255,7 @@ def test_value_invalid_str_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert not lc.contains_log("level", value="invalid") @@ -268,7 +268,7 @@ def test_value_invalid_int_value(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 1}), ResultEntry({"someId": 1}), - ] + ], ) assert not lc.contains_log("some_id", value=10) @@ -295,7 +295,7 @@ def test_pattern_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("level", pattern=r"WARN|INFO") assert len(logs) == 2 @@ -313,7 +313,7 @@ def test_pattern_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) logs = lc.get_logs_by_field("some_id", pattern=r"^1$") assert len(logs) == 3 @@ -328,7 +328,7 @@ def test_pattern_cast_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) logs = lc.get_logs_by_field("some_id", pattern="^0$") assert len(logs) == 2 @@ -349,7 +349,7 @@ def test_pattern_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("invalid", pattern="WARN") assert len(logs) == 0 @@ -361,7 +361,7 @@ def test_pattern_invalid_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("level", pattern="invalid") assert len(logs) == 0 @@ -373,7 +373,7 @@ def test_value_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("level", value="INFO") assert len(logs) == 1 @@ -390,7 +390,7 @@ def test_value_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) logs = lc.get_logs_by_field("some_id", value=1) assert len(logs) == 3 @@ -405,7 +405,7 @@ def test_value_none_ok(self): ResultEntry({"level": "INFO", "someId": None}), ResultEntry({"level": "WARN", "someId": 2}), ResultEntry({"level": "INFO", "someId": 1}), - ] + ], ) logs = lc.get_logs_by_field("some_id", value=None) assert len(logs) == 1 @@ -420,7 +420,7 @@ def test_value_filter_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) logs = lc.get_logs_by_field("some_id", value="0") assert len(logs) == 1 @@ -437,7 +437,7 @@ def test_value_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("invalid", value="WARN") assert len(logs) == 0 @@ -449,7 +449,7 @@ def test_value_invalid_str_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.get_logs_by_field("level", value="invalid") assert len(logs) == 0 @@ -463,7 +463,7 @@ def test_value_invalid_int_value(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 1}), ResultEntry({"someId": 1}), - ] + ], ) logs = lc.get_logs_by_field("some_id", value=10) assert len(logs) == 0 @@ -490,7 +490,7 @@ def test_pattern_str_ok(self): [ ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), - ] + ], ) log = lc.find_log("level", pattern=r"WARN|INFO") assert log @@ -505,7 +505,7 @@ def test_pattern_int_ok(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) log = lc.find_log("some_id", pattern=r"^1$") assert log @@ -518,9 +518,9 @@ def test_pattern_many_found(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Multiple logs found for field='level' and pattern='WARN|INFO'"): _ = lc.find_log("level", pattern=r"WARN|INFO") def test_pattern_cast_type(self): @@ -531,7 +531,7 @@ def test_pattern_cast_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) log = lc.find_log("some_id", pattern="^0$") assert log @@ -544,7 +544,7 @@ def test_pattern_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.find_log("invalid", pattern="WARN") is None @@ -555,7 +555,7 @@ def test_pattern_invalid_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.find_log("level", pattern="invalid") is None @@ -566,7 +566,7 @@ def test_value_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) log = lc.find_log("level", value="INFO") assert log @@ -581,7 +581,7 @@ def test_value_int_ok(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) log = lc.find_log("some_id", value=1) assert log @@ -596,7 +596,7 @@ def test_value_none_ok(self): ResultEntry({"level": "INFO", "someId": None}), ResultEntry({"level": "WARN", "someId": 2}), ResultEntry({"level": "INFO", "someId": 1}), - ] + ], ) log = lc.find_log("some_id", value=None) assert log @@ -611,7 +611,7 @@ def test_value_filter_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) log = lc.find_log("some_id", value="0") assert log @@ -628,7 +628,7 @@ def test_value_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.find_log("invalid", value="WARN") is None @@ -639,7 +639,7 @@ def test_value_invalid_str_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) assert lc.find_log("level", value="invalid") is None @@ -652,7 +652,7 @@ def test_value_invalid_int_value(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 1}), ResultEntry({"someId": 1}), - ] + ], ) assert lc.find_log("some_id", value=10) is None @@ -671,7 +671,7 @@ def _common_entries(self) -> list[ResultEntry]: "fields": {"message": "Debug message"}, "target": "target::DEBUG_message", "threadId": "ThreadId(1)", - } + }, ), ResultEntry( { @@ -679,7 +679,7 @@ def _common_entries(self) -> list[ResultEntry]: "level": "INFO", "target": "target::INFO_message", "threadId": "ThreadId(2)", - } + }, ), ] @@ -772,7 +772,7 @@ def test_pattern_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("level", pattern=r"WARN|INFO") assert len(logs) == 1 @@ -789,7 +789,7 @@ def test_pattern_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) logs = lc.remove_logs("some_id", pattern=r"^1$") assert len(logs) == 4 @@ -807,7 +807,7 @@ def test_pattern_cast_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) logs = lc.remove_logs("some_id", pattern="^0$") assert len(logs) == 3 @@ -822,7 +822,7 @@ def test_pattern_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("invalid", pattern="WARN") assert len(logs) == 3 @@ -834,7 +834,7 @@ def test_pattern_invalid_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("level", pattern="invalid") assert len(logs) == 3 @@ -846,7 +846,7 @@ def test_value_str_ok(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("level", value="INFO") assert len(logs) == 2 @@ -864,7 +864,7 @@ def test_value_int_ok(self): ResultEntry({"someId": 1}), ResultEntry({"someId": 11}), ResultEntry({"someId": 12}), - ] + ], ) logs = lc.remove_logs("some_id", value=1) assert len(logs) == 4 @@ -882,7 +882,7 @@ def test_value_none_ok(self): ResultEntry({"level": "INFO", "someId": None}), ResultEntry({"level": "WARN", "someId": 2}), ResultEntry({"level": "INFO", "someId": 1}), - ] + ], ) logs = lc.remove_logs("some_id", value=None) assert len(logs) == 4 @@ -897,7 +897,7 @@ def test_value_filter_type(self): ResultEntry({"level": "DEBUG", "someId": 6543}), ResultEntry({"level": "DEBUG", "someId": "10"}), ResultEntry({"level": "DEBUG", "someId": 100}), - ] + ], ) logs = lc.remove_logs("some_id", value="0") @@ -921,7 +921,7 @@ def test_value_invalid_field(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("invalid", value="WARN") assert len(logs) == 3 @@ -933,7 +933,7 @@ def test_value_invalid_str_value(self): ResultEntry({"level": "DEBUG"}), ResultEntry({"level": "INFO"}), ResultEntry({"level": "WARN"}), - ] + ], ) logs = lc.remove_logs("level", value="invalid") assert len(logs) == 3 @@ -947,7 +947,7 @@ def test_value_invalid_int_value(self): ResultEntry({"someId": 3}), ResultEntry({"someId": 1}), ResultEntry({"someId": 1}), - ] + ], ) logs = lc.remove_logs("some_id", value=10) assert len(logs) == 5 @@ -968,8 +968,8 @@ def test_ok(self): "fields": {"message": "Info message 1"}, "target": "target::INFO_message", "threadId": "ThreadId(2)", - } - ) + }, + ), ) lc.add_log( ResultEntry( @@ -979,8 +979,8 @@ def test_ok(self): "fields": {"message": "Info message 2"}, "target": "target::INFO_message", "threadId": "ThreadId(1)", - } - ) + }, + ), ) lc.add_log( ResultEntry( @@ -990,8 +990,8 @@ def test_ok(self): "fields": {"message": "Info message 3"}, "target": "target::INFO_message", "threadId": "ThreadId(2)", - } - ) + }, + ), ) groups = lc.group_by("thread_id") assert len(groups) == 2 diff --git a/tests/test_result_entry.py b/tests/test_result_entry.py index 637411c..a82c74e 100644 --- a/tests/test_result_entry.py +++ b/tests/test_result_entry.py @@ -16,7 +16,7 @@ def test_result_entry_creation_and_properties(): "level": "DEBUG", "target": "target::DEBUG_message", "threadId": "ThreadId(1)", - } + }, ) assert entry.timestamp == str(timedelta(microseconds=1)) assert entry.level == "DEBUG" @@ -32,7 +32,7 @@ def test_result_orchestration_creation_and_properties(): "fields": {"message": "Debug message"}, "target": "target::DEBUG_message", "threadId": "ThreadId(1)", - } + }, ) assert entry.timestamp == str(timedelta(microseconds=10)) @@ -50,7 +50,7 @@ def test_result_entry_str(): "fields": {"message": "Debug message"}, "target": "target::DEBUG_message", "threadId": "ThreadId(1)", - } + }, ) str_repr = str(entry) assert "timestamp=0:00:01.0001" in str_repr @@ -66,7 +66,7 @@ def test_result_entry_access_invalid_attribute(): "level": "DEBUG", "target": "target::DEBUG_message", "threadId": "ThreadId(1)", - } + }, ) with pytest.raises(AttributeError): _ = entry.invalid_attribute