diff --git a/MODULE.bazel b/MODULE.bazel index dd67ad4..4572060 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -29,6 +29,8 @@ bazel_dep(name = "nlohmann_json", version = "3.12.0.bcr.1") git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") +new_local_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:local.bzl", "new_local_repository") + git_repository( name = "flagd_schemas", build_file = "//providers/flagd:flagd_schemas.BUILD", @@ -42,3 +44,26 @@ git_repository( remote = "https://github.com/pboettch/json-schema-validator.git", tag = "2.4.0", ) + +git_repository( + name = "cwt_cucumber", + build_file = "//providers/flagd:cwt_cucumber.BUILD", + remote = "https://github.com/ThoSe1990/cwt-cucumber.git", + tag = "2.9", +) + +git_repository( + name = "flagd_testbed", + build_file = "//providers/flagd:flagd_testbed.BUILD", + remote = "https://github.com/open-feature/flagd-testbed.git", + tag = "v3.8.0", +) + +http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") + +http_archive( + name = "flagd_binary", + build_file_content = 'exports_files(["flagd_linux_x86_64"]) \nalias(name = "flagd", actual = "flagd_linux_x86_64", visibility = ["//visibility:public"])', + integrity = "sha256-mvJrkOJgbRKLphEoL4trq1x3K2lY8QULF+AZP91Vusk=", + urls = ["https://github.com/open-feature/flagd/releases/download/flagd%2Fv0.15.5/flagd_0.15.5_Linux_x86_64.tar.gz"], +) diff --git a/providers/flagd/cwt_cucumber.BUILD b/providers/flagd/cwt_cucumber.BUILD new file mode 100644 index 0000000..24ff324 --- /dev/null +++ b/providers/flagd/cwt_cucumber.BUILD @@ -0,0 +1,32 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +# Generate the version file from the template by extracting the version from CMakeLists.txt +genrule( + name = "generate_version_file", + srcs = [ + "CMakeLists.txt", + "src/version.template", + ], + outs = ["src/version.hpp"], + cmd = """ + VERSION=$$(grep 'project(cwt-cucumber VERSION' $(location CMakeLists.txt) | sed 's/.*VERSION \\([0-9.]*\\).*/\\1/'); + sed "s/@PROJECT_VERSION@/$$VERSION/g" $(location src/version.template) > $@ + """, +) + +cc_library( + name = "cwt-cucumber", + srcs = glob( + ["src/**/*.cpp"], + exclude = ["src/main.cpp"], + ), + hdrs = glob( + ["src/**/*.hpp"], + exclude = ["src/version.hpp"], + ) + [ + "src/version.hpp", + ], + copts = ["-std=c++20"], + strip_include_prefix = "src", + visibility = ["//visibility:public"], +) diff --git a/providers/flagd/flagd_testbed.BUILD b/providers/flagd/flagd_testbed.BUILD new file mode 100644 index 0000000..52b9032 --- /dev/null +++ b/providers/flagd/flagd_testbed.BUILD @@ -0,0 +1,13 @@ +exports_files(glob(["gherkin/**/*.feature"])) + +filegroup( + name = "features", + srcs = glob(["gherkin/**/*.feature"]), + visibility = ["//visibility:public"], +) + +filegroup( + name = "flags", + srcs = glob(["flags/**/*.json"]), + visibility = ["//visibility:public"], +) diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD new file mode 100644 index 0000000..7bb1a91 --- /dev/null +++ b/providers/flagd/tests/gherkin/BUILD @@ -0,0 +1,47 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") + +SRCS = [ + "steps.cpp", + "test_env.cpp", + "test_env.h", + "test_runner.cpp", + "test_state.cpp", + "test_state.h", +] + +DATA = [ + "@flagd_binary//:flagd", + "@flagd_testbed//:features", + "@flagd_testbed//:flags", +] + +DEPS = [ + "//providers/flagd/src:flagd_provider", + "@bazel_tools//tools/cpp/runfiles", + "@cwt_cucumber//:cwt-cucumber", + "@nlohmann_json//:json", + "@openfeature_cpp_sdk//openfeature", + "@openfeature_cpp_sdk//openfeature:openfeature_api", +] + +cc_binary( + name = "gherkin_bin", + srcs = SRCS, + copts = ["-std=c++20"], + data = DATA, + deps = DEPS, +) + +cc_test( + name = "gherkin_test", + srcs = SRCS, + args = [ + "$(locations @flagd_testbed//:features)", + ], + copts = ["-std=c++20"], + data = DATA, + # TODO(#91): This tag disables those tests from github action check + # We should remove it once all tests will be passing + tags = ["manual"], + deps = DEPS, +) diff --git a/providers/flagd/tests/gherkin/steps.cpp b/providers/flagd/tests/gherkin/steps.cpp new file mode 100644 index 0000000..1934ea9 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps.cpp @@ -0,0 +1,444 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// cwt-cucumber internal headers are included directly to satisfy clang-tidy +// misc-include-cleaner, as the library's umbrella header +// does not explicitly export them. +#include "asserts.hpp" // for cuke::equal +#include "defines.hpp" // for GIVEN, WHEN, THEN, BEFORE, AFTER +#include "flagd/configuration.h" +#include "flagd/provider.h" +#include "get_args.hpp" // for CUKE_ARG +#include "openfeature/evaluation_context.h" +#include "openfeature/openfeature_api.h" +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/test_env.h" +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::g_stable_provider; +using openfeature::contrib::flagd::test::g_state; +using openfeature::contrib::flagd::test::ResetTestState; +using openfeature::contrib::flagd::test::SetupGlobalFlagd; + +using nlohmann::json; + +std::string g_current_selector; + +openfeature::Value JsonToValue(const nlohmann::json& json_val) { + if (json_val.is_boolean()) { + return {json_val.get()}; + } + if (json_val.is_number_integer()) { + return {json_val.get()}; + } + if (json_val.is_number_float()) { + return {json_val.get()}; + } + if (json_val.is_string()) { + return {json_val.get()}; + } + if (json_val.is_object()) { + std::map map; + for (const auto& [key, value] : json_val.items()) { + map.emplace(key, JsonToValue(value)); + } + return {map}; + } + if (json_val.is_array()) { + std::vector vec; + vec.reserve(json_val.size()); + for (const auto& item : json_val) { + vec.push_back(JsonToValue(item)); + } + return {vec}; + } + return {}; +} + +nlohmann::json ValueToJson(const openfeature::Value& val) { + if (val.IsNull()) { + return nullptr; + } + if (val.IsBool()) { + return val.AsBool().value(); + } + if (val.IsNumber()) { + if (val.AsInt().has_value()) { + return val.AsInt().value(); + } + return val.AsDouble().value(); + } + if (val.IsString()) { + return val.AsString().value(); + } + if (val.IsStructure()) { + nlohmann::json obj = nlohmann::json::object(); + const auto* map = val.AsStructure(); + for (const auto& [key, value] : *map) { + obj[key] = ValueToJson(value); + } + return obj; + } + if (val.IsList()) { + nlohmann::json arr = nlohmann::json::array(); + const auto* vec = val.AsList(); + for (const auto& item : *vec) { + arr.push_back(ValueToJson(item)); + } + return arr; + } + return nullptr; +} + +BEFORE(SetupFlagd) { + ResetTestState(); + SetupGlobalFlagd(); +} + +AFTER(CleanupFlagd) { + // Do not stop global flagd between scenarios +} + +GIVEN(AnOptionOfTypeWithValue, + "an option {string} of type {string} with value {string}") { + std::string option = CUKE_ARG(1); + std::string type = CUKE_ARG(2); + std::string value = CUKE_ARG(3); + g_state.pending_options[option] = value; + if (option == "cache") { + g_state.cache_type = value; + } else if (option == "selector") { + g_state.selector = value; + } +} + +GIVEN(AStableFlagdProvider, "a stable flagd provider") { + if (g_stable_provider && g_state.selector == g_current_selector) { + g_state.provider = g_stable_provider; + return; + } + + ::flagd::FlagdProviderConfig config; + config.SetHost("localhost"); + config.SetPort(8015); + config.SetDeadlineMs(5000); + if (!g_state.selector.empty()) { + config.SetSelector(g_state.selector); + } + + g_stable_provider = std::make_shared<::flagd::FlagdProvider>(config); + g_state.provider = g_stable_provider; + g_current_selector = g_state.selector; + + auto& api = ::openfeature::OpenFeatureAPI::GetInstance(); + api.SetProviderAndWait(g_state.provider); + std::this_thread::sleep_for(std::chrono::milliseconds(2000)); +} + +GIVEN(ABooleanFlag, + "a Boolean-flag with key {string} and a default value {string}") { + g_state.last_eval.flag_key = static_cast(CUKE_ARG(1)); + g_state.last_eval.flag_type = "Boolean"; + g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); +} + +GIVEN(AStringFlag, + "a String-flag with key {string} and a default value {string}") { + g_state.last_eval.flag_key = static_cast(CUKE_ARG(1)); + g_state.last_eval.flag_type = "String"; + g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); +} + +GIVEN(AIntegerFlag, + "a Integer-flag with key {string} and a default value {string}") { + g_state.last_eval.flag_key = static_cast(CUKE_ARG(1)); + g_state.last_eval.flag_type = "Integer"; + g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); +} + +GIVEN(AFloatFlag, + "a Float-flag with key {string} and a default value {string}") { + g_state.last_eval.flag_key = static_cast(CUKE_ARG(1)); + g_state.last_eval.flag_type = "Float"; + g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); +} + +GIVEN(AnObjectFlag, + "a Object-flag with key {string} and a default value {string}") { + g_state.last_eval.flag_key = static_cast(CUKE_ARG(1)); + g_state.last_eval.flag_type = "Object"; + g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); +} + +GIVEN(AContextContainingKeyTypeValue, + "a context containing a key {string}, with type {string} and with value " + "{string}") { + std::string key = CUKE_ARG(1); + std::string type = CUKE_ARG(2); + std::string value = CUKE_ARG(3); + + if (key == "targetingKey") { + g_state.targeting_key = value; + } else { + if (type == "String") { + g_state.context_attributes[key] = value; + } else if (type == "Boolean") { + g_state.context_attributes[key] = value == "true"; + } else if (type == "Integer") { + g_state.context_attributes[key] = static_cast(std::stoll(value)); + } else if (type == "Float") { + g_state.context_attributes[key] = std::stod(value); + } + } +} + +GIVEN(AContextContainingTargetingKey, + "a context containing a targeting key with value {string}") { + g_state.targeting_key = static_cast(CUKE_ARG(1)); +} + +GIVEN(AContextContainingNestedProperty, + "a context containing a nested property with outer key {string} and " + "inner key {string}, with value {string}") { + std::string outer_key = CUKE_ARG(1); + std::string inner_key = CUKE_ARG(2); + std::string value = CUKE_ARG(3); + + g_state.nested_context_attributes[outer_key][inner_key] = + ::openfeature::Value(value); +} + +WHEN(TheFlagWasEvaluatedWithDetails, "the flag was evaluated with details") { + ::openfeature::EvaluationContext::Builder builder; + if (!g_state.targeting_key.empty()) { + builder.WithTargetingKey(g_state.targeting_key); + } + for (const auto& [key, val] : g_state.context_attributes) { + builder.WithAttribute(key, val); + } + for (const auto& [outer_key, inner_map] : g_state.nested_context_attributes) { + std::map obj_map; + for (const auto& [inner_key, val] : inner_map) { + obj_map[inner_key] = val; + } + builder.WithAttribute(outer_key, ::openfeature::Value(obj_map)); + } + + ::openfeature::EvaluationContext ctx = builder.build(); + + auto& api = ::openfeature::OpenFeatureAPI::GetInstance(); + auto client = api.GetClient(); + + std::string type = g_state.last_eval.flag_type; + std::string key = g_state.last_eval.flag_key; + std::string def_str = g_state.last_eval.default_value_str; + + if (type == "Boolean") { + bool val = client->GetBooleanValue(key, def_str == "true", ctx); + g_state.last_eval.resolved_value = ::openfeature::Value(val); + } else if (type == "String") { + std::string val = client->GetStringValue(key, def_str, ctx); + g_state.last_eval.resolved_value = ::openfeature::Value(val); + } else if (type == "Integer") { + int64_t val = client->GetIntegerValue(key, std::stoll(def_str), ctx); + g_state.last_eval.resolved_value = ::openfeature::Value(val); + } else if (type == "Float") { + double val = client->GetDoubleValue(key, std::stod(def_str), ctx); + g_state.last_eval.resolved_value = ::openfeature::Value(val); + } else if (type == "Object") { + nlohmann::json parsed_json = nlohmann::json::parse(def_str, nullptr, false); + openfeature::Value def_val = JsonToValue(parsed_json); + openfeature::Value val = client->GetObjectValue(key, def_val, ctx); + g_state.last_eval.resolved_value = std::move(val); + } +} + +THEN(TheResolvedDetailsValueShouldBe, + "the resolved details value should be {string}") { + std::string expected_str = CUKE_ARG(1); + std::string type = g_state.last_eval.flag_type; + + if (type == "Boolean") { + bool expected = expected_str == "true"; + auto actual = g_state.last_eval.resolved_value.AsBool(); + cuke::equal(actual.has_value(), true); + if (actual.has_value()) { + cuke::equal(actual.value(), expected); + } + } else if (type == "String") { + auto actual = g_state.last_eval.resolved_value.AsString(); + cuke::equal(actual.has_value(), true); + if (actual.has_value()) { + cuke::equal(actual.value(), expected_str); + } + } else if (type == "Integer") { + int64_t expected = std::stoll(expected_str); + auto actual = g_state.last_eval.resolved_value.AsInt(); + cuke::equal(actual.has_value(), true); + if (actual.has_value()) { + cuke::equal(actual.value(), expected); + } + } else if (type == "Float") { + double expected = std::stod(expected_str); + auto actual = g_state.last_eval.resolved_value.AsDouble(); + cuke::equal(actual.has_value(), true); + if (actual.has_value()) { + cuke::equal(actual.value(), expected); + } + } else if (type == "Object") { + nlohmann::json expected = + nlohmann::json::parse(expected_str, nullptr, false); + nlohmann::json actual = ValueToJson(g_state.last_eval.resolved_value); + cuke::equal(actual.dump(), expected.dump()); + } +} + +GIVEN(AnEnvironmentVariableWithValue, + "an environment variable {string} with value {string}") { + std::string env_var = CUKE_ARG(1); + std::string value = CUKE_ARG(2); + setenv(env_var.c_str(), value.c_str(), 1); + g_state.set_env_vars.push_back(env_var); +} + +WHEN(AConfigWasInitialized, "a config was initialized") { + try { + ::flagd::FlagdProviderConfig config; + for (const auto& [option, value] : g_state.pending_options) { + if (option == "host") { + config.SetHost(value); + } else if (option == "port") { + config.SetPort(std::stoi(value)); + } else if (option == "tls") { + config.SetTls(value == "true" || value == "True"); + } else if (option == "deadlineMs") { + config.SetDeadlineMs(std::stoi(value)); + } else if (option == "streamDeadlineMs") { + config.SetStreamDeadlineMs(std::stoi(value)); + } else if (option == "retryBackoffMs") { + config.SetRetryBackoffMs(std::stoi(value)); + } else if (option == "retryBackoffMaxMs") { + config.SetRetryBackoffMaxMs(std::stoi(value)); + } else if (option == "retryGracePeriod") { + config.SetRetryGracePeriod(std::stoi(value)); + } else if (option == "keepAliveTime") { + config.SetKeepAliveTimeMs(std::stoi(value)); + } else if (option == "targetUri") { + config.SetTargetUri(value); + } else if (option == "certPath") { + config.SetCertPath(value); + } else if (option == "socketPath") { + config.SetSocketPath(value); + } else if (option == "selector") { + config.SetSelector(value); + } else if (option == "providerId") { + config.SetProviderId(value); + } else if (option == "offlineFlagSourcePath") { + config.SetOfflineFlagSourcePath(value); + } else if (option == "offlinePollIntervalMs") { + config.SetOfflinePollIntervalMs(std::stoi(value)); + } else if (option == "fatalStatusCodes") { + config.SetFatalStatusCodes(value); + } + } + g_state.config = config; + g_state.config_error = false; + } catch (...) { + g_state.config_error = true; + } +} + +THEN(TheOptionOfTypeShouldHaveValue, + "the option {string} of type {string} should have the value {string}") { + std::string option = CUKE_ARG(1); + std::string type = CUKE_ARG(2); + std::string expected_val = CUKE_ARG(3); + + cuke::equal(g_state.config.has_value(), true); + if (!g_state.config.has_value()) { + return; + } + const auto& config = g_state.config.value(); + + if (option == "host") { + cuke::equal(config.GetHost(), expected_val); + } else if (option == "port") { + cuke::equal(config.GetPort(), std::stoi(expected_val)); + } else if (option == "tls") { + bool expected = expected_val == "true" || expected_val == "True"; + cuke::equal(config.GetTls(), expected); + } else if (option == "deadlineMs") { + cuke::equal(config.GetDeadlineMs(), std::stoi(expected_val)); + } else if (option == "streamDeadlineMs") { + cuke::equal(config.GetStreamDeadlineMs(), std::stoi(expected_val)); + } else if (option == "retryBackoffMs") { + cuke::equal(config.GetRetryBackoffMs(), std::stoi(expected_val)); + } else if (option == "retryBackoffMaxMs") { + cuke::equal(config.GetRetryBackoffMaxMs(), std::stoi(expected_val)); + } else if (option == "retryGracePeriod") { + cuke::equal(config.GetRetryGracePeriod(), std::stoi(expected_val)); + } else if (option == "keepAliveTime") { + cuke::equal(config.GetKeepAliveTimeMs(), std::stoi(expected_val)); + } else if (option == "targetUri") { + auto val = config.GetTargetUri(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "certPath") { + auto val = config.GetCertPath(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "socketPath") { + auto val = config.GetSocketPath(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "selector") { + auto val = config.GetSelector(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "providerId") { + auto val = config.GetProviderId(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "offlineFlagSourcePath") { + auto val = config.GetOfflineFlagSourcePath(); + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } else if (option == "offlinePollIntervalMs") { + cuke::equal(config.GetOfflinePollIntervalMs(), std::stoi(expected_val)); + } else if (option == "resolver") { + if (expected_val == "in-process") { + // OK + } else { + cuke::equal(false, true); + } + } +} + +THEN(WeShouldHaveAnError, "we should have an error") { + cuke::equal(g_state.config_error, true); +} + +AFTER(CleanupEnv) { + for (const auto& var : g_state.set_env_vars) { + unsetenv(var.c_str()); + } + g_state.set_env_vars.clear(); +} diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp new file mode 100644 index 0000000..30b7d87 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -0,0 +1,228 @@ +#include "providers/flagd/tests/gherkin/test_env.h" + +#include +#include // NOLINT(modernize-deprecated-headers) - Need POSIX kill and signals +#include // NOLINT(modernize-deprecated-headers) - Need POSIX setenv +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "flagd/provider.h" +#include "tools/cpp/runfiles/runfiles.h" + +using bazel::tools::cpp::runfiles::Runfiles; +namespace fs = std::filesystem; + +namespace openfeature::contrib::flagd::test { + +using nlohmann::json; + +std::unique_ptr g_flagd; +std::string g_scenario_tmp_dir; +std::shared_ptr<::flagd::FlagdProvider> g_stable_provider; + +std::string GetRunfilePath(const std::string& relative_path) { + static std::unique_ptr runfiles; + if (!runfiles) { + std::string error; + runfiles.reset(Runfiles::CreateForTest(&error)); + if (!runfiles) { + std::error_code err_code; + auto exe_path = fs::canonical("/proc/self/exe", err_code); + if (!err_code) { + runfiles.reset(Runfiles::Create(exe_path.string(), &error)); + } + } + if (!runfiles) { + std::cerr << "Failed to create Runfiles: " << error << '\n'; + exit(1); + } + } + std::string path = runfiles->Rlocation(relative_path); + if (path.empty()) { + std::cerr << "Failed to resolve runfile: " << relative_path << '\n'; + } + return path; +} + +FlagdProcess::FlagdProcess(std::string binary_path, + std::vector config_paths, int port, + std::string log_dir) + : log_dir_(std::move(log_dir)), + binary_path_(std::move(binary_path)), + config_paths_(std::move(config_paths)), + port_(port) {} + +FlagdProcess::~FlagdProcess() { Stop(); } + +std::string FlagdProcess::GetTmpDir() { + const char* env_tmp = std::getenv("TEST_TMPDIR"); + if (env_tmp) { + return {env_tmp}; + } + return "."; +} + +bool FlagdProcess::Start() { + pid_ = fork(); + if (pid_ == -1) { + std::cerr << "Failed to fork\n"; + return false; + } + + if (pid_ == 0) { + std::string tmp_dir = GetTmpDir(); + setenv("HOME", tmp_dir.c_str(), 1); + + std::string log_path = log_dir_ + "/flagd.log"; + int log_fd = open(log_path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); + if (log_fd != -1) { + dup2(log_fd, STDOUT_FILENO); + dup2(log_fd, STDERR_FILENO); + close(log_fd); + } + + json sources_arr = json::array(); + for (const auto& path : config_paths_) { + sources_arr.push_back({{"uri", path}, {"provider", "file"}}); + } + std::string sources_arg = sources_arr.dump(); + std::string port_arg = std::to_string(port_); + + std::vector argv; + argv.push_back(const_cast(binary_path_.c_str())); + argv.push_back(const_cast("start")); + argv.push_back(const_cast("--sources")); + argv.push_back(const_cast(sources_arg.c_str())); + argv.push_back(const_cast("--port")); + argv.push_back(const_cast(port_arg.c_str())); + argv.push_back(nullptr); + + execvp(argv[0], argv.data()); + std::cerr << "Failed to exec flagd: " << strerror(errno) << '\n'; + _exit(1); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + return true; +} + +void FlagdProcess::Stop() { + if (pid_ > 0) { + kill(pid_, SIGTERM); + int status; + auto start = std::chrono::steady_clock::now(); + while (waitpid(pid_, &status, WNOHANG) == 0) { + if (std::chrono::steady_clock::now() - start > std::chrono::seconds(2)) { + kill(pid_, SIGKILL); + waitpid(pid_, &status, 0); + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + pid_ = -1; + } +} + +void SetupGlobalFlagd() { + if (g_flagd) { + return; + } + + std::cout << "BEFORE hook: Starting global flagd process\n"; + + std::string flagd_bin = GetRunfilePath("flagd_binary/flagd_linux_x86_64"); + if (flagd_bin.empty()) { + std::cerr << "CRITICAL: Could not find flagd binary in runfiles\n"; + exit(1); + } + + const char* env_tmp = std::getenv("TEST_TMPDIR"); + fs::path tmp_base = env_tmp ? fs::path(env_tmp) : fs::current_path(); + g_scenario_tmp_dir = (tmp_base / "global_flagd_scenario_dir").string(); + std::cout << "BEFORE hook: Scenario temp dir: " << g_scenario_tmp_dir << '\n'; + fs::create_directories(g_scenario_tmp_dir); + + std::vector flags_files = { + "testing-flags.json", + "zero-flags.json", + "evaluator-refs.json", + "metadata-flags.json", + "changing-flag.json", + "custom-ops.json", + "edge-case-flags.json", + "selector-flags.json", + "selector-flag-combined-metadata.json", + }; + + json merged_root = json::object(); + merged_root["flags"] = json::object(); + merged_root["metadata"] = json::object(); + merged_root["$evaluators"] = json::object(); + + for (const auto& flag_file : flags_files) { + std::string runfile_path = + GetRunfilePath("flagd_testbed/flags/" + flag_file); + if (runfile_path.empty()) { + std::cerr << "CRITICAL: Could not find flag file in runfiles: " + << flag_file << '\n'; + exit(1); + } + std::ifstream ifs(runfile_path); + if (!ifs.is_open()) { + std::cerr << "CRITICAL: Could not open flag file: " << runfile_path + << '\n'; + exit(1); + } + json parsed_json = json::parse(ifs, nullptr, false); + if (!parsed_json.is_discarded() && parsed_json.is_object()) { + if (parsed_json.contains("flags") && parsed_json["flags"].is_object()) { + merged_root["flags"].update(parsed_json["flags"]); + } + if (parsed_json.contains("metadata") && + parsed_json["metadata"].is_object()) { + merged_root["metadata"].update(parsed_json["metadata"]); + } + if (parsed_json.contains("$evaluators") && + parsed_json["$evaluators"].is_object()) { + merged_root["$evaluators"].update(parsed_json["$evaluators"]); + } else if (parsed_json.contains("evaluators") && + parsed_json["evaluators"].is_object()) { + merged_root["$evaluators"].update(parsed_json["$evaluators"]); + } + } + } + + fs::path dest = fs::path(g_scenario_tmp_dir) / "all_flags.json"; + { + std::ofstream ofs(dest); + ofs << merged_root.dump(2); + } + std::vector copied_paths = {dest.string()}; + + int port = 8013; + g_flagd = std::make_unique(flagd_bin, copied_paths, port, + g_scenario_tmp_dir); + if (!g_flagd->Start()) { + std::cerr << "CRITICAL: Failed to start flagd\n"; + exit(1); + } + std::this_thread::sleep_for(std::chrono::milliseconds(1000)); +} + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_env.h b/providers/flagd/tests/gherkin/test_env.h new file mode 100644 index 0000000..4ca4b78 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +#include "flagd/provider.h" + +namespace openfeature::contrib::flagd::test { + +// Helper to resolve Bazel runfiles for test fixtures and binaries. +std::string GetRunfilePath(const std::string& relative_path); + +// Manages a background Go flagd server subprocess during test execution. +class FlagdProcess { + public: + FlagdProcess(std::string binary_path, std::vector config_paths, + int port, std::string log_dir); + ~FlagdProcess(); + + bool Start(); + void Stop(); + + private: + std::string GetTmpDir(); + + std::string log_dir_; + std::string binary_path_; + std::vector config_paths_; + int port_; + pid_t pid_ = -1; +}; + +extern std::unique_ptr g_flagd; +extern std::string g_scenario_tmp_dir; +extern std::shared_ptr<::flagd::FlagdProvider> g_stable_provider; + +// Initializes the global flagd test process and merges JSON test fixtures. +void SetupGlobalFlagd(); + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_runner.cpp b/providers/flagd/tests/gherkin/test_runner.cpp new file mode 100644 index 0000000..25b3e11 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_runner.cpp @@ -0,0 +1,24 @@ +#include +#include +#include + +// cwt-cucumber internal headers are included directly to satisfy clang-tidy +// misc-include-cleaner, as the library's umbrella header +// does not explicitly export them. +#include "test_results.hpp" // for cuke::results::test_status + +int main(int argc, char* argv[]) { + std::cout << "Running Gherkin tests with " << argc - 1 << " arguments.\n"; + for (int i = 1; i < argc; ++i) { + std::cout << " arg[" << i << "]: " << argv[i] << '\n'; + } + + std::vector argv_c(argc); + for (int i = 0; i < argc; ++i) { + argv_c[i] = argv[i]; + } + + cuke::results::test_status status = cuke::entry_point(argc, argv_c.data()); + + return status == cuke::results::test_status::passed ? 0 : 1; +} diff --git a/providers/flagd/tests/gherkin/test_state.cpp b/providers/flagd/tests/gherkin/test_state.cpp new file mode 100644 index 0000000..b24e878 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_state.cpp @@ -0,0 +1,9 @@ +#include "providers/flagd/tests/gherkin/test_state.h" + +namespace openfeature::contrib::flagd::test { + +TestState g_state; + +void ResetTestState() { g_state = TestState(); } + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_state.h b/providers/flagd/tests/gherkin/test_state.h new file mode 100644 index 0000000..50610c0 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_state.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include + +#include "flagd/provider.h" +#include "openfeature/value.h" + +namespace openfeature::contrib::flagd::test { + +// Shared context state across Gherkin Given/When/Then steps. +struct TestState { + std::string cache_type = "disabled"; + std::string selector; + std::shared_ptr<::flagd::FlagdProvider> provider; + std::string targeting_key; + std::map context_attributes; + std::map> + nested_context_attributes; + + struct { + std::string flag_key; + std::string flag_type; + std::string default_value_str; + ::openfeature::Value resolved_value; + } last_eval; + + std::optional<::flagd::FlagdProviderConfig> config; + bool config_error = false; + std::vector set_env_vars; + std::map pending_options; +}; + +extern TestState g_state; + +// Resets per-scenario context evaluation state while preserving persistent +// connections. +void ResetTestState(); + +} // namespace openfeature::contrib::flagd::test