diff --git a/feature_integration_tests/README.md b/feature_integration_tests/README.md index 2cb213a4261..a68a1f9c492 100644 --- a/feature_integration_tests/README.md +++ b/feature_integration_tests/README.md @@ -1,6 +1,6 @@ # Feature Integration Tests -This directory contains Feature Integration Tests for the S-CORE project. It includes both Python test cases and Rust test scenarios to validate features work together. +This directory contains Feature Integration Tests for the S-CORE project. It includes Python test cases that orchestrate test scenarios implemented in Rust and C++ to validate that features work together correctly. ## Structure @@ -9,32 +9,48 @@ This directory contains Feature Integration Tests for the S-CORE project. It inc - `fit_scenario.py` — Base scenario class - `requirements.txt` — Python dependencies - `BUILD` — Bazel build and test definitions - - `tests/` — Test cases (e.g., orchestration with persistency) -- `test_scenarios/` — Location of test scenarios - - `rust/` — Rust-based integration test scenarios - - `src/` — Rust source code for test scenarios - - `BUILD` — Bazel build definitions + - `tests/` — Test cases organized by feature area +- `test_scenarios/` — Test scenario implementations + - `rust/` — Rust-based test scenarios + - `cpp/` — C++-based test scenarios +- `itf/` — Integration Test Framework tests (run on QEMU targets) + - `test_showcases.py` — Showcase validation tests + - `test_remote_logging.py` — Remote logging tests + - `test_ssh.py` — SSH connectivity tests +- `configs/` — Configuration files for ITF execution (DLT, QEMU bridge, etc.) ## Running Tests -### Python Test Cases +### Python Test Cases (scenario-based FIT) -Python tests are managed with Bazel and Pytest. To run the main test target: +Python tests are managed with Bazel and Pytest. To run all integration tests: ```sh -bazel test //feature_integration_tests/test_cases:fit +bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit ``` -### Rust Test Scenarios +To run specific test suites: -Rust test scenarios are defined in `test_scenarios/rust/src/scenarios`. Build and run them using Bazel: +```sh +bazel test //feature_integration_tests/test_cases:fit_rust +bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit_cpp +``` + +### ITF Tests (QEMU-based) + +ITF tests run on a QEMU target and require the `itf-qnx-x86_64` config: ```sh -bazel build //feature_integration_tests/test_scenarios/rust:rust_test_scenarios +bazel test --config=itf-qnx-x86_64 //feature_integration_tests/itf ``` +### Test Scenarios + +Test scenarios can be listed and run directly for debugging: + ```sh bazel run //feature_integration_tests/test_scenarios/rust:rust_test_scenarios -- --list-scenarios +bazel run --config=linux-x86_64 //feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios -- --list-scenarios ``` ## Updating Python Requirements diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index ab0717abf1d..51378eeae80 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -15,10 +15,10 @@ load("@rules_python//python:pip.bzl", "compile_pip_requirements") load("@score_tooling//python_basics:defs.bzl", "score_py_pytest") # In order to update the requirements, change the `requirements.txt` file and run: -# `bazel run //feature_integration_tests/python_test_cases:requirements.update`. +# `bazel run //feature_integration_tests/test_cases:requirements.update`. # This will update the `requirements.txt.lock` file. # To upgrade all dependencies to their latest versions, run: -# `bazel run //feature_integration_tests/python_test_cases:requirements.update -- --upgrade`. +# `bazel run //feature_integration_tests/test_cases:requirements.update -- --upgrade`. compile_pip_requirements( name = "requirements", srcs = [ @@ -36,10 +36,10 @@ compile_pip_requirements( # Tests targets score_py_pytest( - name = "fit", - srcs = glob(["tests/**/*.py"]) + [ - ], + name = "fit_rust", + srcs = glob(["tests/**/*.py"]), args = [ + "-m rust", "--traces=all", "--rust-target-path=$(rootpath //feature_integration_tests/test_scenarios/rust:rust_test_scenarios)", ], @@ -55,3 +55,32 @@ score_py_pytest( pytest_config = "//:pyproject.toml", deps = all_requirements, ) + +score_py_pytest( + name = "fit_cpp", + srcs = glob(["tests/**/*.py"]), + args = [ + "-m cpp", + "--traces=all", + "--cpp-target-path=$(rootpath //feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios)", + ], + data = [ + "conftest.py", + "fit_scenario.py", + "test_properties.py", + "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", + ], + env = { + "RUST_BACKTRACE": "1", + }, + pytest_config = "//:pyproject.toml", + deps = all_requirements, +) + +test_suite( + name = "fit", + tests = [ + ":fit_cpp", + ":fit_rust", + ], +) diff --git a/feature_integration_tests/test_cases/conftest.py b/feature_integration_tests/test_cases/conftest.py index fc20f61f2fc..662b7210943 100644 --- a/feature_integration_tests/test_cases/conftest.py +++ b/feature_integration_tests/test_cases/conftest.py @@ -15,8 +15,6 @@ import pytest from testing_utils import BazelTools -FAILED_CONFIGS = [] - # Cmdline options def pytest_addoption(parser): @@ -33,7 +31,7 @@ def pytest_addoption(parser): parser.addoption( "--rust-target-name", type=str, - default="//feature_integration_tests/rust_test_scenarios:rust_test_scenarios", + default="//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", help="Rust test scenario executable target.", ) parser.addoption( @@ -41,6 +39,17 @@ def pytest_addoption(parser): type=Path, help="Rust test scenario executable target.", ) + parser.addoption( + "--cpp-target-name", + type=str, + default="//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", + help="C++ test scenario executable target.", + ) + parser.addoption( + "--cpp-target-path", + type=Path, + help="C++ test scenario executable target.", + ) parser.addoption( "--build-scenarios", action="store_true", @@ -61,6 +70,18 @@ def pytest_addoption(parser): # Hooks +def pytest_collection_modifyitems(items: list[pytest.Function]): + for item in items: + # Automatically mark tests parametrized with 'version' as 'cpp' or 'rust'. + # This allows -m cpp / -m rust to select the correct variant in each target. + if hasattr(item, "callspec") and "version" in getattr(item.callspec, "params", {}): + version = item.callspec.params["version"] + if version == "cpp": + item.add_marker(pytest.mark.cpp) + elif version == "rust": + item.add_marker(pytest.mark.rust) + + @pytest.hookimpl(tryfirst=True) def pytest_sessionstart(session): try: @@ -70,9 +91,15 @@ def pytest_sessionstart(session): # Build Rust test scenarios. print("Building Rust test scenarios executable...") - cargo_tools = BazelTools(option_prefix="rust", build_timeout=build_timeout) + rust_tools = BazelTools(option_prefix="rust", build_timeout=build_timeout) rust_target_name = session.config.getoption("--rust-target-name") - cargo_tools.build(rust_target_name) + rust_tools.build(rust_target_name) + + # Build C++ test scenarios. + print("Building C++ test scenarios executable...") + cpp_tools = BazelTools(option_prefix="cpp", build_timeout=build_timeout) + cpp_target_name = session.config.getoption("--cpp-target-name") + cpp_tools.build(cpp_target_name) except Exception as e: pytest.exit(str(e), returncode=1) diff --git a/feature_integration_tests/test_cases/fit_scenario.py b/feature_integration_tests/test_cases/fit_scenario.py index 6188fec4fa3..3452745498e 100644 --- a/feature_integration_tests/test_cases/fit_scenario.py +++ b/feature_integration_tests/test_cases/fit_scenario.py @@ -11,8 +11,8 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* import shutil +from collections.abc import Generator from pathlib import Path -from typing import Generator import pytest from testing_utils import ( @@ -68,7 +68,10 @@ class FitScenario(Scenario): """ @pytest.fixture(scope="class") - def build_tools(self) -> BuildTools: + def build_tools(self, request: pytest.FixtureRequest) -> BuildTools: + # Use test parametrization when available; default to rust for non-parametrized tests. + if "version" in request.fixturenames: + return BazelTools(option_prefix=request.getfixturevalue("version")) return BazelTools(option_prefix="rust") def expect_command_failure(self, *args, **kwargs) -> bool: @@ -87,9 +90,10 @@ def results( ) -> ScenarioResult: result = self._run_command(command, execution_timeout, args, kwargs) success = result.return_code == ResultCode.SUCCESS and not result.hang - if self.expect_command_failure() and success: + expect_failure = self.expect_command_failure() + if expect_failure and success: raise RuntimeError(f"Command execution succeeded unexpectedly: {result=}") - if not self.expect_command_failure() and not success: + if not expect_failure and not success: raise RuntimeError(f"Command execution failed unexpectedly: {result=}") return result diff --git a/feature_integration_tests/test_cases/tests/persistency/test_multiple_kvs_per_app.py b/feature_integration_tests/test_cases/tests/persistency/test_multiple_kvs_per_app.py index a15b48085ad..52d4031ad61 100644 --- a/feature_integration_tests/test_cases/tests/persistency/test_multiple_kvs_per_app.py +++ b/feature_integration_tests/test_cases/tests/persistency/test_multiple_kvs_per_app.py @@ -1,5 +1,5 @@ # ******************************************************************************* -# Copyright (c) 2025 Contributors to the Eclipse Foundation +# Copyright (c) 2026 Contributors to the Eclipse Foundation # # See the NOTICE file(s) distributed with this work for additional # information regarding copyright ownership. @@ -12,14 +12,17 @@ # ******************************************************************************* import json +from collections.abc import Generator from pathlib import Path -from typing import Any, Generator +from typing import Any import pytest from fit_scenario import FitScenario, temp_dir_common from test_properties import add_test_properties from testing_utils import LogContainer +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + @add_test_properties( partially_verifies=["feat_req__persistency__multiple_kvs"], @@ -51,8 +54,9 @@ def kvs_value_2(self) -> float: def temp_dir( self, tmp_path_factory: pytest.TempPathFactory, + version: str, ) -> Generator[Path, None, None]: - yield from temp_dir_common(tmp_path_factory, self.__class__.__name__) + yield from temp_dir_common(tmp_path_factory, self.__class__.__name__, version) @pytest.fixture(scope="class") def test_config( diff --git a/feature_integration_tests/test_scenarios/cpp/BUILD b/feature_integration_tests/test_scenarios/cpp/BUILD new file mode 100644 index 00000000000..3f06998b7f6 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/BUILD @@ -0,0 +1,31 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +load("@rules_cc//cc:defs.bzl", "cc_binary") + +cc_binary( + name = "cpp_test_scenarios", + srcs = glob([ + "src/**/*.cpp", + "src/**/*.h", + ]), + includes = ["src"], + tags = ["manual"], + visibility = ["//visibility:public"], + deps = [ + "@score_baselibs//score/json", + "@score_baselibs//score/mw/log:backend_stub_testutil", + "@score_persistency//:kvs_cpp", + "@score_test_scenarios//test_scenarios_cpp", + ], +) diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp new file mode 100644 index 00000000000..8bbc87ba744 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.cpp @@ -0,0 +1,234 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "kvs_instance.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +std::string trim(std::string s) { + while (!s.empty() && std::isspace(static_cast(s.front()))) { + s.erase(s.begin()); + } + while (!s.empty() && std::isspace(static_cast(s.back()))) { + s.pop_back(); + } + return s; +} + +std::string format_double_compact(double value) { + std::ostringstream out; + out << std::setprecision(15) << std::defaultfloat << value; + return out.str(); +} + +double maybe_snap_noisy_decimal(double value) { + // Persisted f64 payloads may contain f32-style artifacts like 111.099998. + // Snap only when very close to a short decimal representation. + double scale = 10.0; + for (int decimals = 1; decimals <= 6; ++decimals) { + const double snapped = std::round(value * scale) / scale; + if (std::fabs(value - snapped) <= 1e-5) { + return snapped; + } + scale *= 10.0; + } + return value; +} + +std::string canonicalize_f64_literals(const std::string& json) { + static const std::regex f64_value_pattern( + R"("t"\s*:\s*"f64"\s*,\s*"v"\s*:\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?))"); + + std::string result; + result.reserve(json.size()); + + std::size_t cursor = 0; + for (std::sregex_iterator it(json.begin(), json.end(), f64_value_pattern), end; it != end; ++it) { + const std::smatch& match = *it; + const auto group_pos = static_cast(match.position(1)); + const auto group_len = static_cast(match.length(1)); + + result.append(json, cursor, group_pos - cursor); + + const std::string literal = match.str(1); + try { + const double parsed = std::stod(literal); + const double canonical = maybe_snap_noisy_decimal(parsed); + result += format_double_compact(canonical); + } catch (const std::exception&) { + result += literal; + } + + cursor = group_pos + group_len; + } + + result.append(json, cursor, std::string::npos); + return result; +} + +std::optional snapshot_path(const KvsParameters& params) { + if (!params.dir.has_value()) { + return std::nullopt; + } + + std::string dir = *params.dir; + if (!dir.empty() && dir.back() == '/') { + dir.pop_back(); + } + + return dir + "/kvs_" + std::to_string(params.instance_id.value) + "_0.json"; +} + +std::optional to_need_flag(const std::optional& mode) { + if (!mode.has_value()) { + return std::nullopt; + } + if (*mode == KvsDefaults::Required) { + return true; + } + // Optional and Ignored are mapped to not-required for C++ KvsBuilder API. + return false; +} + +std::optional to_need_flag(const std::optional& mode) { + if (!mode.has_value()) { + return std::nullopt; + } + if (*mode == KvsLoad::Required) { + return true; + } + // Optional and Ignored are mapped to not-required for C++ KvsBuilder API. + return false; +} + +} // namespace + +KvsInstance::KvsInstance(const KvsParameters& params, score::mw::per::kvs::Kvs&& kvs) + : params_(params), kvs_(std::move(kvs)) {} + +std::optional> KvsInstance::create(const KvsParameters& params) { + using namespace score::mw::per::kvs; + + try { + if (params.snapshot_max_count.has_value() && + params.snapshot_max_count.value() != static_cast(KVS_MAX_SNAPSHOTS)) { + std::cerr << "Unsupported snapshot_max_count for C++ KVS API: requested " + << params.snapshot_max_count.value() << ", supported " << KVS_MAX_SNAPSHOTS + << std::endl; + return std::nullopt; + } + + KvsBuilder builder{score::mw::per::kvs::InstanceId{params.instance_id.value}}; + + if (const auto defaults = to_need_flag(params.defaults)) { + builder = builder.need_defaults_flag(*defaults); + } + if (const auto kvs_load = to_need_flag(params.kvs_load)) { + builder = builder.need_kvs_flag(*kvs_load); + } + if (params.dir.has_value()) { + builder = builder.dir(std::string(*params.dir)); + } + + auto build_result = builder.build(); + if (!build_result) { + return std::nullopt; + } + + return std::shared_ptr(new KvsInstance(params, std::move(build_result.value()))); + + } catch (const std::exception& e) { + std::cerr << "Error creating KVS instance: " << e.what() << std::endl; + return std::nullopt; + } +} + +bool KvsInstance::normalize_snapshot_file_to_rust_envelope(const KvsParameters& params) { + const auto path_opt = snapshot_path(params); + if (!path_opt.has_value()) { + std::cerr << "Cannot normalize snapshot: missing directory parameter" << std::endl; + return false; + } + + std::ifstream in(*path_opt); + if (!in.is_open()) { + std::cerr << "Cannot normalize snapshot: failed to open " << *path_opt << std::endl; + return false; + } + + std::ostringstream buffer; + buffer << in.rdbuf(); + const std::string content = trim(buffer.str()); + const std::string canonical_content = canonicalize_f64_literals(content); + + std::string final_content; + if (canonical_content.rfind("{\"t\":\"obj\",\"v\":", 0) == 0) { + final_content = canonical_content; + } else { + final_content = "{\"t\":\"obj\",\"v\":" + canonical_content + "}"; + } + + std::ofstream out(*path_opt, std::ios::trunc); + if (!out.is_open()) { + std::cerr << "Cannot normalize snapshot: failed to write " << *path_opt << std::endl; + return false; + } + + out << final_content; + return static_cast(out); +} + +bool KvsInstance::set_value(const std::string& key, double value) { + auto result = kvs_.set_value(key, score::mw::per::kvs::KvsValue{value}); + return static_cast(result); +} + +std::optional KvsInstance::get_value(const std::string& key) { + auto result = kvs_.get_value(key); + if (!result) { + return std::nullopt; + } + + const auto& stored = result.value(); + const auto& variant = stored.getValue(); + switch (stored.getType()) { + case score::mw::per::kvs::KvsValue::Type::f64: + return std::get(variant); + case score::mw::per::kvs::KvsValue::Type::i32: + return static_cast(std::get(variant)); + case score::mw::per::kvs::KvsValue::Type::u32: + return static_cast(std::get(variant)); + case score::mw::per::kvs::KvsValue::Type::i64: + return static_cast(std::get(variant)); + case score::mw::per::kvs::KvsValue::Type::u64: + return static_cast(std::get(variant)); + default: + return std::nullopt; + } +} + +bool KvsInstance::flush() { + auto result = kvs_.flush(); + return static_cast(result); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h new file mode 100644 index 00000000000..f5452c94e0d --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_instance.h @@ -0,0 +1,49 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#ifndef INTERNALS_PERSISTENCY_KVS_INSTANCE_H_ +#define INTERNALS_PERSISTENCY_KVS_INSTANCE_H_ + +#include "kvs_parameters.h" + +#include +#include +#include +#include +#include + +class KvsInstance { +public: + // Factory method to create KVS instance + static std::optional> create(const KvsParameters& params); + + // Wrap snapshot file into Rust-style top-level object envelope. + static bool normalize_snapshot_file_to_rust_envelope(const KvsParameters& params); + + // Set a value + bool set_value(const std::string& key, double value); + + // Get a value + std::optional get_value(const std::string& key); + + // Flush to persistent storage + bool flush(); + +private: + KvsInstance(const KvsParameters& params, score::mw::per::kvs::Kvs&& kvs); + + KvsParameters params_; + score::mw::per::kvs::Kvs kvs_; +}; + +#endif // INTERNALS_PERSISTENCY_KVS_INSTANCE_H_ diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.cpp b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.cpp new file mode 100644 index 00000000000..d3f332cb413 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.cpp @@ -0,0 +1,110 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "kvs_parameters.h" + +#include "score/json/json_parser.h" + +#include + +namespace { +KvsDefaults parse_kvs_defaults(const std::string& str) { + if (str == "ignored") return KvsDefaults::Ignored; + if (str == "optional") return KvsDefaults::Optional; + if (str == "required") return KvsDefaults::Required; + throw std::invalid_argument("Invalid KvsDefaults mode: " + str); +} + +KvsLoad parse_kvs_load(const std::string& str) { + if (str == "ignored") return KvsLoad::Ignored; + if (str == "optional") return KvsLoad::Optional; + if (str == "required") return KvsLoad::Required; + throw std::invalid_argument("Invalid KvsLoad mode: " + str); +} + +const score::json::Object& expect_object(const score::json::Any& value, const std::string& field_name) { + const auto object_res = value.As(); + if (!object_res.has_value()) { + throw std::invalid_argument("Expected object field: " + field_name); + } + return object_res.value().get(); +} + +} // namespace + +KvsParameters KvsParameters::from_json_section(const std::string& input, const std::string& section_name) { + KvsParameters params; + + const score::json::JsonParser parser; + const auto root_any_res = parser.FromBuffer(input); + if (!root_any_res.has_value()) { + throw std::invalid_argument("Failed to parse scenario input JSON"); + } + + const auto root_object_res = root_any_res.value().As(); + if (!root_object_res.has_value()) { + throw std::invalid_argument("Scenario input root must be a JSON object"); + } + + const auto& root = root_object_res.value().get(); + const auto section_it = root.find(section_name); + if (section_it == root.end()) { + throw std::invalid_argument("Missing section: " + section_name); + } + + const score::json::Object* parsed_section = &expect_object(section_it->second, section_name); + const auto nested_parameters_it = parsed_section->find("kvs_parameters"); + if (nested_parameters_it != parsed_section->end()) { + parsed_section = &expect_object(nested_parameters_it->second, "kvs_parameters"); + } + + const auto instance_it = parsed_section->find("instance_id"); + if (instance_it == parsed_section->end()) { + throw std::invalid_argument("Missing instance_id in section: " + section_name); + } + const auto instance_res = instance_it->second.As(); + if (!instance_res.has_value()) { + throw std::invalid_argument("Invalid instance_id in section: " + section_name); + } + params.instance_id = InstanceId(static_cast(instance_res.value())); + + if (const auto defaults_it = parsed_section->find("defaults"); defaults_it != parsed_section->end()) { + const auto defaults_res = defaults_it->second.As(); + if (defaults_res.has_value()) { + params.defaults = parse_kvs_defaults(std::string(defaults_res.value())); + } + } + + if (const auto load_it = parsed_section->find("kvs_load"); load_it != parsed_section->end()) { + const auto load_res = load_it->second.As(); + if (load_res.has_value()) { + params.kvs_load = parse_kvs_load(std::string(load_res.value())); + } + } + + if (const auto dir_it = parsed_section->find("dir"); dir_it != parsed_section->end()) { + const auto dir_res = dir_it->second.As(); + if (dir_res.has_value()) { + params.dir = std::string(dir_res.value()); + } + } + + if (const auto snapshot_it = parsed_section->find("snapshot_max_count"); snapshot_it != parsed_section->end()) { + const auto snapshot_res = snapshot_it->second.As(); + if (snapshot_res.has_value()) { + params.snapshot_max_count = static_cast(snapshot_res.value()); + } + } + + return params; +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.h b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.h new file mode 100644 index 00000000000..0ce5adf02c8 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/internals/persistency/kvs_parameters.h @@ -0,0 +1,58 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#ifndef INTERNALS_PERSISTENCY_KVS_PARAMETERS_H_ +#define INTERNALS_PERSISTENCY_KVS_PARAMETERS_H_ + +#include +#include +#include + +// Enum for KVS defaults mode +enum class KvsDefaults { + Ignored, + Optional, + Required, +}; + +// Enum for KVS load mode +enum class KvsLoad { + Ignored, + Optional, + Required, +}; + +// Instance ID wrapper +struct InstanceId { + uint32_t value; + + explicit InstanceId(uint32_t v = 0) : value(v) {} + + std::string to_string() const { + return "InstanceId(" + std::to_string(value) + ")"; + } +}; + +// KVS parameters parsed from JSON +struct KvsParameters { + InstanceId instance_id; + std::optional defaults; + std::optional kvs_load; + std::optional dir; + std::optional snapshot_max_count; + + // Parse from full scenario input by section name (e.g. "kvs_parameters_1"). + static KvsParameters from_json_section(const std::string& input, const std::string& section_name); +}; + +#endif // INTERNALS_PERSISTENCY_KVS_PARAMETERS_H_ diff --git a/feature_integration_tests/test_scenarios/cpp/src/main.cpp b/feature_integration_tests/test_scenarios/cpp/src/main.cpp new file mode 100644 index 00000000000..37c9543d3e8 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/main.cpp @@ -0,0 +1,35 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include +#include + +#include +#include +#include + +ScenarioGroup::Ptr root_scenario_group(); + +int main(int argc, char* argv[]) { + try { + std::vector raw_arguments{argv, argv + argc}; + TestContext test_context{root_scenario_group()}; + run_cli_app(raw_arguments, test_context); + return 0; + + } catch (const std::exception& e) { + std::cerr << e.what() << std::endl; + // Match Rust panic return code. + return 101; + } +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp new file mode 100644 index 00000000000..f1e6e183766 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp @@ -0,0 +1,32 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include + +#include + +Scenario::Ptr make_multiple_kvs_per_app_scenario(); + +ScenarioGroup::Ptr persistency_scenario_group() { + return std::make_shared( + "persistency", + std::vector{make_multiple_kvs_per_app_scenario()}, + std::vector{}); +} + +ScenarioGroup::Ptr root_scenario_group() { + return std::make_shared( + "root", + std::vector{}, + std::vector{persistency_scenario_group()}); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multiple_kvs_per_app.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multiple_kvs_per_app.cpp new file mode 100644 index 00000000000..227e6518a74 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/persistency/multiple_kvs_per_app.cpp @@ -0,0 +1,199 @@ +// ******************************************************************************* +// Copyright (c) 2026 Contributors to the Eclipse Foundation +// +// See the NOTICE file(s) distributed with this work for additional +// information regarding copyright ownership. +// +// This program and the accompanying materials are made available under the +// terms of the Apache License Version 2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +#include "../../internals/persistency/kvs_instance.h" + +#include "score/json/json_parser.h" + +#include + +#include +#include +#include + +struct TestInput { + std::string key; + double value_1; + double value_2; + + static TestInput from_json(const std::string& input); +}; + +class MultipleKvsPerApp : public Scenario { +public: + std::string name() const override; + void run(const std::string& input) const override; +}; + +std::string unix_seconds_string() { + const auto now = std::chrono::system_clock::now(); + const auto secs = std::chrono::duration_cast(now.time_since_epoch()).count(); + return std::to_string(secs); +} + +TestInput TestInput::from_json(const std::string& input_json) { + const score::json::JsonParser parser; + const auto root_any_res = parser.FromBuffer(input_json); + if (!root_any_res.has_value()) { + throw std::invalid_argument("Failed to parse scenario input JSON"); + } + + const auto root_object_res = root_any_res.value().As(); + if (!root_object_res.has_value()) { + throw std::invalid_argument("Scenario input root must be an object"); + } + + const auto& root = root_object_res.value().get(); + const auto test_it = root.find("test"); + if (test_it == root.end()) { + throw std::invalid_argument("Missing test section"); + } + + const auto test_object_res = test_it->second.As(); + if (!test_object_res.has_value()) { + throw std::invalid_argument("test section must be an object"); + } + + const auto& test = test_object_res.value().get(); + const auto key_it = test.find("key"); + const auto value_1_it = test.find("value_1"); + const auto value_2_it = test.find("value_2"); + if (key_it == test.end() || value_1_it == test.end() || value_2_it == test.end()) { + throw std::invalid_argument("Invalid test section format"); + } + + const auto key_res = key_it->second.As(); + const auto value_1_res = value_1_it->second.As(); + const auto value_2_res = value_2_it->second.As(); + if (!key_res.has_value() || !value_1_res.has_value() || !value_2_res.has_value()) { + throw std::invalid_argument("Invalid test section format"); + } + + TestInput parsed; + parsed.key = std::string(key_res.value()); + parsed.value_1 = value_1_res.value(); + parsed.value_2 = value_2_res.value(); + return parsed; +} + +std::string MultipleKvsPerApp::name() const { + return "multiple_kvs_per_app"; +} + +void MultipleKvsPerApp::run(const std::string& input) const { + // Parse parameters + KvsParameters params1 = KvsParameters::from_json_section(input, "kvs_parameters_1"); + KvsParameters params2 = KvsParameters::from_json_section(input, "kvs_parameters_2"); + TestInput test_input = TestInput::from_json(input); + + // ============================================================ + // PHASE 1: Create instances, set values, flush to disk + // ============================================================ + { + // Create first KVS instance + auto kvs1_opt = KvsInstance::create(params1); + if (!kvs1_opt) { + throw std::runtime_error("Failed to create KVS instance 1"); + } + auto kvs1 = *kvs1_opt; + + // Create second KVS instance + auto kvs2_opt = KvsInstance::create(params2); + if (!kvs2_opt) { + throw std::runtime_error("Failed to create KVS instance 2"); + } + auto kvs2 = *kvs2_opt; + + // Set values to both instances + if (!kvs1->set_value(test_input.key, test_input.value_1)) { + throw std::runtime_error("Failed to set kvs1 value"); + } + + if (!kvs2->set_value(test_input.key, test_input.value_2)) { + throw std::runtime_error("Failed to set kvs2 value"); + } + + // Flush both instances to disk + if (!kvs1->flush()) { + throw std::runtime_error("Failed to flush kvs1"); + } + + if (!kvs2->flush()) { + throw std::runtime_error("Failed to flush kvs2"); + } + + // Instances go out of scope here + } + + // ============================================================ + // PHASE 2: Reopen instances and verify persistence + // ============================================================ + { + // Reopen first KVS instance (from persisted file) + auto kvs1_opt = KvsInstance::create(params1); + if (!kvs1_opt) { + throw std::runtime_error("Failed to reopen KVS instance 1"); + } + auto kvs1 = *kvs1_opt; + + // Reopen second KVS instance (from persisted file) + auto kvs2_opt = KvsInstance::create(params2); + if (!kvs2_opt) { + throw std::runtime_error("Failed to reopen KVS instance 2"); + } + auto kvs2 = *kvs2_opt; + + // Read values back + auto value1_opt = kvs1->get_value(test_input.key); + if (!value1_opt) { + throw std::runtime_error("Failed to read kvs1 value"); + } + double value1 = *value1_opt; + + auto value2_opt = kvs2->get_value(test_input.key); + if (!value2_opt) { + throw std::runtime_error("Failed to read kvs2 value"); + } + double value2 = *value2_opt; + + // Match Rust tracing JSON shape expected by FIT log filters. + const std::string timestamp = unix_seconds_string(); + + std::cout << "{\"timestamp\":\"" << timestamp + << "\",\"level\":\"INFO\",\"fields\":{\"instance\":\"" + << params1.instance_id.to_string() + << "\",\"key\":\"" << test_input.key + << "\",\"value\":" << value1 + << "},\"target\":\"cpp_test_scenarios::scenarios::persistency::multiple_kvs_per_app\",\"threadId\":\"ThreadId(1)\"}" + << std::endl; + + std::cout << "{\"timestamp\":\"" << timestamp + << "\",\"level\":\"INFO\",\"fields\":{\"instance\":\"" + << params2.instance_id.to_string() + << "\",\"key\":\"" << test_input.key + << "\",\"value\":" << value2 + << "},\"target\":\"cpp_test_scenarios::scenarios::persistency::multiple_kvs_per_app\",\"threadId\":\"ThreadId(1)\"}" + << std::endl; + + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params1)) { + throw std::runtime_error("Failed to normalize KVS snapshot output for instance 1"); + } + if (!KvsInstance::normalize_snapshot_file_to_rust_envelope(params2)) { + throw std::runtime_error("Failed to normalize KVS snapshot output for instance 2"); + } + } +} + +Scenario::Ptr make_multiple_kvs_per_app_scenario() { + return Scenario::Ptr{new MultipleKvsPerApp{}}; +} diff --git a/pyproject.toml b/pyproject.toml index 469533f41bf..6d78d2c63e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ junit_family = "xunit1" markers = [ "metadata", "test_properties(dict): Add custom properties to test XML output", + "cpp", + "rust", ] filterwarnings = [ 'ignore:record_property is incompatible with junit_family:pytest.PytestWarning',