From 12d0d7c3beb54d70c2a098d651fe82dfc9fea5e3 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 26 Jun 2026 15:16:53 +0000 Subject: [PATCH 01/12] First integration of Gherkin Signed-off-by: Marcin Olko --- MODULE.bazel | 25 + providers/flagd/cwt_cucumber.BUILD | 32 ++ providers/flagd/flagd_testbed.BUILD | 13 + providers/flagd/tests/gherkin/BUILD | 47 ++ providers/flagd/tests/gherkin/steps.cpp | 444 ++++++++++++++++++ providers/flagd/tests/gherkin/test_env.cpp | 228 +++++++++ providers/flagd/tests/gherkin/test_env.h | 41 ++ providers/flagd/tests/gherkin/test_runner.cpp | 24 + providers/flagd/tests/gherkin/test_state.cpp | 9 + providers/flagd/tests/gherkin/test_state.h | 42 ++ 10 files changed, 905 insertions(+) create mode 100644 providers/flagd/cwt_cucumber.BUILD create mode 100644 providers/flagd/flagd_testbed.BUILD create mode 100644 providers/flagd/tests/gherkin/BUILD create mode 100644 providers/flagd/tests/gherkin/steps.cpp create mode 100644 providers/flagd/tests/gherkin/test_env.cpp create mode 100644 providers/flagd/tests/gherkin/test_env.h create mode 100644 providers/flagd/tests/gherkin/test_runner.cpp create mode 100644 providers/flagd/tests/gherkin/test_state.cpp create mode 100644 providers/flagd/tests/gherkin/test_state.h 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 From 9712b4dd8eb7eb543606d553d3a83c663cdde165 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Tue, 14 Jul 2026 08:47:43 +0000 Subject: [PATCH 02/12] Removed hardcoding flag configs paths Signed-off-by: Marcin Olko --- MODULE.bazel | 2 - providers/flagd/tests/gherkin/BUILD | 6 ++ providers/flagd/tests/gherkin/steps.cpp | 4 +- providers/flagd/tests/gherkin/test_env.cpp | 67 ++++++++++++++++------ 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 4572060..e35e10a 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -29,8 +29,6 @@ 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", diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index 7bb1a91..9af50d5 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -29,6 +29,9 @@ cc_binary( srcs = SRCS, copts = ["-std=c++20"], data = DATA, + env = { + "FLAGD_TEST_FLAGS": "$(locations @flagd_testbed//:flags)", + }, deps = DEPS, ) @@ -40,6 +43,9 @@ cc_test( ], copts = ["-std=c++20"], data = DATA, + env = { + "FLAGD_TEST_FLAGS": "$(locations @flagd_testbed//:flags)", + }, # TODO(#91): This tag disables those tests from github action check # We should remove it once all tests will be passing tags = ["manual"], diff --git a/providers/flagd/tests/gherkin/steps.cpp b/providers/flagd/tests/gherkin/steps.cpp index 1934ea9..5ef0b06 100644 --- a/providers/flagd/tests/gherkin/steps.cpp +++ b/providers/flagd/tests/gherkin/steps.cpp @@ -1,6 +1,6 @@ -#include +#include // NOLINT(modernize-deprecated-headers) + #include -#include #include #include #include diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index 30b7d87..83e7644 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -13,6 +13,7 @@ #include #include #include +#include // NOLINT(misc-include-cleaner) - Used for parsing FLAGD_TEST_FLAGS env var #include #include #include @@ -158,17 +159,30 @@ void SetupGlobalFlagd() { 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", - }; + std::vector flags_files; + if (const char* env_flags = std::getenv("FLAGD_TEST_FLAGS")) { + std::istringstream iss(env_flags); + std::string path; + while (iss >> path) { + flags_files.push_back(path); + } + } + + if (flags_files.empty()) { + std::string flags_dir = GetRunfilePath("flagd_testbed/flags"); + if (flags_dir.empty() || !fs::exists(flags_dir)) { + if (fs::exists("../+_repo_rules+flagd_testbed/flags")) { + flags_dir = "../+_repo_rules+flagd_testbed/flags"; + } + } + if (!flags_dir.empty() && fs::exists(flags_dir)) { + for (const auto& entry : fs::directory_iterator(flags_dir)) { + if (entry.path().extension() == ".json") { + flags_files.push_back(entry.path().string()); + } + } + } + } json merged_root = json::object(); merged_root["flags"] = json::object(); @@ -176,12 +190,27 @@ void SetupGlobalFlagd() { 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); + fs::path p(flag_file); + std::string filename = p.filename().string(); + if (filename.rfind("selector-", 0) == 0) { + continue; // Skip selector- files in all_flags.json merge, matching Launchpad behavior + } + + std::string runfile_path; + if (fs::exists(flag_file)) { + runfile_path = flag_file; + } else { + runfile_path = GetRunfilePath(flag_file); + if (runfile_path.empty() && + flag_file.find("flagd_testbed/flags/") == std::string::npos) { + runfile_path = GetRunfilePath("flagd_testbed/flags/" + flag_file); + } + } + + if (runfile_path.empty() || !fs::exists(runfile_path)) { + std::cerr << "WARNING: Could not resolve flag file path: " << flag_file + << '\n'; + continue; } std::ifstream ifs(runfile_path); if (!ifs.is_open()) { @@ -202,8 +231,8 @@ void SetupGlobalFlagd() { 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"]); + parsed_json["evaluators"].is_object()) { + merged_root["$evaluators"].update(parsed_json["evaluators"]); } } } From b7fef1899db0db3f98fb9484f8f90c4d105a9db2 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Tue, 14 Jul 2026 09:13:16 +0000 Subject: [PATCH 03/12] Added support for flags selectors Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/steps.cpp | 2 +- providers/flagd/tests/gherkin/test_env.cpp | 51 +++++++++++++++++----- providers/flagd/tests/gherkin/test_env.h | 9 +++- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/providers/flagd/tests/gherkin/steps.cpp b/providers/flagd/tests/gherkin/steps.cpp index 5ef0b06..4c79bec 100644 --- a/providers/flagd/tests/gherkin/steps.cpp +++ b/providers/flagd/tests/gherkin/steps.cpp @@ -126,7 +126,7 @@ GIVEN(AStableFlagdProvider, "a stable flagd provider") { ::flagd::FlagdProviderConfig config; config.SetHost("localhost"); - config.SetPort(8015); + config.SetPort(8013); config.SetDeadlineMs(5000); if (!g_state.selector.empty()) { config.SetSelector(g_state.selector); diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index 83e7644..99461c8 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -13,10 +13,10 @@ #include #include #include -#include // NOLINT(misc-include-cleaner) - Used for parsing FLAGD_TEST_FLAGS env var #include #include #include +#include // NOLINT(misc-include-cleaner) - Used for parsing FLAGD_TEST_FLAGS env var #include #include #include @@ -62,11 +62,11 @@ std::string GetRunfilePath(const std::string& relative_path) { } FlagdProcess::FlagdProcess(std::string binary_path, - std::vector config_paths, int port, + std::vector sources, int port, std::string log_dir) : log_dir_(std::move(log_dir)), binary_path_(std::move(binary_path)), - config_paths_(std::move(config_paths)), + sources_(std::move(sources)), port_(port) {} FlagdProcess::~FlagdProcess() { Stop(); } @@ -99,8 +99,12 @@ bool FlagdProcess::Start() { } json sources_arr = json::array(); - for (const auto& path : config_paths_) { - sources_arr.push_back({{"uri", path}, {"provider", "file"}}); + for (const auto& src : sources_) { + json src_obj = {{"uri", src.path}, {"provider", "file"}}; + if (!src.selector.empty()) { + src_obj["selector"] = src.selector; + } + sources_arr.push_back(src_obj); } std::string sources_arg = sources_arr.dump(); std::string port_arg = std::to_string(port_); @@ -189,12 +193,11 @@ void SetupGlobalFlagd() { merged_root["metadata"] = json::object(); merged_root["$evaluators"] = json::object(); + std::vector sources; + for (const auto& flag_file : flags_files) { fs::path p(flag_file); std::string filename = p.filename().string(); - if (filename.rfind("selector-", 0) == 0) { - continue; // Skip selector- files in all_flags.json merge, matching Launchpad behavior - } std::string runfile_path; if (fs::exists(flag_file)) { @@ -212,6 +215,28 @@ void SetupGlobalFlagd() { << '\n'; continue; } + + if (filename.rfind("selector-", 0) == 0) { + // Copy selector file to tmp scenario directory + fs::path dest_selector_path = fs::path(g_scenario_tmp_dir) / filename; + std::error_code ec; + fs::copy_file(runfile_path, dest_selector_path, + fs::copy_options::overwrite_existing, ec); + if (ec) { + std::cerr << "CRITICAL: Could not copy selector file: " << runfile_path + << " to " << dest_selector_path << " - " << ec.message() + << '\n'; + exit(1); + } + // Register selector file source, matching key format + // "rawflags/selector-flags.json" + sources.push_back({ + .path = dest_selector_path.string(), + .selector = "rawflags/" + filename, + }); + continue; + } + std::ifstream ifs(runfile_path); if (!ifs.is_open()) { std::cerr << "CRITICAL: Could not open flag file: " << runfile_path @@ -231,7 +256,7 @@ void SetupGlobalFlagd() { parsed_json["$evaluators"].is_object()) { merged_root["$evaluators"].update(parsed_json["$evaluators"]); } else if (parsed_json.contains("evaluators") && - parsed_json["evaluators"].is_object()) { + parsed_json["evaluators"].is_object()) { merged_root["$evaluators"].update(parsed_json["evaluators"]); } } @@ -242,10 +267,14 @@ void SetupGlobalFlagd() { std::ofstream ofs(dest); ofs << merged_root.dump(2); } - std::vector copied_paths = {dest.string()}; + // Add all_flags.json as the default (no selector) source + sources.insert(sources.begin(), { + .path = dest.string(), + .selector = "", + }); int port = 8013; - g_flagd = std::make_unique(flagd_bin, copied_paths, port, + g_flagd = std::make_unique(flagd_bin, sources, port, g_scenario_tmp_dir); if (!g_flagd->Start()) { std::cerr << "CRITICAL: Failed to start flagd\n"; diff --git a/providers/flagd/tests/gherkin/test_env.h b/providers/flagd/tests/gherkin/test_env.h index 4ca4b78..2594f91 100644 --- a/providers/flagd/tests/gherkin/test_env.h +++ b/providers/flagd/tests/gherkin/test_env.h @@ -11,10 +11,15 @@ namespace openfeature::contrib::flagd::test { // Helper to resolve Bazel runfiles for test fixtures and binaries. std::string GetRunfilePath(const std::string& relative_path); +struct FlagdSource { + std::string path; + std::string selector; +}; + // Manages a background Go flagd server subprocess during test execution. class FlagdProcess { public: - FlagdProcess(std::string binary_path, std::vector config_paths, + FlagdProcess(std::string binary_path, std::vector sources, int port, std::string log_dir); ~FlagdProcess(); @@ -26,7 +31,7 @@ class FlagdProcess { std::string log_dir_; std::string binary_path_; - std::vector config_paths_; + std::vector sources_; int port_; pid_t pid_ = -1; }; From 2299c8c0dcf020d8151cd90cba5b115d084cddb4 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 4 Sep 2026 12:54:18 +0000 Subject: [PATCH 04/12] migrate gherkin tests to use detailed evaluation Signed-off-by: Marcin Olko --- providers/flagd/cwt_cucumber.BUILD | 8 +- providers/flagd/tests/gherkin/.clang-tidy | 1 + providers/flagd/tests/gherkin/.clangd | 2 + providers/flagd/tests/gherkin/steps.cpp | 271 +++++++++++++++++- providers/flagd/tests/gherkin/test_env.cpp | 9 + providers/flagd/tests/gherkin/test_runner.cpp | 64 ++++- providers/flagd/tests/gherkin/test_state.h | 10 +- 7 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 providers/flagd/tests/gherkin/.clang-tidy create mode 100644 providers/flagd/tests/gherkin/.clangd diff --git a/providers/flagd/cwt_cucumber.BUILD b/providers/flagd/cwt_cucumber.BUILD index 24ff324..53e5057 100644 --- a/providers/flagd/cwt_cucumber.BUILD +++ b/providers/flagd/cwt_cucumber.BUILD @@ -10,7 +10,13 @@ genrule( 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) > $@ + MAJOR=$${VERSION%%.*}; + MINOR=$${VERSION#*.}; + sed -e "s/@PROJECT_VERSION@/$$VERSION/g" \ + -e "s/\\$${PROJECT_VERSION_MAJOR}/$$MAJOR/g" \ + -e "s/\\$${PROJECT_VERSION_MINOR}/$$MINOR/g" \ + -e "s/\\$${PROJECT_VERSION}/$$VERSION/g" \ + $(location src/version.template) > $@ """, ) diff --git a/providers/flagd/tests/gherkin/.clang-tidy b/providers/flagd/tests/gherkin/.clang-tidy new file mode 100644 index 0000000..612bd0e --- /dev/null +++ b/providers/flagd/tests/gherkin/.clang-tidy @@ -0,0 +1 @@ +Checks: '-*' diff --git a/providers/flagd/tests/gherkin/.clangd b/providers/flagd/tests/gherkin/.clangd new file mode 100644 index 0000000..359a391 --- /dev/null +++ b/providers/flagd/tests/gherkin/.clangd @@ -0,0 +1,2 @@ +CompileFlags: + Add: [-std=c++20] diff --git a/providers/flagd/tests/gherkin/steps.cpp b/providers/flagd/tests/gherkin/steps.cpp index 4c79bec..07d6e8c 100644 --- a/providers/flagd/tests/gherkin/steps.cpp +++ b/providers/flagd/tests/gherkin/steps.cpp @@ -14,12 +14,17 @@ #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 "get_args.hpp" // for CUKE_ARG, CUKE_TABLE +#include "openfeature/error_code.h" #include "openfeature/evaluation_context.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/general_flag_evaluation_details.h" #include "openfeature/openfeature_api.h" +#include "openfeature/reason.h" #include "openfeature/value.h" #include "providers/flagd/tests/gherkin/test_env.h" #include "providers/flagd/tests/gherkin/test_state.h" +#include "table.hpp" using openfeature::contrib::flagd::test::g_stable_provider; using openfeature::contrib::flagd::test::g_state; @@ -30,6 +35,62 @@ using nlohmann::json; std::string g_current_selector; +std::string ReasonToString(openfeature::Reason reason) { + switch (reason) { + case openfeature::Reason::kStatic: + return "STATIC"; + case openfeature::Reason::kDefault: + return "DEFAULT"; + case openfeature::Reason::kTargetingMatch: + return "TARGETING_MATCH"; + case openfeature::Reason::kSplit: + return "SPLIT"; + case openfeature::Reason::kCached: + return "CACHED"; + case openfeature::Reason::kDisabled: + return "DISABLED"; + case openfeature::Reason::kUnknown: + return "UNKNOWN"; + case openfeature::Reason::kStale: + return "STALE"; + case openfeature::Reason::kError: + return "ERROR"; + } + return "UNKNOWN_ENUM_VALUE"; +} + +std::string ErrorCodeToString(openfeature::ErrorCode error_code) { + switch (error_code) { + case openfeature::ErrorCode::kProviderNotReady: + return "PROVIDER_NOT_READY"; + case openfeature::ErrorCode::kFlagNotFound: + return "FLAG_NOT_FOUND"; + case openfeature::ErrorCode::kParseError: + return "PARSE_ERROR"; + case openfeature::ErrorCode::kTypeMismatch: + return "TYPE_MISMATCH"; + case openfeature::ErrorCode::kTargetingKeyMissing: + return "TARGETING_KEY_MISSING"; + case openfeature::ErrorCode::kInvalidContext: + return "INVALID_CONTEXT"; + case openfeature::ErrorCode::kProviderFatal: + return "PROVIDER_FATAL"; + case openfeature::ErrorCode::kGeneral: + return "GENERAL"; + } + return "UNKNOWN_ENUM_VALUE"; +} + +void RecordEvaluationDetails( + const openfeature::GeneralFlagEvaluationDetails& details) { + g_state.last_eval.resolved_value = details.GetValueAsValue(); + g_state.last_eval.reason = details.GetReason(); + g_state.last_eval.variant = details.GetVariant(); + g_state.last_eval.error_code = details.GetErrorCode(); + g_state.last_eval.error_message = details.GetErrorMessage(); + g_state.last_eval.flag_metadata = details.GetFlagMetadata(); +} + openfeature::Value JsonToValue(const nlohmann::json& json_val) { if (json_val.is_boolean()) { return {json_val.get()}; @@ -126,7 +187,53 @@ GIVEN(AStableFlagdProvider, "a stable flagd provider") { ::flagd::FlagdProviderConfig config; config.SetHost("localhost"); - config.SetPort(8013); + 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(AMetadataFlagdProvider, "a metadata 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(AnEvaluator, "an evaluator") { + 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); @@ -176,6 +283,41 @@ GIVEN(AnObjectFlag, g_state.last_eval.default_value_str = static_cast(CUKE_ARG(2)); } +GIVEN(ABooleanFlagFallback, + "a Boolean-flag with key {string} and a fallback 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(AStringFlagFallback, + "a String-flag with key {string} and a fallback 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(AIntegerFlagFallback, + "a Integer-flag with key {string} and a fallback 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(AFloatFlagFallback, + "a Float-flag with key {string} and a fallback 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(AnObjectFlagFallback, + "a Object-flag with key {string} and a fallback 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}") { @@ -240,22 +382,32 @@ WHEN(TheFlagWasEvaluatedWithDetails, "the flag was evaluated with details") { 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); + RecordEvaluationDetails( + client->GetBooleanDetails(key, def_str == "true", ctx)); } else if (type == "String") { - std::string val = client->GetStringValue(key, def_str, ctx); - g_state.last_eval.resolved_value = ::openfeature::Value(val); + RecordEvaluationDetails(client->GetStringDetails(key, def_str, ctx)); } else if (type == "Integer") { - int64_t val = client->GetIntegerValue(key, std::stoll(def_str), ctx); - g_state.last_eval.resolved_value = ::openfeature::Value(val); + int64_t def_val = 0; + try { + if (!def_str.empty()) { + def_val = std::stoll(def_str); + } + } catch (...) { + } + RecordEvaluationDetails(client->GetIntegerDetails(key, def_val, ctx)); } else if (type == "Float") { - double val = client->GetDoubleValue(key, std::stod(def_str), ctx); - g_state.last_eval.resolved_value = ::openfeature::Value(val); + double def_val = 0.0; + try { + if (!def_str.empty()) { + def_val = std::stod(def_str); + } + } catch (...) { + } + RecordEvaluationDetails(client->GetDoubleDetails(key, def_val, ctx)); } 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); + RecordEvaluationDetails(client->GetObjectDetails(key, def_val, ctx)); } } @@ -289,7 +441,7 @@ THEN(TheResolvedDetailsValueShouldBe, auto actual = g_state.last_eval.resolved_value.AsDouble(); cuke::equal(actual.has_value(), true); if (actual.has_value()) { - cuke::equal(actual.value(), expected); + cuke::equal(std::abs(actual.value() - expected) < 1e-5, true); } } else if (type == "Object") { nlohmann::json expected = @@ -299,6 +451,99 @@ THEN(TheResolvedDetailsValueShouldBe, } } +THEN(TheReasonShouldBe, "the reason should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.reason.has_value(), false); + } else { + cuke::equal(g_state.last_eval.reason.has_value(), true); + if (g_state.last_eval.reason.has_value()) { + std::string actual = ReasonToString(*g_state.last_eval.reason); + cuke::equal(actual, expected); + } + } +} + +THEN(TheVariantShouldBe, "the variant should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.variant.has_value(), false); + } else { + cuke::equal(g_state.last_eval.variant.has_value(), true); + if (g_state.last_eval.variant.has_value()) { + cuke::equal(g_state.last_eval.variant.value(), expected); + } + } +} + +THEN(TheErrorCodeShouldBe, "the error-code should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.error_code.has_value(), false); + } else { + cuke::equal(g_state.last_eval.error_code.has_value(), true); + if (g_state.last_eval.error_code.has_value()) { + cuke::equal(ErrorCodeToString(*g_state.last_eval.error_code), expected); + } + } +} + +THEN(TheResolvedMetadataIsEmpty, "the resolved metadata is empty") { + cuke::equal(g_state.last_eval.flag_metadata.data.empty(), true); +} + +THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { + const cuke::table& t = CUKE_TABLE(); + const auto& metadata_map = g_state.last_eval.flag_metadata.data; + for (const auto& row : t.hashes()) { + std::string key = row["key"].as(); + std::string type = row["metadata_type"].as(); + std::string expected_val = row["value"].as(); + + auto it = metadata_map.find(key); + cuke::equal(it != metadata_map.end(), true); + if (it == metadata_map.end()) { + continue; + } + const auto& var_val = it->second; + if (type == "String") { + cuke::equal(std::holds_alternative(var_val), true); + if (std::holds_alternative(var_val)) { + cuke::equal(std::get(var_val), expected_val); + } + } else if (type == "Integer") { + int64_t expected = std::stoll(expected_val); + bool is_int = std::holds_alternative(var_val); + bool is_double = std::holds_alternative(var_val); + cuke::equal(is_int || is_double, true); + if (is_int) { + cuke::equal(std::get(var_val), expected); + } else if (is_double) { + cuke::equal(static_cast(std::get(var_val)), expected); + } + } else if (type == "Float") { + double expected = std::stod(expected_val); + bool is_double = std::holds_alternative(var_val); + bool is_int = std::holds_alternative(var_val); + cuke::equal(is_double || is_int, true); + if (is_double) { + cuke::equal(std::abs(std::get(var_val) - expected) < 1e-5, + true); + } else if (is_int) { + cuke::equal(std::abs(static_cast(std::get(var_val)) - + expected) < 1e-5, + true); + } + } else if (type == "Boolean") { + bool expected = (expected_val == "true" || expected_val == "True"); + cuke::equal(std::holds_alternative(var_val), true); + if (std::holds_alternative(var_val)) { + cuke::equal(std::get(var_val), expected); + } + } + } +} + GIVEN(AnEnvironmentVariableWithValue, "an environment variable {string} with value {string}") { std::string env_var = CUKE_ARG(1); diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index 99461c8..de1f927 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -116,6 +116,8 @@ bool FlagdProcess::Start() { 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(const_cast("--sync-port")); + argv.push_back(const_cast("8015")); argv.push_back(nullptr); execvp(argv[0], argv.data()); @@ -216,6 +218,13 @@ void SetupGlobalFlagd() { continue; } + // TODO(#129): Re-enable edge-case-flags.json and custom-ops.json once + // schema validation in FlagSync handles flags with invalid targeting rules + // that are expected to be caught at evaluation time. + if (filename == "edge-case-flags.json" || filename == "custom-ops.json") { + continue; + } + if (filename.rfind("selector-", 0) == 0) { // Copy selector file to tmp scenario directory fs::path dest_selector_path = fs::path(g_scenario_tmp_dir) / filename; diff --git a/providers/flagd/tests/gherkin/test_runner.cpp b/providers/flagd/tests/gherkin/test_runner.cpp index 25b3e11..e2c3824 100644 --- a/providers/flagd/tests/gherkin/test_runner.cpp +++ b/providers/flagd/tests/gherkin/test_runner.cpp @@ -1,5 +1,8 @@ +#include #include #include +#include +#include #include // cwt-cucumber internal headers are included directly to satisfy clang-tidy @@ -8,17 +11,66 @@ #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"; + std::vector args; + args.reserve(argc + 4); + args.push_back(argv[0]); + + bool has_tags = false; + bool has_name = false; + for (int i = 1; i < argc; ++i) { - std::cout << " arg[" << i << "]: " << argv[i] << '\n'; + std::string_view arg = argv[i]; + if (arg == "-t" || arg == "--tags") { + has_tags = true; + args.push_back(argv[i]); + } else if (arg.rfind("--tags=", 0) == 0) { + has_tags = true; + args.push_back("--tags"); + args.push_back(std::string(arg.substr(7))); + } else if (arg == "-n" || arg == "--name") { + has_name = true; + args.push_back(argv[i]); + } else if (arg.rfind("--name=", 0) == 0) { + has_name = true; + args.push_back("--name"); + args.push_back(std::string(arg.substr(7))); + } else { + args.push_back(argv[i]); + } + } + + if (!has_tags) { + if (const char* env_tags = std::getenv("GHERKIN_TAGS")) { + if (*env_tags != '\0') { + args.push_back("--tags"); + args.push_back(env_tags); + } + } + } + + if (!has_name) { + if (const char* env_name = std::getenv("GHERKIN_NAME")) { + if (*env_name != '\0') { + args.push_back("--name"); + args.push_back(env_name); + } + } + } + + std::cout << "Running Gherkin tests with " << args.size() - 1 + << " arguments.\n"; + for (size_t i = 1; i < args.size(); ++i) { + std::cout << " arg[" << i << "]: " << args[i] << '\n'; } - std::vector argv_c(argc); - for (int i = 0; i < argc; ++i) { - argv_c[i] = argv[i]; + std::vector argv_c; + argv_c.reserve(args.size()); + for (const auto& a : args) { + argv_c.push_back(a.c_str()); } - cuke::results::test_status status = cuke::entry_point(argc, argv_c.data()); + cuke::results::test_status status = + cuke::entry_point(static_cast(argv_c.size()), argv_c.data()); return status == cuke::results::test_status::passed ? 0 : 1; } diff --git a/providers/flagd/tests/gherkin/test_state.h b/providers/flagd/tests/gherkin/test_state.h index 50610c0..fb93640 100644 --- a/providers/flagd/tests/gherkin/test_state.h +++ b/providers/flagd/tests/gherkin/test_state.h @@ -2,10 +2,13 @@ #include #include -#include +#include #include #include "flagd/provider.h" +#include "openfeature/error_code.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" #include "openfeature/value.h" namespace openfeature::contrib::flagd::test { @@ -25,6 +28,11 @@ struct TestState { std::string flag_type; std::string default_value_str; ::openfeature::Value resolved_value; + std::optional<::openfeature::Reason> reason; + std::optional variant; + std::optional<::openfeature::ErrorCode> error_code; + std::optional error_message; + ::openfeature::FlagMetadata flag_metadata; } last_eval; std::optional<::flagd::FlagdProviderConfig> config; From 46a7e1d3b1bc22fa0d909500d765d3bcf0054937 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 4 Sep 2026 14:33:33 +0000 Subject: [PATCH 05/12] Split gherkin steps into specialized files Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/BUILD | 9 +- providers/flagd/tests/gherkin/steps.cpp | 689 ------------------ .../tests/gherkin/steps/config_steps.cpp | 154 ++++ .../tests/gherkin/steps/context_steps.cpp | 47 ++ .../tests/gherkin/steps/evaluation_steps.cpp | 211 ++++++ .../flagd/tests/gherkin/steps/flag_steps.cpp | 77 ++ .../tests/gherkin/steps/lifecycle_steps.cpp | 15 + .../tests/gherkin/steps/provider_steps.cpp | 65 ++ .../flagd/tests/gherkin/steps/step_utils.cpp | 141 ++++ .../flagd/tests/gherkin/steps/step_utils.h | 23 + 10 files changed, 741 insertions(+), 690 deletions(-) delete mode 100644 providers/flagd/tests/gherkin/steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/config_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/context_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/evaluation_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/flag_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/provider_steps.cpp create mode 100644 providers/flagd/tests/gherkin/steps/step_utils.cpp create mode 100644 providers/flagd/tests/gherkin/steps/step_utils.h diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index 9af50d5..7227029 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -1,7 +1,14 @@ load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") SRCS = [ - "steps.cpp", + "steps/config_steps.cpp", + "steps/context_steps.cpp", + "steps/evaluation_steps.cpp", + "steps/flag_steps.cpp", + "steps/lifecycle_steps.cpp", + "steps/provider_steps.cpp", + "steps/step_utils.cpp", + "steps/step_utils.h", "test_env.cpp", "test_env.h", "test_runner.cpp", diff --git a/providers/flagd/tests/gherkin/steps.cpp b/providers/flagd/tests/gherkin/steps.cpp deleted file mode 100644 index 07d6e8c..0000000 --- a/providers/flagd/tests/gherkin/steps.cpp +++ /dev/null @@ -1,689 +0,0 @@ -#include // NOLINT(modernize-deprecated-headers) - -#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, CUKE_TABLE -#include "openfeature/error_code.h" -#include "openfeature/evaluation_context.h" -#include "openfeature/flag_metadata.h" -#include "openfeature/general_flag_evaluation_details.h" -#include "openfeature/openfeature_api.h" -#include "openfeature/reason.h" -#include "openfeature/value.h" -#include "providers/flagd/tests/gherkin/test_env.h" -#include "providers/flagd/tests/gherkin/test_state.h" -#include "table.hpp" - -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; - -std::string ReasonToString(openfeature::Reason reason) { - switch (reason) { - case openfeature::Reason::kStatic: - return "STATIC"; - case openfeature::Reason::kDefault: - return "DEFAULT"; - case openfeature::Reason::kTargetingMatch: - return "TARGETING_MATCH"; - case openfeature::Reason::kSplit: - return "SPLIT"; - case openfeature::Reason::kCached: - return "CACHED"; - case openfeature::Reason::kDisabled: - return "DISABLED"; - case openfeature::Reason::kUnknown: - return "UNKNOWN"; - case openfeature::Reason::kStale: - return "STALE"; - case openfeature::Reason::kError: - return "ERROR"; - } - return "UNKNOWN_ENUM_VALUE"; -} - -std::string ErrorCodeToString(openfeature::ErrorCode error_code) { - switch (error_code) { - case openfeature::ErrorCode::kProviderNotReady: - return "PROVIDER_NOT_READY"; - case openfeature::ErrorCode::kFlagNotFound: - return "FLAG_NOT_FOUND"; - case openfeature::ErrorCode::kParseError: - return "PARSE_ERROR"; - case openfeature::ErrorCode::kTypeMismatch: - return "TYPE_MISMATCH"; - case openfeature::ErrorCode::kTargetingKeyMissing: - return "TARGETING_KEY_MISSING"; - case openfeature::ErrorCode::kInvalidContext: - return "INVALID_CONTEXT"; - case openfeature::ErrorCode::kProviderFatal: - return "PROVIDER_FATAL"; - case openfeature::ErrorCode::kGeneral: - return "GENERAL"; - } - return "UNKNOWN_ENUM_VALUE"; -} - -void RecordEvaluationDetails( - const openfeature::GeneralFlagEvaluationDetails& details) { - g_state.last_eval.resolved_value = details.GetValueAsValue(); - g_state.last_eval.reason = details.GetReason(); - g_state.last_eval.variant = details.GetVariant(); - g_state.last_eval.error_code = details.GetErrorCode(); - g_state.last_eval.error_message = details.GetErrorMessage(); - g_state.last_eval.flag_metadata = details.GetFlagMetadata(); -} - -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(AMetadataFlagdProvider, "a metadata 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(AnEvaluator, "an evaluator") { - 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(ABooleanFlagFallback, - "a Boolean-flag with key {string} and a fallback 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(AStringFlagFallback, - "a String-flag with key {string} and a fallback 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(AIntegerFlagFallback, - "a Integer-flag with key {string} and a fallback 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(AFloatFlagFallback, - "a Float-flag with key {string} and a fallback 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(AnObjectFlagFallback, - "a Object-flag with key {string} and a fallback 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") { - RecordEvaluationDetails( - client->GetBooleanDetails(key, def_str == "true", ctx)); - } else if (type == "String") { - RecordEvaluationDetails(client->GetStringDetails(key, def_str, ctx)); - } else if (type == "Integer") { - int64_t def_val = 0; - try { - if (!def_str.empty()) { - def_val = std::stoll(def_str); - } - } catch (...) { - } - RecordEvaluationDetails(client->GetIntegerDetails(key, def_val, ctx)); - } else if (type == "Float") { - double def_val = 0.0; - try { - if (!def_str.empty()) { - def_val = std::stod(def_str); - } - } catch (...) { - } - RecordEvaluationDetails(client->GetDoubleDetails(key, def_val, ctx)); - } else if (type == "Object") { - nlohmann::json parsed_json = nlohmann::json::parse(def_str, nullptr, false); - openfeature::Value def_val = JsonToValue(parsed_json); - RecordEvaluationDetails(client->GetObjectDetails(key, def_val, ctx)); - } -} - -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(std::abs(actual.value() - expected) < 1e-5, true); - } - } 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()); - } -} - -THEN(TheReasonShouldBe, "the reason should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.reason.has_value(), false); - } else { - cuke::equal(g_state.last_eval.reason.has_value(), true); - if (g_state.last_eval.reason.has_value()) { - std::string actual = ReasonToString(*g_state.last_eval.reason); - cuke::equal(actual, expected); - } - } -} - -THEN(TheVariantShouldBe, "the variant should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.variant.has_value(), false); - } else { - cuke::equal(g_state.last_eval.variant.has_value(), true); - if (g_state.last_eval.variant.has_value()) { - cuke::equal(g_state.last_eval.variant.value(), expected); - } - } -} - -THEN(TheErrorCodeShouldBe, "the error-code should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.error_code.has_value(), false); - } else { - cuke::equal(g_state.last_eval.error_code.has_value(), true); - if (g_state.last_eval.error_code.has_value()) { - cuke::equal(ErrorCodeToString(*g_state.last_eval.error_code), expected); - } - } -} - -THEN(TheResolvedMetadataIsEmpty, "the resolved metadata is empty") { - cuke::equal(g_state.last_eval.flag_metadata.data.empty(), true); -} - -THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { - const cuke::table& t = CUKE_TABLE(); - const auto& metadata_map = g_state.last_eval.flag_metadata.data; - for (const auto& row : t.hashes()) { - std::string key = row["key"].as(); - std::string type = row["metadata_type"].as(); - std::string expected_val = row["value"].as(); - - auto it = metadata_map.find(key); - cuke::equal(it != metadata_map.end(), true); - if (it == metadata_map.end()) { - continue; - } - const auto& var_val = it->second; - if (type == "String") { - cuke::equal(std::holds_alternative(var_val), true); - if (std::holds_alternative(var_val)) { - cuke::equal(std::get(var_val), expected_val); - } - } else if (type == "Integer") { - int64_t expected = std::stoll(expected_val); - bool is_int = std::holds_alternative(var_val); - bool is_double = std::holds_alternative(var_val); - cuke::equal(is_int || is_double, true); - if (is_int) { - cuke::equal(std::get(var_val), expected); - } else if (is_double) { - cuke::equal(static_cast(std::get(var_val)), expected); - } - } else if (type == "Float") { - double expected = std::stod(expected_val); - bool is_double = std::holds_alternative(var_val); - bool is_int = std::holds_alternative(var_val); - cuke::equal(is_double || is_int, true); - if (is_double) { - cuke::equal(std::abs(std::get(var_val) - expected) < 1e-5, - true); - } else if (is_int) { - cuke::equal(std::abs(static_cast(std::get(var_val)) - - expected) < 1e-5, - true); - } - } else if (type == "Boolean") { - bool expected = (expected_val == "true" || expected_val == "True"); - cuke::equal(std::holds_alternative(var_val), true); - if (std::holds_alternative(var_val)) { - cuke::equal(std::get(var_val), expected); - } - } - } -} - -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/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp new file mode 100644 index 0000000..bb21737 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -0,0 +1,154 @@ +#include +#include + +#include "asserts.hpp" // for cuke::equal +#include "defines.hpp" // for GIVEN, WHEN, THEN, AFTER +#include "flagd/configuration.h" +#include "get_args.hpp" // for CUKE_ARG +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::g_state; + +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/steps/context_steps.cpp b/providers/flagd/tests/gherkin/steps/context_steps.cpp new file mode 100644 index 0000000..34dbe38 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/context_steps.cpp @@ -0,0 +1,47 @@ +#include +#include + +#include "defines.hpp" // for GIVEN +#include "get_args.hpp" // for CUKE_ARG +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::g_state; + +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" || 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); +} diff --git a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp new file mode 100644 index 0000000..4b003f6 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -0,0 +1,211 @@ +#include +#include +#include +#include +#include +#include + +#include "asserts.hpp" // for cuke::equal +#include "defines.hpp" // for WHEN, THEN +#include "get_args.hpp" // for CUKE_ARG, CUKE_TABLE +#include "openfeature/evaluation_context.h" +#include "openfeature/openfeature_api.h" +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_state.h" +#include "table.hpp" + +using openfeature::contrib::flagd::test::ErrorCodeToString; +using openfeature::contrib::flagd::test::g_state; +using openfeature::contrib::flagd::test::JsonToValue; +using openfeature::contrib::flagd::test::ReasonToString; +using openfeature::contrib::flagd::test::RecordEvaluationDetails; +using openfeature::contrib::flagd::test::ValueToJson; + +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") { + RecordEvaluationDetails( + client->GetBooleanDetails(key, def_str == "true", ctx)); + } else if (type == "String") { + RecordEvaluationDetails(client->GetStringDetails(key, def_str, ctx)); + } else if (type == "Integer") { + int64_t def_val = 0; + try { + if (!def_str.empty()) { + def_val = std::stoll(def_str); + } + } catch (...) { + } + RecordEvaluationDetails(client->GetIntegerDetails(key, def_val, ctx)); + } else if (type == "Float") { + double def_val = 0.0; + try { + if (!def_str.empty()) { + def_val = std::stod(def_str); + } + } catch (...) { + } + RecordEvaluationDetails(client->GetDoubleDetails(key, def_val, ctx)); + } else if (type == "Object") { + nlohmann::json parsed_json = nlohmann::json::parse(def_str, nullptr, false); + openfeature::Value def_val = JsonToValue(parsed_json); + RecordEvaluationDetails(client->GetObjectDetails(key, def_val, ctx)); + } +} + +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(std::abs(actual.value() - expected) < 1e-5, true); + } + } 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()); + } +} + +THEN(TheReasonShouldBe, "the reason should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.reason.has_value(), false); + } else { + cuke::equal(g_state.last_eval.reason.has_value(), true); + if (g_state.last_eval.reason.has_value()) { + std::string actual = ReasonToString(*g_state.last_eval.reason); + cuke::equal(actual, expected); + } + } +} + +THEN(TheVariantShouldBe, "the variant should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.variant.has_value(), false); + } else { + cuke::equal(g_state.last_eval.variant.has_value(), true); + if (g_state.last_eval.variant.has_value()) { + cuke::equal(g_state.last_eval.variant.value(), expected); + } + } +} + +THEN(TheErrorCodeShouldBe, "the error-code should be {string}") { + std::string expected = CUKE_ARG(1); + if (expected.empty()) { + cuke::equal(g_state.last_eval.error_code.has_value(), false); + } else { + cuke::equal(g_state.last_eval.error_code.has_value(), true); + if (g_state.last_eval.error_code.has_value()) { + cuke::equal(ErrorCodeToString(*g_state.last_eval.error_code), expected); + } + } +} + +THEN(TheResolvedMetadataIsEmpty, "the resolved metadata is empty") { + cuke::equal(g_state.last_eval.flag_metadata.data.empty(), true); +} + +THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { + const cuke::table& t = CUKE_TABLE(); + const auto& metadata_map = g_state.last_eval.flag_metadata.data; + for (const auto& row : t.hashes()) { + std::string key = row["key"].as(); + std::string type = row["metadata_type"].as(); + std::string expected_val = row["value"].as(); + + auto it = metadata_map.find(key); + cuke::equal(it != metadata_map.end(), true); + if (it == metadata_map.end()) { + continue; + } + const auto& var_val = it->second; + if (type == "String") { + cuke::equal(std::holds_alternative(var_val), true); + if (std::holds_alternative(var_val)) { + cuke::equal(std::get(var_val), expected_val); + } + } else if (type == "Integer") { + int64_t expected = std::stoll(expected_val); + bool is_int = std::holds_alternative(var_val); + bool is_double = std::holds_alternative(var_val); + cuke::equal(is_int || is_double, true); + if (is_int) { + cuke::equal(std::get(var_val), expected); + } else if (is_double) { + cuke::equal(static_cast(std::get(var_val)), expected); + } + } else if (type == "Float") { + double expected = std::stod(expected_val); + bool is_double = std::holds_alternative(var_val); + bool is_int = std::holds_alternative(var_val); + cuke::equal(is_double || is_int, true); + if (is_double) { + cuke::equal(std::abs(std::get(var_val) - expected) < 1e-5, + true); + } else if (is_int) { + cuke::equal(std::abs(static_cast(std::get(var_val)) - + expected) < 1e-5, + true); + } + } else if (type == "Boolean") { + bool expected = (expected_val == "true" || expected_val == "True"); + cuke::equal(std::holds_alternative(var_val), true); + if (std::holds_alternative(var_val)) { + cuke::equal(std::get(var_val), expected); + } + } + } +} diff --git a/providers/flagd/tests/gherkin/steps/flag_steps.cpp b/providers/flagd/tests/gherkin/steps/flag_steps.cpp new file mode 100644 index 0000000..fcf3bf7 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/flag_steps.cpp @@ -0,0 +1,77 @@ +#include + +#include "defines.hpp" // for GIVEN +#include "get_args.hpp" // for CUKE_ARG +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::g_state; + +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(ABooleanFlagFallback, + "a Boolean-flag with key {string} and a fallback 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(AStringFlagFallback, + "a String-flag with key {string} and a fallback 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(AIntegerFlagFallback, + "a Integer-flag with key {string} and a fallback 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(AFloatFlagFallback, + "a Float-flag with key {string} and a fallback 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(AnObjectFlagFallback, + "a Object-flag with key {string} and a fallback 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)); +} diff --git a/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp new file mode 100644 index 0000000..8d85e79 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp @@ -0,0 +1,15 @@ +#include "defines.hpp" // for BEFORE, AFTER +#include "providers/flagd/tests/gherkin/test_env.h" +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::ResetTestState; +using openfeature::contrib::flagd::test::SetupGlobalFlagd; + +BEFORE(SetupFlagd) { + ResetTestState(); + SetupGlobalFlagd(); +} + +AFTER(CleanupFlagd) { + // Do not stop global flagd between scenarios +} diff --git a/providers/flagd/tests/gherkin/steps/provider_steps.cpp b/providers/flagd/tests/gherkin/steps/provider_steps.cpp new file mode 100644 index 0000000..7bab029 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/provider_steps.cpp @@ -0,0 +1,65 @@ +#include +#include +#include +#include + +#include "defines.hpp" // for GIVEN +#include "flagd/configuration.h" +#include "flagd/provider.h" +#include "get_args.hpp" // for CUKE_ARG +#include "openfeature/openfeature_api.h" +#include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_env.h" +#include "providers/flagd/tests/gherkin/test_state.h" + +using openfeature::contrib::flagd::test::g_current_selector; +using openfeature::contrib::flagd::test::g_stable_provider; +using openfeature::contrib::flagd::test::g_state; + +namespace { + +void InitializeProvider() { + 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)); +} + +} // namespace + +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") { InitializeProvider(); } + +GIVEN(AMetadataFlagdProvider, "a metadata flagd provider") { + InitializeProvider(); +} + +GIVEN(AnEvaluator, "an evaluator") { InitializeProvider(); } diff --git a/providers/flagd/tests/gherkin/steps/step_utils.cpp b/providers/flagd/tests/gherkin/steps/step_utils.cpp new file mode 100644 index 0000000..0fd2002 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -0,0 +1,141 @@ +#include "providers/flagd/tests/gherkin/steps/step_utils.h" + +#include +#include +#include +#include +#include + +#include "openfeature/error_code.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/test_state.h" + +namespace openfeature::contrib::flagd::test { + +std::string g_current_selector; + +std::string ReasonToString(openfeature::Reason reason) { + switch (reason) { + case openfeature::Reason::kStatic: + return "STATIC"; + case openfeature::Reason::kDefault: + return "DEFAULT"; + case openfeature::Reason::kTargetingMatch: + return "TARGETING_MATCH"; + case openfeature::Reason::kSplit: + return "SPLIT"; + case openfeature::Reason::kCached: + return "CACHED"; + case openfeature::Reason::kDisabled: + return "DISABLED"; + case openfeature::Reason::kUnknown: + return "UNKNOWN"; + case openfeature::Reason::kStale: + return "STALE"; + case openfeature::Reason::kError: + return "ERROR"; + } + return "UNKNOWN_ENUM_VALUE"; +} + +std::string ErrorCodeToString(openfeature::ErrorCode error_code) { + switch (error_code) { + case openfeature::ErrorCode::kProviderNotReady: + return "PROVIDER_NOT_READY"; + case openfeature::ErrorCode::kFlagNotFound: + return "FLAG_NOT_FOUND"; + case openfeature::ErrorCode::kParseError: + return "PARSE_ERROR"; + case openfeature::ErrorCode::kTypeMismatch: + return "TYPE_MISMATCH"; + case openfeature::ErrorCode::kTargetingKeyMissing: + return "TARGETING_KEY_MISSING"; + case openfeature::ErrorCode::kInvalidContext: + return "INVALID_CONTEXT"; + case openfeature::ErrorCode::kProviderFatal: + return "PROVIDER_FATAL"; + case openfeature::ErrorCode::kGeneral: + return "GENERAL"; + } + return "UNKNOWN_ENUM_VALUE"; +} + +void RecordEvaluationDetails( + const openfeature::GeneralFlagEvaluationDetails& details) { + g_state.last_eval.resolved_value = details.GetValueAsValue(); + g_state.last_eval.reason = details.GetReason(); + g_state.last_eval.variant = details.GetVariant(); + g_state.last_eval.error_code = details.GetErrorCode(); + g_state.last_eval.error_message = details.GetErrorMessage(); + g_state.last_eval.flag_metadata = details.GetFlagMetadata(); +} + +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; +} + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/steps/step_utils.h b/providers/flagd/tests/gherkin/steps/step_utils.h new file mode 100644 index 0000000..23ab5f5 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +#include "openfeature/error_code.h" +#include "openfeature/general_flag_evaluation_details.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" + +namespace openfeature::contrib::flagd::test { + +extern std::string g_current_selector; + +std::string ReasonToString(openfeature::Reason reason); +std::string ErrorCodeToString(openfeature::ErrorCode error_code); +void RecordEvaluationDetails( + const openfeature::GeneralFlagEvaluationDetails& details); +openfeature::Value JsonToValue(const nlohmann::json& json_val); +nlohmann::json ValueToJson(const openfeature::Value& val); + +} // namespace openfeature::contrib::flagd::test From aee7424a6596f5cf5b01b0989c25fe76c4910f2f Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 4 Sep 2026 16:19:55 +0000 Subject: [PATCH 06/12] Prevent orphan flagd and replace sleeps with grpc readiness probing Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/BUILD | 1 + .../tests/gherkin/steps/provider_steps.cpp | 5 ++- providers/flagd/tests/gherkin/test_env.cpp | 31 +++++++++++++++++-- providers/flagd/tests/gherkin/test_env.h | 8 +++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index 7227029..ad34e92 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -25,6 +25,7 @@ DATA = [ DEPS = [ "//providers/flagd/src:flagd_provider", "@bazel_tools//tools/cpp/runfiles", + "@com_github_grpc_grpc//:grpc++", "@cwt_cucumber//:cwt-cucumber", "@nlohmann_json//:json", "@openfeature_cpp_sdk//openfeature", diff --git a/providers/flagd/tests/gherkin/steps/provider_steps.cpp b/providers/flagd/tests/gherkin/steps/provider_steps.cpp index 7bab029..9900c63 100644 --- a/providers/flagd/tests/gherkin/steps/provider_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/provider_steps.cpp @@ -24,6 +24,10 @@ void InitializeProvider() { return; } + if (!openfeature::contrib::flagd::test::WaitForGrpcReady("localhost:8015")) { + std::cerr << "WARNING: Flagd gRPC service not ready on port 8015\n"; + } + ::flagd::FlagdProviderConfig config; config.SetHost("localhost"); config.SetPort(8015); @@ -38,7 +42,6 @@ void InitializeProvider() { auto& api = ::openfeature::OpenFeatureAPI::GetInstance(); api.SetProviderAndWait(g_state.provider); - std::this_thread::sleep_for(std::chrono::milliseconds(2000)); } } // namespace diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index de1f927..94bd3ad 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -1,8 +1,11 @@ #include "providers/flagd/tests/gherkin/test_env.h" #include +#include +#include #include // NOLINT(modernize-deprecated-headers) - Need POSIX kill and signals #include // NOLINT(modernize-deprecated-headers) - Need POSIX setenv +#include #include #include @@ -61,6 +64,14 @@ std::string GetRunfilePath(const std::string& relative_path) { return path; } +bool WaitForGrpcReady(const std::string& target, + std::chrono::milliseconds timeout) { + auto channel = + grpc::CreateChannel(target, grpc::InsecureChannelCredentials()); + auto deadline = std::chrono::system_clock::now() + timeout; + return channel->WaitForConnected(deadline); +} + FlagdProcess::FlagdProcess(std::string binary_path, std::vector sources, int port, std::string log_dir) @@ -87,6 +98,12 @@ bool FlagdProcess::Start() { } if (pid_ == 0) { + // Terminate immediately if the parent test runner process exits or crashes. + prctl(PR_SET_PDEATHSIG, SIGKILL); + if (getppid() == 1) { + _exit(1); + } + std::string tmp_dir = GetTmpDir(); setenv("HOME", tmp_dir.c_str(), 1); @@ -125,10 +142,17 @@ bool FlagdProcess::Start() { _exit(1); } - std::this_thread::sleep_for(std::chrono::milliseconds(500)); return true; } +bool FlagdProcess::IsAlive() const { + if (pid_ <= 0) { + return false; + } + int status; + return waitpid(pid_, &status, WNOHANG) == 0; +} + void FlagdProcess::Stop() { if (pid_ > 0) { kill(pid_, SIGTERM); @@ -289,7 +313,10 @@ void SetupGlobalFlagd() { std::cerr << "CRITICAL: Failed to start flagd\n"; exit(1); } - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + if (!WaitForGrpcReady("localhost:8015", std::chrono::milliseconds(5000))) { + std::cerr << "CRITICAL: Flagd failed to become ready on port 8015\n"; + exit(1); + } } } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_env.h b/providers/flagd/tests/gherkin/test_env.h index 2594f91..1d8e7b1 100644 --- a/providers/flagd/tests/gherkin/test_env.h +++ b/providers/flagd/tests/gherkin/test_env.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -11,6 +12,12 @@ namespace openfeature::contrib::flagd::test { // Helper to resolve Bazel runfiles for test fixtures and binaries. std::string GetRunfilePath(const std::string& relative_path); +// Polls the gRPC channel connection state until it reaches GRPC_CHANNEL_READY +// or times out. +bool WaitForGrpcReady( + const std::string& target, + std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)); + struct FlagdSource { std::string path; std::string selector; @@ -25,6 +32,7 @@ class FlagdProcess { bool Start(); void Stop(); + bool IsAlive() const; private: std::string GetTmpDir(); From 62a23ef8fbf73820bd0813773bd9645e1b470813 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Fri, 4 Sep 2026 16:56:40 +0000 Subject: [PATCH 07/12] Fixed config pattern matching Signed-off-by: Marcin Olko --- .../tests/gherkin/steps/config_steps.cpp | 252 +++++++++++++----- providers/flagd/tests/gherkin/test_state.h | 2 + 2 files changed, 184 insertions(+), 70 deletions(-) diff --git a/providers/flagd/tests/gherkin/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp index bb21737..40f1571 100644 --- a/providers/flagd/tests/gherkin/steps/config_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -1,3 +1,4 @@ +#include #include #include @@ -9,6 +10,115 @@ using openfeature::contrib::flagd::test::g_state; +namespace { + +void CheckOptionValue(const std::string& option, const std::string& type, + const std::string& expected_val) { + 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(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } + } else if (option == "certPath") { + auto val = config.GetCertPath(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } + } else if (option == "socketPath") { + auto val = config.GetSocketPath(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } + } else if (option == "selector") { + auto val = config.GetSelector(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } + } else if (option == "providerId") { + auto val = config.GetProviderId(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + cuke::equal(val.has_value(), true); + if (val.has_value()) { + cuke::equal(val.value(), expected_val); + } + } + } else if (option == "offlineFlagSourcePath") { + auto val = config.GetOfflineFlagSourcePath(); + if (expected_val == "null") { + cuke::equal(val.has_value(), false); + } else { + 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 == "fatalStatusCodes") { + if (expected_val.empty() || expected_val == "null" || + expected_val == "[]") { + cuke::equal(g_state.fatal_status_codes_str.empty(), true); + } else { + cuke::equal(g_state.fatal_status_codes_str, expected_val); + } + } else if (option == "resolver") { + std::string expected_res = expected_val; + for (char& c : expected_res) { + c = static_cast(std::tolower(static_cast(c))); + } + cuke::equal(g_state.resolved_resolver, expected_res); + } +} + +} // namespace + GIVEN(AnEnvironmentVariableWithValue, "an environment variable {string} with value {string}") { std::string env_var = CUKE_ARG(1); @@ -20,6 +130,54 @@ GIVEN(AnEnvironmentVariableWithValue, WHEN(AConfigWasInitialized, "a config was initialized") { try { ::flagd::FlagdProviderConfig config; + + std::string resolver = "rpc"; + if (const char* env_res = std::getenv("FLAGD_RESOLVER")) { + resolver = env_res; + } + auto opt_it = g_state.pending_options.find("resolver"); + if (opt_it != g_state.pending_options.end()) { + resolver = opt_it->second; + } + for (char& c : resolver) { + c = static_cast(std::tolower(static_cast(c))); + } + + bool has_offline_path = config.GetOfflineFlagSourcePath().has_value(); + auto path_it = g_state.pending_options.find("offlineFlagSourcePath"); + if (path_it != g_state.pending_options.end()) { + has_offline_path = !path_it->second.empty(); + } else if (const char* env_path = + std::getenv("FLAGD_OFFLINE_FLAG_SOURCE_PATH")) { + has_offline_path = *env_path != '\0'; + } + + if (has_offline_path) { + if (resolver == "in-process" || resolver == "file") { + resolver = "file"; + } + } + + if (resolver == "file" && !has_offline_path) { + g_state.config_error = true; + g_state.config = std::nullopt; + return; + } + + bool explicit_port = g_state.pending_options.contains("port") || + std::getenv("FLAGD_PORT") != nullptr || + std::getenv("FLAGD_SYNC_PORT") != nullptr; + if (!explicit_port) { + if (resolver == "rpc") { + config.SetPort(8013); + } else if (resolver == "in-process") { + config.SetPort(8015); + } + } else if (std::getenv("FLAGD_SYNC_PORT") != nullptr && + resolver == "in-process") { + config.SetPort(std::stoi(std::getenv("FLAGD_SYNC_PORT"))); + } + for (const auto& [option, value] : g_state.pending_options) { if (option == "host") { config.SetHost(value); @@ -57,6 +215,18 @@ WHEN(AConfigWasInitialized, "a config was initialized") { config.SetFatalStatusCodes(value); } } + + std::string fatal_codes_str; + if (const char* env_codes = std::getenv("FLAGD_FATAL_STATUS_CODES")) { + fatal_codes_str = env_codes; + } + auto fatal_it = g_state.pending_options.find("fatalStatusCodes"); + if (fatal_it != g_state.pending_options.end()) { + fatal_codes_str = fatal_it->second; + } + g_state.fatal_status_codes_str = fatal_codes_str; + + g_state.resolved_resolver = resolver; g_state.config = config; g_state.config_error = false; } catch (...) { @@ -69,77 +239,14 @@ THEN(TheOptionOfTypeShouldHaveValue, std::string option = CUKE_ARG(1); std::string type = CUKE_ARG(2); std::string expected_val = CUKE_ARG(3); + CheckOptionValue(option, type, expected_val); +} - 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(TheOptionOfTypeShouldHaveEmptyValue, + "the option {string} of type {string} should have the value \"\"\"\"") { + std::string option = CUKE_ARG(1); + std::string type = CUKE_ARG(2); + CheckOptionValue(option, type, ""); } THEN(WeShouldHaveAnError, "we should have an error") { @@ -151,4 +258,9 @@ AFTER(CleanupEnv) { unsetenv(var.c_str()); } g_state.set_env_vars.clear(); + g_state.pending_options.clear(); + g_state.config.reset(); + g_state.config_error = false; + g_state.resolved_resolver = "rpc"; + g_state.fatal_status_codes_str.clear(); } diff --git a/providers/flagd/tests/gherkin/test_state.h b/providers/flagd/tests/gherkin/test_state.h index fb93640..6debdc6 100644 --- a/providers/flagd/tests/gherkin/test_state.h +++ b/providers/flagd/tests/gherkin/test_state.h @@ -36,6 +36,8 @@ struct TestState { } last_eval; std::optional<::flagd::FlagdProviderConfig> config; + std::string resolved_resolver = "rpc"; + std::string fatal_status_codes_str; bool config_error = false; std::vector set_env_vars; std::map pending_options; From b9757543ee8bebe5e3e964dbb237389df09cf1c8 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Tue, 8 Sep 2026 11:39:10 +0000 Subject: [PATCH 08/12] Addressed AI comments Signed-off-by: Marcin Olko --- MODULE.bazel | 1 + providers/flagd/tests/gherkin/BUILD | 8 +++ .../tests/gherkin/steps/config_steps.cpp | 19 ++++-- .../tests/gherkin/steps/evaluation_steps.cpp | 58 +++++++++++++++---- .../flagd/tests/gherkin/steps/step_utils.cpp | 35 ++++++++++- .../flagd/tests/gherkin/steps/step_utils.h | 3 + providers/flagd/tests/gherkin/test_env.cpp | 10 ++++ providers/flagd/tests/gherkin/test_state.cpp | 13 ++++- providers/flagd/tests/gherkin/test_state.h | 2 +- 9 files changed, 128 insertions(+), 21 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index 5beb73b..4c5758d 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -5,6 +5,7 @@ bazel_dep(name = "rules_python", version = "2.3.2") bazel_dep(name = "rules_proto", version = "7.1.0") bazel_dep(name = "googletest", version = "1.18.0.bcr.1") bazel_dep(name = "abseil-cpp", version = "20250814.2") +bazel_dep(name = "platforms", version = "0.0.10") bazel_dep(name = "openfeature_cpp_sdk") git_override( module_name = "openfeature_cpp_sdk", diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index ad34e92..d067bef 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -40,6 +40,10 @@ cc_binary( env = { "FLAGD_TEST_FLAGS": "$(locations @flagd_testbed//:flags)", }, + target_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], deps = DEPS, ) @@ -57,5 +61,9 @@ cc_test( # TODO(#91): This tag disables those tests from github action check # We should remove it once all tests will be passing tags = ["manual"], + target_compatible_with = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", + ], deps = DEPS, ) diff --git a/providers/flagd/tests/gherkin/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp index 40f1571..8c4962c 100644 --- a/providers/flagd/tests/gherkin/steps/config_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -123,8 +123,15 @@ GIVEN(AnEnvironmentVariableWithValue, "an environment variable {string} with value {string}") { std::string env_var = CUKE_ARG(1); std::string value = CUKE_ARG(2); + if (!g_state.saved_env_vars.contains(env_var)) { + const char* cur = std::getenv(env_var.c_str()); + if (cur != nullptr) { + g_state.saved_env_vars[env_var] = std::string(cur); + } else { + g_state.saved_env_vars[env_var] = std::nullopt; + } + } setenv(env_var.c_str(), value.c_str(), 1); - g_state.set_env_vars.push_back(env_var); } WHEN(AConfigWasInitialized, "a config was initialized") { @@ -254,10 +261,14 @@ THEN(WeShouldHaveAnError, "we should have an error") { } AFTER(CleanupEnv) { - for (const auto& var : g_state.set_env_vars) { - unsetenv(var.c_str()); + for (const auto& [var, val] : g_state.saved_env_vars) { + if (val.has_value()) { + setenv(var.c_str(), val->c_str(), 1); + } else { + unsetenv(var.c_str()); + } } - g_state.set_env_vars.clear(); + g_state.saved_env_vars.clear(); g_state.pending_options.clear(); g_state.config.reset(); g_state.config_error = false; diff --git a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp index 4b003f6..dc24a77 100644 --- a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -18,6 +18,8 @@ using openfeature::contrib::flagd::test::ErrorCodeToString; using openfeature::contrib::flagd::test::g_state; using openfeature::contrib::flagd::test::JsonToValue; +using openfeature::contrib::flagd::test::ParseDouble; +using openfeature::contrib::flagd::test::ParseInt64; using openfeature::contrib::flagd::test::ReasonToString; using openfeature::contrib::flagd::test::RecordEvaluationDetails; using openfeature::contrib::flagd::test::ValueToJson; @@ -96,24 +98,42 @@ THEN(TheResolvedDetailsValueShouldBe, cuke::equal(actual.value(), expected_str); } } else if (type == "Integer") { - int64_t expected = std::stoll(expected_str); + auto expected = ParseInt64(expected_str); + cuke::equal( + expected.has_value(), true, + "Expected integer string is not a valid int64: " + expected_str); + if (!expected.has_value()) { + return; + } auto actual = g_state.last_eval.resolved_value.AsInt(); cuke::equal(actual.has_value(), true); if (actual.has_value()) { - cuke::equal(actual.value(), expected); + cuke::equal(actual.value(), expected.value()); } } else if (type == "Float") { - double expected = std::stod(expected_str); + auto expected = ParseDouble(expected_str); + cuke::equal(expected.has_value(), true, + "Expected float string is not a valid double: " + expected_str); + if (!expected.has_value()) { + return; + } auto actual = g_state.last_eval.resolved_value.AsDouble(); cuke::equal(actual.has_value(), true); if (actual.has_value()) { - cuke::equal(std::abs(actual.value() - expected) < 1e-5, true); + cuke::equal(std::abs(actual.value() - expected.value()) < 1e-5, true); } } else if (type == "Object") { nlohmann::json expected = nlohmann::json::parse(expected_str, nullptr, false); + cuke::equal(expected.is_discarded(), false, + "Expected JSON is malformed: " + expected_str); + if (expected.is_discarded()) { + return; + } nlohmann::json actual = ValueToJson(g_state.last_eval.resolved_value); - cuke::equal(actual.dump(), expected.dump()); + cuke::equal(actual == expected, true, + "Actual JSON does not match expected JSON. Actual: " + + actual.dump() + ", Expected: " + expected.dump()); } } @@ -178,26 +198,40 @@ THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { cuke::equal(std::get(var_val), expected_val); } } else if (type == "Integer") { - int64_t expected = std::stoll(expected_val); + auto expected = ParseInt64(expected_val); + cuke::equal(expected.has_value(), true, + "Expected integer metadata string is not a valid int64: " + + expected_val); + if (!expected.has_value()) { + continue; + } bool is_int = std::holds_alternative(var_val); bool is_double = std::holds_alternative(var_val); cuke::equal(is_int || is_double, true); if (is_int) { - cuke::equal(std::get(var_val), expected); + cuke::equal(std::get(var_val), expected.value()); } else if (is_double) { - cuke::equal(static_cast(std::get(var_val)), expected); + cuke::equal(static_cast(std::get(var_val)), + expected.value()); } } else if (type == "Float") { - double expected = std::stod(expected_val); + auto expected = ParseDouble(expected_val); + cuke::equal(expected.has_value(), true, + "Expected float metadata string is not a valid double: " + + expected_val); + if (!expected.has_value()) { + continue; + } bool is_double = std::holds_alternative(var_val); bool is_int = std::holds_alternative(var_val); cuke::equal(is_double || is_int, true); if (is_double) { - cuke::equal(std::abs(std::get(var_val) - expected) < 1e-5, - true); + cuke::equal( + std::abs(std::get(var_val) - expected.value()) < 1e-5, + true); } else if (is_int) { cuke::equal(std::abs(static_cast(std::get(var_val)) - - expected) < 1e-5, + expected.value()) < 1e-5, true); } } else if (type == "Boolean") { diff --git a/providers/flagd/tests/gherkin/steps/step_utils.cpp b/providers/flagd/tests/gherkin/steps/step_utils.cpp index 0fd2002..8935e47 100644 --- a/providers/flagd/tests/gherkin/steps/step_utils.cpp +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -1,5 +1,6 @@ #include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include #include #include #include @@ -111,10 +112,12 @@ nlohmann::json ValueToJson(const openfeature::Value& val) { return val.AsBool().value(); } if (val.IsNumber()) { - if (val.AsInt().has_value()) { - return val.AsInt().value(); + double d = val.AsDouble().value(); + int64_t i = val.AsInt().value(); + if (static_cast(i) == d && !std::isnan(d)) { + return i; } - return val.AsDouble().value(); + return d; } if (val.IsString()) { return val.AsString().value(); @@ -138,4 +141,30 @@ nlohmann::json ValueToJson(const openfeature::Value& val) { return nullptr; } +std::optional ParseInt64(const std::string& str) { + try { + size_t idx = 0; + int64_t val = std::stoll(str, &idx); + if (idx != str.size()) { + return std::nullopt; + } + return val; + } catch (...) { + return std::nullopt; + } +} + +std::optional ParseDouble(const std::string& str) { + try { + size_t idx = 0; + double val = std::stod(str, &idx); + if (idx != str.size()) { + return std::nullopt; + } + return val; + } catch (...) { + return std::nullopt; + } +} + } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/steps/step_utils.h b/providers/flagd/tests/gherkin/steps/step_utils.h index 23ab5f5..48a3eed 100644 --- a/providers/flagd/tests/gherkin/steps/step_utils.h +++ b/providers/flagd/tests/gherkin/steps/step_utils.h @@ -2,6 +2,7 @@ #include #include +#include #include #include "openfeature/error_code.h" @@ -19,5 +20,7 @@ void RecordEvaluationDetails( const openfeature::GeneralFlagEvaluationDetails& details); openfeature::Value JsonToValue(const nlohmann::json& json_val); nlohmann::json ValueToJson(const openfeature::Value& val); +std::optional ParseInt64(const std::string& str); +std::optional ParseDouble(const std::string& str); } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index 94bd3ad..b3b7326 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -298,7 +298,17 @@ void SetupGlobalFlagd() { fs::path dest = fs::path(g_scenario_tmp_dir) / "all_flags.json"; { std::ofstream ofs(dest); + if (!ofs.is_open()) { + std::cerr << "CRITICAL: Could not open output flag file for writing: " + << dest << '\n'; + exit(1); + } ofs << merged_root.dump(2); + if (!ofs.good()) { + std::cerr << "CRITICAL: Failed writing to output flag file: " << dest + << '\n'; + exit(1); + } } // Add all_flags.json as the default (no selector) source sources.insert(sources.begin(), { diff --git a/providers/flagd/tests/gherkin/test_state.cpp b/providers/flagd/tests/gherkin/test_state.cpp index b24e878..3b9c8e3 100644 --- a/providers/flagd/tests/gherkin/test_state.cpp +++ b/providers/flagd/tests/gherkin/test_state.cpp @@ -1,9 +1,20 @@ #include "providers/flagd/tests/gherkin/test_state.h" +#include + namespace openfeature::contrib::flagd::test { TestState g_state; -void ResetTestState() { g_state = TestState(); } +void ResetTestState() { + for (const auto& [var, val] : g_state.saved_env_vars) { + if (val.has_value()) { + setenv(var.c_str(), val->c_str(), 1); + } else { + unsetenv(var.c_str()); + } + } + 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 index 6debdc6..5fb8f7b 100644 --- a/providers/flagd/tests/gherkin/test_state.h +++ b/providers/flagd/tests/gherkin/test_state.h @@ -39,7 +39,7 @@ struct TestState { std::string resolved_resolver = "rpc"; std::string fatal_status_codes_str; bool config_error = false; - std::vector set_env_vars; + std::map> saved_env_vars; std::map pending_options; }; From 0ae951d47f1d20e9501e9fa570a1171f91e69744 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Mon, 14 Sep 2026 12:51:52 +0000 Subject: [PATCH 09/12] Fix offline resolver handling and integer metadata assertion Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/steps/config_steps.cpp | 7 +++++-- .../flagd/tests/gherkin/steps/evaluation_steps.cpp | 12 ++++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/providers/flagd/tests/gherkin/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp index 8c4962c..9e46b94 100644 --- a/providers/flagd/tests/gherkin/steps/config_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -137,14 +137,16 @@ GIVEN(AnEnvironmentVariableWithValue, WHEN(AConfigWasInitialized, "a config was initialized") { try { ::flagd::FlagdProviderConfig config; - + bool explicit_resolver = false; std::string resolver = "rpc"; if (const char* env_res = std::getenv("FLAGD_RESOLVER")) { resolver = env_res; + explicit_resolver = true; } auto opt_it = g_state.pending_options.find("resolver"); if (opt_it != g_state.pending_options.end()) { resolver = opt_it->second; + explicit_resolver = true; } for (char& c : resolver) { c = static_cast(std::tolower(static_cast(c))); @@ -160,7 +162,8 @@ WHEN(AConfigWasInitialized, "a config was initialized") { } if (has_offline_path) { - if (resolver == "in-process" || resolver == "file") { + if (!explicit_resolver || resolver == "in-process" || + resolver == "file") { resolver = "file"; } } diff --git a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp index dc24a77..c5f576b 100644 --- a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -211,8 +211,16 @@ THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { if (is_int) { cuke::equal(std::get(var_val), expected.value()); } else if (is_double) { - cuke::equal(static_cast(std::get(var_val)), - expected.value()); + double d = std::get(var_val); + bool is_integral = !std::isnan(d) && !std::isinf(d) && + d >= -9223372036854775808.0 && + d < 9223372036854775808.0 && std::trunc(d) == d; + cuke::equal(is_integral, true, + "Metadata double value " + std::to_string(d) + + " is not an in-range integral value"); + if (is_integral) { + cuke::equal(static_cast(d), expected.value()); + } } } else if (type == "Float") { auto expected = ParseDouble(expected_val); From 7ae4c6b1584fa7e45e6c43c92a4bc81d114ddffa Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Thu, 17 Sep 2026 09:39:15 +0000 Subject: [PATCH 10/12] Improved one more time Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/.clang-tidy | 2 +- providers/flagd/tests/gherkin/BUILD | 156 ++++-- providers/flagd/tests/gherkin/README.md | 88 +++ .../tests/gherkin/steps/config_steps.cpp | 448 ++++++++------- .../tests/gherkin/steps/context_steps.cpp | 84 ++- .../tests/gherkin/steps/evaluation_steps.cpp | 510 +++++++++++------ .../flagd/tests/gherkin/steps/flag_steps.cpp | 113 ++-- .../tests/gherkin/steps/lifecycle_steps.cpp | 35 +- .../tests/gherkin/steps/provider_steps.cpp | 62 ++- .../flagd/tests/gherkin/steps/step_utils.cpp | 113 +++- .../flagd/tests/gherkin/steps/step_utils.h | 44 +- .../flagd/tests/gherkin/test_context.cpp | 54 ++ providers/flagd/tests/gherkin/test_context.h | 99 ++++ providers/flagd/tests/gherkin/test_env.cpp | 523 +++++++++++------- providers/flagd/tests/gherkin/test_env.h | 49 +- providers/flagd/tests/gherkin/test_runner.cpp | 93 ++-- providers/flagd/tests/gherkin/test_state.cpp | 20 - providers/flagd/tests/gherkin/test_state.h | 52 -- 18 files changed, 1597 insertions(+), 948 deletions(-) create mode 100644 providers/flagd/tests/gherkin/README.md create mode 100644 providers/flagd/tests/gherkin/test_context.cpp create mode 100644 providers/flagd/tests/gherkin/test_context.h delete mode 100644 providers/flagd/tests/gherkin/test_state.cpp delete mode 100644 providers/flagd/tests/gherkin/test_state.h diff --git a/providers/flagd/tests/gherkin/.clang-tidy b/providers/flagd/tests/gherkin/.clang-tidy index 612bd0e..53d6da3 100644 --- a/providers/flagd/tests/gherkin/.clang-tidy +++ b/providers/flagd/tests/gherkin/.clang-tidy @@ -1 +1 @@ -Checks: '-*' +Checks: "-*" diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index d067bef..4aca9d0 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -1,69 +1,123 @@ -load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_test") +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") -SRCS = [ - "steps/config_steps.cpp", - "steps/context_steps.cpp", - "steps/evaluation_steps.cpp", - "steps/flag_steps.cpp", - "steps/lifecycle_steps.cpp", - "steps/provider_steps.cpp", - "steps/step_utils.cpp", - "steps/step_utils.h", - "test_env.cpp", - "test_env.h", - "test_runner.cpp", - "test_state.cpp", - "test_state.h", +# cwt-cucumber requires C++20. The provider itself targets C++17, so this must +# stay scoped to these targets rather than moving into .bazelrc. copts are not +# propagated across targets in either direction, which is why @cwt_cucumber +# carries its own copy for its own sources. +GHERKIN_COPTS = ["-std=c++20"] + +GHERKIN_PLATFORM = [ + "@platforms//os:linux", + "@platforms//cpu:x86_64", ] -DATA = [ - "@flagd_binary//:flagd", - "@flagd_testbed//:features", - "@flagd_testbed//:flags", +# Feature files the provider can currently satisfy. +# +# Deliberately excluded: +# connection.feature, events.feature - need event-handler steps, and the +# provider exposes no event API yet. +# contextEnrichment.feature - needs sync-metadata enrichment. +# sync-payload.feature - needs sync-metadata enrichment. +# rpc-caching.feature - @rpc only; there is no RPC resolver. +# +# Listing them would add roughly 170 permanently-undefined steps, which buries +# real regressions in noise. Add a file here as soon as its steps exist. +SUPPORTED_FEATURES = [ + "@flagd_testbed//:gherkin/config.feature", + "@flagd_testbed//:gherkin/disabled.feature", + "@flagd_testbed//:gherkin/evaluation.feature", + "@flagd_testbed//:gherkin/metadata.feature", + "@flagd_testbed//:gherkin/selector.feature", + "@flagd_testbed//:gherkin/targeting.feature", ] -DEPS = [ - "//providers/flagd/src:flagd_provider", - "@bazel_tools//tools/cpp/runfiles", - "@com_github_grpc_grpc//:grpc++", - "@cwt_cucumber//:cwt-cucumber", - "@nlohmann_json//:json", - "@openfeature_cpp_sdk//openfeature", - "@openfeature_cpp_sdk//openfeature:openfeature_api", +# flagd binary and fixture flags. Needed in the runfiles of anything that runs +# the suite, and needed as a direct prerequisite of any rule whose `env` uses +# $(rootpaths) on them. +GHERKIN_DATA = [ + "@flagd_binary//:flagd", + "@flagd_testbed//:flags", ] -cc_binary( - name = "gherkin_bin", - srcs = SRCS, - copts = ["-std=c++20"], - data = DATA, - env = { - "FLAGD_TEST_FLAGS": "$(locations @flagd_testbed//:flags)", - }, - target_compatible_with = [ - "@platforms//os:linux", - "@platforms//cpu:x86_64", +# Step definitions register from static initializers that nothing in main() +# references, so without alwayslink the linker drops every object file here and +# every step reports as UNDEFINED. +cc_library( + name = "gherkin_steps", + testonly = True, + srcs = [ + "steps/config_steps.cpp", + "steps/context_steps.cpp", + "steps/evaluation_steps.cpp", + "steps/flag_steps.cpp", + "steps/lifecycle_steps.cpp", + "steps/provider_steps.cpp", + "steps/step_utils.cpp", + "test_context.cpp", + "test_env.cpp", + ], + hdrs = [ + "steps/step_utils.h", + "test_context.h", + "test_env.h", ], - deps = DEPS, + copts = GHERKIN_COPTS, + data = GHERKIN_DATA, + # `bazel build //...` expands to every target in the package, so leaving + # the library untagged would still make CI fetch the external repos. + tags = ["manual"], + deps = [ + "//providers/flagd/src:flagd_provider", + "@bazel_tools//tools/cpp/runfiles", + "@com_github_grpc_grpc//:grpc++", + "@cwt_cucumber//:cwt-cucumber", + "@nlohmann_json//:json", + "@openfeature_cpp_sdk//openfeature", + "@openfeature_cpp_sdk//openfeature:openfeature_api", + ], + alwayslink = True, ) cc_test( name = "gherkin_test", - srcs = SRCS, - args = [ - "$(locations @flagd_testbed//:features)", + size = "large", + srcs = ["test_runner.cpp"], + args = ["$(rootpath %s)" % feature for feature in SUPPORTED_FEATURES], + copts = GHERKIN_COPTS, + data = SUPPORTED_FEATURES + GHERKIN_DATA, + env = { + "FLAGD_TEST_FLAGS": "$(rootpaths @flagd_testbed//:flags)", + # The provider is in-process only; @rpc-tagged scenarios would + # otherwise run against the sync port and fail for the wrong reason. + "GHERKIN_TAGS": "@in-process", + }, + # TODO(#91): drop once the suite is green, so CI can enforce it. + tags = ["manual"], + target_compatible_with = GHERKIN_PLATFORM, + deps = [ + ":gherkin_steps", + "@cwt_cucumber//:cwt-cucumber", ], - copts = ["-std=c++20"], - data = DATA, +) + +# Same binary without the Bazel test harness, for running a subset by hand: +# bazel run //providers/flagd/tests/gherkin:gherkin_bin -- \ +# --tags "@in-process and @targeting" +# +# Manual for the same reason as above. +cc_binary( + name = "gherkin_bin", + testonly = True, + srcs = ["test_runner.cpp"], + copts = GHERKIN_COPTS, + data = SUPPORTED_FEATURES + GHERKIN_DATA, env = { - "FLAGD_TEST_FLAGS": "$(locations @flagd_testbed//:flags)", + "FLAGD_TEST_FLAGS": "$(rootpaths @flagd_testbed//:flags)", }, - # TODO(#91): This tag disables those tests from github action check - # We should remove it once all tests will be passing tags = ["manual"], - target_compatible_with = [ - "@platforms//os:linux", - "@platforms//cpu:x86_64", + target_compatible_with = GHERKIN_PLATFORM, + deps = [ + ":gherkin_steps", + "@cwt_cucumber//:cwt-cucumber", ], - deps = DEPS, ) diff --git a/providers/flagd/tests/gherkin/README.md b/providers/flagd/tests/gherkin/README.md new file mode 100644 index 0000000..daf5069 --- /dev/null +++ b/providers/flagd/tests/gherkin/README.md @@ -0,0 +1,88 @@ +# Gherkin integration tests + +Runs the [flagd-testbed](https://github.com/open-feature/flagd-testbed) Gherkin +suite against the C++ flagd provider, using +[cwt-cucumber](https://github.com/ThoSe1990/cwt-cucumber) as the runner. + +A real `flagd` binary is downloaded by Bazel and started as a subprocess for +the duration of the run; the tests talk to it over gRPC exactly as a real +application would. + +## Running + +The target is tagged `manual`, so `bazel test //providers/...` skips it. Run it +explicitly: + +```sh +bazel test //providers/flagd/tests/gherkin:gherkin_test --test_output=all +``` + +To run a subset, use the binary directly: + +```sh +# Everything tagged @targeting +bazel run //providers/flagd/tests/gherkin:gherkin_bin -- \ + --tags "@in-process and @targeting" \ + $PWD/bazel-cpp-sdk-contrib/external/+_repo_rules+flagd_testbed/gherkin/targeting.feature + +# A single scenario by name +bazel run //providers/flagd/tests/gherkin:gherkin_bin -- \ + --name "Returns metadata" \ + $PWD/bazel-cpp-sdk-contrib/external/+_repo_rules+flagd_testbed/gherkin/metadata.feature +``` + +`--tags` and `--name` can also be supplied as `GHERKIN_TAGS` and +`GHERKIN_NAME`; command-line flags win over the environment. + +> [!NOTE] +> Linux x86_64 only. The `flagd` release archive pinned in `MODULE.bazel` has +> no other platform, so `target_compatible_with` makes these targets *skip* +> silently elsewhere rather than fail. + +## Layout + +| File | Purpose | +|---|---| +| `test_runner.cpp` | `main()`; normalises arguments and calls `cuke::entry_point` | +| `test_env.{h,cpp}` | Starts/stops flagd, merges the fixture files, resolves runfiles | +| `test_context.{h,cpp}` | All mutable test state, plus environment save/restore | +| `steps/flag_steps.cpp` | `a -flag with key ...` | +| `steps/context_steps.cpp` | `a context containing ...` | +| `steps/provider_steps.cpp` | `a stable flagd provider`, option collection | +| `steps/evaluation_steps.cpp` | `the flag was evaluated with details` and its assertions | +| `steps/config_steps.cpp` | `a config was initialized` and option assertions | +| `steps/lifecycle_steps.cpp` | `BEFORE_ALL` / `AFTER_ALL` / per-scenario reset | +| `steps/step_utils.{h,cpp}` | Conversions, parsing, assertion helpers | + +## Two things that will confuse you + +**Empty Scenario-Outline cells arrive as four quote characters.** +cwt-cucumber substitutes an empty Examples cell with the literal `""`, which +combines with the quotes already in the step text. `{string}` (`"([^"]*)"`) +cannot match that, so every step whose value may be blank is registered twice — +once normally and once with `GHERKIN_EMPTY_ARG`. Both registrations delegate to +one function. See the comment on `GHERKIN_EMPTY_ARG` in `steps/step_utils.h`. + +**Step definitions need `alwayslink`.** +Steps register themselves from static initializers. In a plain `cc_library` the +linker discards every object file that `main()` does not reference, taking the +registrations with it — the binary links cleanly and reports *every* step as +undefined. The `gherkin_steps` target sets `alwayslink = True`. + +## Known gaps + +These are real provider gaps, not harness bugs. Scenarios covering them fail or +are excluded on purpose; see the `SUPPORTED_FEATURES` list in `BUILD`. + +| Gap | Effect | +|---|---| +| No RPC resolver | `rpc-caching.feature` excluded; `GHERKIN_TAGS` pins the run to `@in-process` | +| No file/offline resolver (TODO #20) | `FlagdProvider` calls `LOG(FATAL)` when `offlineFlagSourcePath` is set, which would abort the whole run | +| No provider events | `connection.feature`, `events.feature` excluded | +| No sync-metadata enrichment | `contextEnrichment.feature`, `sync-payload.feature` excluded | +| No `resolver` / `cache` / `maxCacheSize` in `FlagdProviderConfig` | Those `config.feature` scenarios report as not-implemented | +| `edge-case-flags.json`, `custom-ops.json` rejected by FlagSync (TODO #129) | Fixtures skipped; dependent scenarios fail | + +Steps deliberately fail rather than pass when they cannot verify something. A +step that recognises none of its inputs runs zero assertions, and reporting +that as success is how a suite ends up certifying unimplemented behaviour. diff --git a/providers/flagd/tests/gherkin/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp index 9e46b94..b7eb684 100644 --- a/providers/flagd/tests/gherkin/steps/config_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -1,119 +1,161 @@ -#include -#include +#include +#include +#include #include +#include #include "asserts.hpp" // for cuke::equal -#include "defines.hpp" // for GIVEN, WHEN, THEN, AFTER +#include "defines.hpp" // for GIVEN, WHEN, THEN #include "flagd/configuration.h" #include "get_args.hpp" // for CUKE_ARG -#include "providers/flagd/tests/gherkin/test_state.h" - -using openfeature::contrib::flagd::test::g_state; +#include "grpcpp/support/status.h" +#include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_context.h" namespace { -void CheckOptionValue(const std::string& option, const std::string& type, - const std::string& expected_val) { - cuke::equal(g_state.config.has_value(), true); - if (!g_state.config.has_value()) { +using openfeature::contrib::flagd::test::Ctx; +using openfeature::contrib::flagd::test::ExpectEq; +using openfeature::contrib::flagd::test::FailStep; +using openfeature::contrib::flagd::test::FailStepNotImplemented; +using openfeature::contrib::flagd::test::ParseBool; +using openfeature::contrib::flagd::test::ParseInt64; + +// The provider is in-process only: there is no Resolver type, and +// `cache`/`maxCacheSize` describe the unimplemented RPC resolver's flag cache. +// Asserting on them would mean asserting on a value the test itself invented, +// so they are reported as unimplemented instead. +bool IsUnmodelledOption(const std::string& option) { + return option == "resolver" || option == "cache" || option == "maxCacheSize"; +} + +bool ApplyInt(const std::string& option, const std::string& value, int* out) { + const auto parsed = ParseInt64(value); + if (!parsed.has_value()) { + FailStep("option '" + option + "' is not a valid integer: '" + value + "'"); + return false; + } + *out = static_cast(*parsed); + return true; +} + +void ExpectOptionalEquals(const std::optional& actual, + const std::string& expected, + const std::string& option) { + if (expected == "null") { + cuke::equal(actual.has_value(), false, + "expected option '" + option + "' to be unset, got '" + + actual.value_or("") + "'"); + return; + } + if (!actual.has_value()) { + FailStep("expected option '" + option + "' to be '" + expected + + "', but it is unset"); + return; + } + ExpectEq(*actual, expected, "option '" + option + "'"); +} + +void ExpectIntEquals(int actual, const std::string& expected, + const std::string& option) { + const auto parsed = ParseInt64(expected); + if (!parsed.has_value()) { + FailStep("expected value for option '" + option + + "' is not a valid integer: '" + expected + "'"); + return; + } + ExpectEq(static_cast(actual), *parsed, "option '" + option + "'"); +} + +std::string StatusCodesToString(const std::vector& codes) { + std::string out; + for (const grpc::StatusCode code : codes) { + if (!out.empty()) { + out += ", "; + } + out += std::to_string(static_cast(code)); + } + return out; +} + +void CheckFatalStatusCodes(const ::flagd::FlagdProviderConfig& config, + const std::string& expected) { + const std::vector& actual = config.GetFatalStatusCodes(); + if (expected.empty() || expected == "null" || expected == "[]") { + cuke::equal(actual.empty(), true, + "expected no fatal status codes, got '" + + StatusCodesToString(actual) + "'"); return; } - const auto& config = g_state.config.value(); + + // The testbed uses placeholder names ("A, B"). FlagdProviderConfig parses + // the list into grpc::StatusCode and drops what it cannot recognise, so a + // placeholder can never round-trip. + const auto expected_count = + std::count(expected.begin(), expected.end(), ',') + 1; + cuke::equal(static_cast(actual.size()), + static_cast(expected_count), + "fatalStatusCodes '" + expected + "' produced " + + std::to_string(actual.size()) + + " parsed code(s); FlagdProviderConfig silently discards " + "tokens it cannot map to a grpc::StatusCode"); +} + +void CheckOptionValue(const std::string& option, const std::string& expected) { + if (IsUnmodelledOption(option)) { + FailStepNotImplemented("the '" + option + "' option"); + return; + } + + const auto& maybe_config = Ctx().scenario.config; + if (!maybe_config.has_value()) { + FailStep("no config was initialized before checking option '" + option + + "'"); + return; + } + const ::flagd::FlagdProviderConfig& config = *maybe_config; if (option == "host") { - cuke::equal(config.GetHost(), expected_val); + ExpectEq(config.GetHost(), expected, "option 'host'"); } else if (option == "port") { - cuke::equal(config.GetPort(), std::stoi(expected_val)); + ExpectIntEquals(config.GetPort(), expected, option); } else if (option == "tls") { - bool expected = expected_val == "true" || expected_val == "True"; - cuke::equal(config.GetTls(), expected); + const auto parsed = ParseBool(expected); + if (!parsed.has_value()) { + FailStep("expected value for 'tls' is not a boolean: '" + expected + "'"); + return; + } + ExpectEq(config.GetTls(), *parsed, "option 'tls'"); } else if (option == "deadlineMs") { - cuke::equal(config.GetDeadlineMs(), std::stoi(expected_val)); + ExpectIntEquals(config.GetDeadlineMs(), expected, option); } else if (option == "streamDeadlineMs") { - cuke::equal(config.GetStreamDeadlineMs(), std::stoi(expected_val)); + ExpectIntEquals(config.GetStreamDeadlineMs(), expected, option); } else if (option == "retryBackoffMs") { - cuke::equal(config.GetRetryBackoffMs(), std::stoi(expected_val)); + ExpectIntEquals(config.GetRetryBackoffMs(), expected, option); } else if (option == "retryBackoffMaxMs") { - cuke::equal(config.GetRetryBackoffMaxMs(), std::stoi(expected_val)); + ExpectIntEquals(config.GetRetryBackoffMaxMs(), expected, option); } else if (option == "retryGracePeriod") { - cuke::equal(config.GetRetryGracePeriod(), std::stoi(expected_val)); + ExpectIntEquals(config.GetRetryGracePeriod(), expected, option); } else if (option == "keepAliveTime") { - cuke::equal(config.GetKeepAliveTimeMs(), std::stoi(expected_val)); + ExpectIntEquals(config.GetKeepAliveTimeMs(), expected, option); + } else if (option == "offlinePollIntervalMs") { + ExpectIntEquals(config.GetOfflinePollIntervalMs(), expected, option); } else if (option == "targetUri") { - auto val = config.GetTargetUri(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - cuke::equal(val.has_value(), true); - if (val.has_value()) { - cuke::equal(val.value(), expected_val); - } - } + ExpectOptionalEquals(config.GetTargetUri(), expected, option); } else if (option == "certPath") { - auto val = config.GetCertPath(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - cuke::equal(val.has_value(), true); - if (val.has_value()) { - cuke::equal(val.value(), expected_val); - } - } + ExpectOptionalEquals(config.GetCertPath(), expected, option); } else if (option == "socketPath") { - auto val = config.GetSocketPath(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - cuke::equal(val.has_value(), true); - if (val.has_value()) { - cuke::equal(val.value(), expected_val); - } - } + ExpectOptionalEquals(config.GetSocketPath(), expected, option); } else if (option == "selector") { - auto val = config.GetSelector(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - cuke::equal(val.has_value(), true); - if (val.has_value()) { - cuke::equal(val.value(), expected_val); - } - } + ExpectOptionalEquals(config.GetSelector(), expected, option); } else if (option == "providerId") { - auto val = config.GetProviderId(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - cuke::equal(val.has_value(), true); - if (val.has_value()) { - cuke::equal(val.value(), expected_val); - } - } + ExpectOptionalEquals(config.GetProviderId(), expected, option); } else if (option == "offlineFlagSourcePath") { - auto val = config.GetOfflineFlagSourcePath(); - if (expected_val == "null") { - cuke::equal(val.has_value(), false); - } else { - 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)); + ExpectOptionalEquals(config.GetOfflineFlagSourcePath(), expected, option); } else if (option == "fatalStatusCodes") { - if (expected_val.empty() || expected_val == "null" || - expected_val == "[]") { - cuke::equal(g_state.fatal_status_codes_str.empty(), true); - } else { - cuke::equal(g_state.fatal_status_codes_str, expected_val); - } - } else if (option == "resolver") { - std::string expected_res = expected_val; - for (char& c : expected_res) { - c = static_cast(std::tolower(static_cast(c))); - } - cuke::equal(g_state.resolved_resolver, expected_res); + CheckFatalStatusCodes(config, expected); + } else { + FailStep("unknown config option '" + option + "'"); } } @@ -121,160 +163,112 @@ void CheckOptionValue(const std::string& option, const std::string& type, GIVEN(AnEnvironmentVariableWithValue, "an environment variable {string} with value {string}") { - std::string env_var = CUKE_ARG(1); - std::string value = CUKE_ARG(2); - if (!g_state.saved_env_vars.contains(env_var)) { - const char* cur = std::getenv(env_var.c_str()); - if (cur != nullptr) { - g_state.saved_env_vars[env_var] = std::string(cur); - } else { - g_state.saved_env_vars[env_var] = std::nullopt; - } - } - setenv(env_var.c_str(), value.c_str(), 1); + const std::string name = CUKE_ARG(1); + const std::string value = CUKE_ARG(2); + Ctx().scenario.env.Set(name, value); } WHEN(AConfigWasInitialized, "a config was initialized") { - try { - ::flagd::FlagdProviderConfig config; - bool explicit_resolver = false; - std::string resolver = "rpc"; - if (const char* env_res = std::getenv("FLAGD_RESOLVER")) { - resolver = env_res; - explicit_resolver = true; - } - auto opt_it = g_state.pending_options.find("resolver"); - if (opt_it != g_state.pending_options.end()) { - resolver = opt_it->second; - explicit_resolver = true; - } - for (char& c : resolver) { - c = static_cast(std::tolower(static_cast(c))); - } - - bool has_offline_path = config.GetOfflineFlagSourcePath().has_value(); - auto path_it = g_state.pending_options.find("offlineFlagSourcePath"); - if (path_it != g_state.pending_options.end()) { - has_offline_path = !path_it->second.empty(); - } else if (const char* env_path = - std::getenv("FLAGD_OFFLINE_FLAG_SOURCE_PATH")) { - has_offline_path = *env_path != '\0'; - } - - if (has_offline_path) { - if (!explicit_resolver || resolver == "in-process" || - resolver == "file") { - resolver = "file"; - } - } - - if (resolver == "file" && !has_offline_path) { - g_state.config_error = true; - g_state.config = std::nullopt; - return; - } + // The constructor reads every FLAGD_* environment variable itself, so + // env-driven scenarios are covered just by constructing it here. + ::flagd::FlagdProviderConfig config; + bool ok = true; - bool explicit_port = g_state.pending_options.contains("port") || - std::getenv("FLAGD_PORT") != nullptr || - std::getenv("FLAGD_SYNC_PORT") != nullptr; - if (!explicit_port) { - if (resolver == "rpc") { - config.SetPort(8013); - } else if (resolver == "in-process") { - config.SetPort(8015); - } - } else if (std::getenv("FLAGD_SYNC_PORT") != nullptr && - resolver == "in-process") { - config.SetPort(std::stoi(std::getenv("FLAGD_SYNC_PORT"))); + for (const auto& [option, value] : Ctx().scenario.pending_options) { + if (IsUnmodelledOption(option)) { + continue; } - - 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); + int int_value = 0; + if (option == "host") { + config.SetHost(value); + } else if (option == "port") { + if (ApplyInt(option, value, &int_value)) + config.SetPort(int_value); + else + ok = false; + } else if (option == "tls") { + const auto parsed = ParseBool(value); + if (parsed.has_value()) { + config.SetTls(*parsed); + } else { + FailStep("option 'tls' is not a boolean: '" + value + "'"); + ok = false; } + } else if (option == "deadlineMs") { + if (ApplyInt(option, value, &int_value)) + config.SetDeadlineMs(int_value); + else + ok = false; + } else if (option == "streamDeadlineMs") { + if (ApplyInt(option, value, &int_value)) + config.SetStreamDeadlineMs(int_value); + else + ok = false; + } else if (option == "retryBackoffMs") { + if (ApplyInt(option, value, &int_value)) + config.SetRetryBackoffMs(int_value); + else + ok = false; + } else if (option == "retryBackoffMaxMs") { + if (ApplyInt(option, value, &int_value)) + config.SetRetryBackoffMaxMs(int_value); + else + ok = false; + } else if (option == "retryGracePeriod") { + if (ApplyInt(option, value, &int_value)) + config.SetRetryGracePeriod(int_value); + else + ok = false; + } else if (option == "keepAliveTime") { + if (ApplyInt(option, value, &int_value)) + config.SetKeepAliveTimeMs(int_value); + else + ok = false; + } else if (option == "offlinePollIntervalMs") { + if (ApplyInt(option, value, &int_value)) + config.SetOfflinePollIntervalMs(int_value); + else + ok = false; + } 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 == "fatalStatusCodes") { + config.SetFatalStatusCodes(value); + } else { + FailStep("unknown config option '" + option + "'"); + ok = false; } - - std::string fatal_codes_str; - if (const char* env_codes = std::getenv("FLAGD_FATAL_STATUS_CODES")) { - fatal_codes_str = env_codes; - } - auto fatal_it = g_state.pending_options.find("fatalStatusCodes"); - if (fatal_it != g_state.pending_options.end()) { - fatal_codes_str = fatal_it->second; - } - g_state.fatal_status_codes_str = fatal_codes_str; - - g_state.resolved_resolver = resolver; - g_state.config = config; - g_state.config_error = false; - } catch (...) { - g_state.config_error = true; } + + Ctx().scenario.config = config; + // FlagdProviderConfig has no validation entry point, so the only errors the + // test can observe are the ones it produced applying the options above. + Ctx().scenario.config_error = !ok; } 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); - CheckOptionValue(option, type, expected_val); + CheckOptionValue(CUKE_ARG(1), CUKE_ARG(3)); } THEN(TheOptionOfTypeShouldHaveEmptyValue, - "the option {string} of type {string} should have the value \"\"\"\"") { - std::string option = CUKE_ARG(1); - std::string type = CUKE_ARG(2); - CheckOptionValue(option, type, ""); + "the option {string} of type {string} should have the " + "value " GHERKIN_EMPTY_ARG) { + CheckOptionValue(CUKE_ARG(1), ""); } THEN(WeShouldHaveAnError, "we should have an error") { - cuke::equal(g_state.config_error, true); -} - -AFTER(CleanupEnv) { - for (const auto& [var, val] : g_state.saved_env_vars) { - if (val.has_value()) { - setenv(var.c_str(), val->c_str(), 1); - } else { - unsetenv(var.c_str()); - } - } - g_state.saved_env_vars.clear(); - g_state.pending_options.clear(); - g_state.config.reset(); - g_state.config_error = false; - g_state.resolved_resolver = "rpc"; - g_state.fatal_status_codes_str.clear(); + // config.feature reaches this only for the "file" resolver without an + // offlineFlagSourcePath. FlagdProviderConfig neither models the resolver nor + // validates that combination, so there is nothing real to assert. + FailStepNotImplemented("configuration validation"); } diff --git a/providers/flagd/tests/gherkin/steps/context_steps.cpp b/providers/flagd/tests/gherkin/steps/context_steps.cpp index 34dbe38..028e578 100644 --- a/providers/flagd/tests/gherkin/steps/context_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/context_steps.cpp @@ -1,47 +1,81 @@ -#include #include #include "defines.hpp" // for GIVEN #include "get_args.hpp" // for CUKE_ARG #include "openfeature/value.h" -#include "providers/flagd/tests/gherkin/test_state.h" +#include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_context.h" -using openfeature::contrib::flagd::test::g_state; +namespace { -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); +using openfeature::contrib::flagd::test::Ctx; +using openfeature::contrib::flagd::test::FailStep; +using openfeature::contrib::flagd::test::ParseBool; +using openfeature::contrib::flagd::test::ParseDouble; +using openfeature::contrib::flagd::test::ParseInt64; +void AddContextAttribute(const std::string& key, const std::string& type, + const std::string& value) { 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" || 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); + Ctx().scenario.targeting_key = value; + return; + } + + auto& attributes = Ctx().scenario.context_attributes; + if (type == "String") { + attributes[key] = value; + } else if (type == "Boolean") { + if (auto parsed = ParseBool(value)) { + attributes[key] = *parsed; + } else { + FailStep("context attribute '" + key + "' is not a valid Boolean: '" + + value + "'"); + } + } else if (type == "Integer") { + if (auto parsed = ParseInt64(value)) { + attributes[key] = *parsed; + } else { + FailStep("context attribute '" + key + "' is not a valid Integer: '" + + value + "'"); + } + } else if (type == "Float") { + if (auto parsed = ParseDouble(value)) { + attributes[key] = *parsed; + } else { + FailStep("context attribute '" + key + "' is not a valid Float: '" + + value + "'"); } + } else { + FailStep("unsupported context attribute type '" + type + "' for key '" + + key + "'"); } } +} // namespace + +GIVEN(AContextContainingKeyTypeValue, + "a context containing a key {string}, with type {string} and with value " + "{string}") { + AddContextAttribute(CUKE_ARG(1), CUKE_ARG(2), CUKE_ARG(3)); +} + +GIVEN(AContextContainingKeyTypeEmptyValue, + "a context containing a key {string}, with type {string} and with value " + "" GHERKIN_EMPTY_ARG) { + AddContextAttribute(CUKE_ARG(1), CUKE_ARG(2), ""); +} + GIVEN(AContextContainingTargetingKey, "a context containing a targeting key with value {string}") { - g_state.targeting_key = static_cast(CUKE_ARG(1)); + Ctx().scenario.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] = + const std::string outer_key = CUKE_ARG(1); + const std::string inner_key = CUKE_ARG(2); + const std::string value = CUKE_ARG(3); + Ctx().scenario.nested_context_attributes[outer_key][inner_key] = ::openfeature::Value(value); } diff --git a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp index c5f576b..6852ab6 100644 --- a/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -1,4 +1,3 @@ -#include #include #include #include @@ -12,242 +11,391 @@ #include "openfeature/openfeature_api.h" #include "openfeature/value.h" #include "providers/flagd/tests/gherkin/steps/step_utils.h" -#include "providers/flagd/tests/gherkin/test_state.h" +#include "providers/flagd/tests/gherkin/test_context.h" #include "table.hpp" +namespace { + +using openfeature::contrib::flagd::test::AsExactInt64; +using openfeature::contrib::flagd::test::Ctx; using openfeature::contrib::flagd::test::ErrorCodeToString; -using openfeature::contrib::flagd::test::g_state; +using openfeature::contrib::flagd::test::ExpectEq; +using openfeature::contrib::flagd::test::FailStep; +using openfeature::contrib::flagd::test::FlagType; +using openfeature::contrib::flagd::test::FlagTypeToString; using openfeature::contrib::flagd::test::JsonToValue; +using openfeature::contrib::flagd::test::NearlyEqual; +using openfeature::contrib::flagd::test::ParseBool; using openfeature::contrib::flagd::test::ParseDouble; using openfeature::contrib::flagd::test::ParseInt64; using openfeature::contrib::flagd::test::ReasonToString; using openfeature::contrib::flagd::test::RecordEvaluationDetails; using openfeature::contrib::flagd::test::ValueToJson; -WHEN(TheFlagWasEvaluatedWithDetails, "the flag was evaluated with details") { +::openfeature::EvaluationContext BuildEvaluationContext() { ::openfeature::EvaluationContext::Builder builder; - if (!g_state.targeting_key.empty()) { - builder.WithTargetingKey(g_state.targeting_key); + const auto& scenario = Ctx().scenario; + + if (!scenario.targeting_key.empty()) { + builder.WithTargetingKey(scenario.targeting_key); } - for (const auto& [key, val] : g_state.context_attributes) { - builder.WithAttribute(key, val); + for (const auto& [key, value] : scenario.context_attributes) { + builder.WithAttribute(key, value); } - 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; + for (const auto& [outer_key, inner_map] : + scenario.nested_context_attributes) { + std::map object; + for (const auto& [inner_key, value] : inner_map) { + object[inner_key] = value; } - builder.WithAttribute(outer_key, ::openfeature::Value(obj_map)); + builder.WithAttribute(outer_key, ::openfeature::Value(object)); } + return builder.build(); +} - ::openfeature::EvaluationContext ctx = builder.build(); - - auto& api = ::openfeature::OpenFeatureAPI::GetInstance(); - auto client = api.GetClient(); +// Without this guard a THEN step reached without a WHEN step would assert +// against a default-constructed result and report success. +const openfeature::contrib::flagd::test::EvaluationResult* RequireEvaluation() { + const auto& result = Ctx().scenario.last_eval; + if (!result.recorded) { + FailStep( + "no evaluation has been recorded; the 'the flag was evaluated with " + "details' step did not run or did not resolve the flag"); + return nullptr; + } + return &result; +} - 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; +void CheckResolvedValue(const std::string& expected_str) { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } - if (type == "Boolean") { - RecordEvaluationDetails( - client->GetBooleanDetails(key, def_str == "true", ctx)); - } else if (type == "String") { - RecordEvaluationDetails(client->GetStringDetails(key, def_str, ctx)); - } else if (type == "Integer") { - int64_t def_val = 0; - try { - if (!def_str.empty()) { - def_val = std::stoll(def_str); + switch (Ctx().scenario.pending_eval.flag_type) { + case FlagType::kBoolean: { + const auto expected = ParseBool(expected_str); + if (!expected.has_value()) { + FailStep("expected Boolean value is not parseable: '" + expected_str + + "'"); + return; } - } catch (...) { + const auto actual = result->value.AsBool(); + if (!actual.has_value()) { + FailStep("resolved value is not a Boolean"); + return; + } + ExpectEq(*actual, *expected, "resolved Boolean mismatch"); + return; } - RecordEvaluationDetails(client->GetIntegerDetails(key, def_val, ctx)); - } else if (type == "Float") { - double def_val = 0.0; - try { - if (!def_str.empty()) { - def_val = std::stod(def_str); + case FlagType::kString: { + const auto actual = result->value.AsString(); + if (!actual.has_value()) { + FailStep("resolved value is not a String"); + return; + } + ExpectEq(*actual, expected_str, "resolved String mismatch"); + return; + } + case FlagType::kInteger: { + const auto expected = ParseInt64(expected_str); + if (!expected.has_value()) { + FailStep("expected Integer value is not a valid int64: '" + + expected_str + "'"); + return; + } + const auto actual = result->value.AsInt(); + if (!actual.has_value()) { + FailStep("resolved value is not an Integer"); + return; + } + ExpectEq(*actual, *expected, "resolved Integer mismatch"); + return; + } + case FlagType::kFloat: { + const auto expected = ParseDouble(expected_str); + if (!expected.has_value()) { + FailStep("expected Float value is not a valid double: '" + + expected_str + "'"); + return; + } + const auto actual = result->value.AsDouble(); + if (!actual.has_value()) { + FailStep("resolved value is not a Float"); + return; + } + cuke::equal(NearlyEqual(*actual, *expected), true, + "resolved Float mismatch: got " + std::to_string(*actual) + + ", expected " + std::to_string(*expected)); + return; + } + case FlagType::kObject: { + const nlohmann::json expected = + nlohmann::json::parse(expected_str, nullptr, false); + if (expected.is_discarded()) { + FailStep("expected JSON is malformed: '" + expected_str + "'"); + return; } - } catch (...) { + const nlohmann::json actual = ValueToJson(result->value); + cuke::equal(actual == expected, true, + "resolved Object mismatch. Actual: " + actual.dump() + + ", expected: " + expected.dump()); + return; } - RecordEvaluationDetails(client->GetDoubleDetails(key, def_val, ctx)); - } else if (type == "Object") { - nlohmann::json parsed_json = nlohmann::json::parse(def_str, nullptr, false); - openfeature::Value def_val = JsonToValue(parsed_json); - RecordEvaluationDetails(client->GetObjectDetails(key, def_val, ctx)); } + FailStep("unhandled flag type in resolved-value assertion"); } -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); +void CheckMetadataEntry( + const std::string& key, const std::string& type, + const std::string& expected_val, + const std::unordered_map& + metadata) { + const auto it = metadata.find(key); + if (it == metadata.end()) { + FailStep("resolved metadata has no key '" + key + "'"); + return; + } + const auto& value = it->second; + + if (type == "String") { + if (!std::holds_alternative(value)) { + FailStep("metadata '" + key + "' is not a String"); + return; } + ExpectEq(std::get(value), expected_val, + "metadata '" + key + "'"); } else if (type == "Integer") { - auto expected = ParseInt64(expected_str); - cuke::equal( - expected.has_value(), true, - "Expected integer string is not a valid int64: " + expected_str); + const auto expected = ParseInt64(expected_val); if (!expected.has_value()) { + FailStep("expected Integer metadata is not a valid int64: '" + + expected_val + "'"); return; } - auto actual = g_state.last_eval.resolved_value.AsInt(); - cuke::equal(actual.has_value(), true); - if (actual.has_value()) { - cuke::equal(actual.value(), expected.value()); + if (std::holds_alternative(value)) { + ExpectEq(std::get(value), *expected, "metadata '" + key + "'"); + return; + } + if (std::holds_alternative(value)) { + // flagd round-trips metadata through JSON, so a whole number may arrive + // as a double. Accept it only when it is exactly integral. + const auto as_int = AsExactInt64(std::get(value)); + if (!as_int.has_value()) { + FailStep("metadata '" + key + + "' is a non-integral double where an Integer was expected"); + return; + } + ExpectEq(*as_int, *expected, "metadata '" + key + "'"); + return; } + FailStep("metadata '" + key + "' is neither Integer nor Float"); } else if (type == "Float") { - auto expected = ParseDouble(expected_str); - cuke::equal(expected.has_value(), true, - "Expected float string is not a valid double: " + expected_str); + const auto expected = ParseDouble(expected_val); if (!expected.has_value()) { + FailStep("expected Float metadata is not a valid double: '" + + expected_val + "'"); return; } - auto actual = g_state.last_eval.resolved_value.AsDouble(); - cuke::equal(actual.has_value(), true); - if (actual.has_value()) { - cuke::equal(std::abs(actual.value() - expected.value()) < 1e-5, true); + double actual = 0.0; + if (std::holds_alternative(value)) { + actual = std::get(value); + } else if (std::holds_alternative(value)) { + actual = static_cast(std::get(value)); + } else { + FailStep("metadata '" + key + "' is neither Float nor Integer"); + return; } - } else if (type == "Object") { - nlohmann::json expected = - nlohmann::json::parse(expected_str, nullptr, false); - cuke::equal(expected.is_discarded(), false, - "Expected JSON is malformed: " + expected_str); - if (expected.is_discarded()) { + cuke::equal(NearlyEqual(actual, *expected), true, + "metadata '" + key + "' mismatch: got " + + std::to_string(actual) + ", expected " + + std::to_string(*expected)); + } else if (type == "Boolean") { + const auto expected = ParseBool(expected_val); + if (!expected.has_value()) { + FailStep("expected Boolean metadata is not parseable: '" + expected_val + + "'"); return; } - nlohmann::json actual = ValueToJson(g_state.last_eval.resolved_value); - cuke::equal(actual == expected, true, - "Actual JSON does not match expected JSON. Actual: " + - actual.dump() + ", Expected: " + expected.dump()); + if (!std::holds_alternative(value)) { + FailStep("metadata '" + key + "' is not a Boolean"); + return; + } + ExpectEq(std::get(value), *expected, "metadata '" + key + "'"); + } else { + FailStep("unsupported metadata_type '" + type + "' for key '" + key + "'"); } } -THEN(TheReasonShouldBe, "the reason should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.reason.has_value(), false); - } else { - cuke::equal(g_state.last_eval.reason.has_value(), true); - if (g_state.last_eval.reason.has_value()) { - std::string actual = ReasonToString(*g_state.last_eval.reason); - cuke::equal(actual, expected); +} // namespace + +WHEN(TheFlagWasEvaluatedWithDetails, "the flag was evaluated with details") { + const auto& pending = Ctx().scenario.pending_eval; + if (!pending.declared) { + FailStep("no flag was declared before evaluation"); + return; + } + + const ::openfeature::EvaluationContext ctx = BuildEvaluationContext(); + auto client = ::openfeature::OpenFeatureAPI::GetInstance().GetClient(); + if (client == nullptr) { + FailStep("OpenFeatureAPI returned no client; was a provider registered?"); + return; + } + + const std::string& key = pending.flag_key; + const std::string& def_str = pending.default_value_str; + + switch (pending.flag_type) { + case FlagType::kBoolean: { + const auto def_val = ParseBool(def_str); + if (!def_val.has_value()) { + FailStep("default Boolean value is not parseable: '" + def_str + "'"); + return; + } + RecordEvaluationDetails(client->GetBooleanDetails(key, *def_val, ctx)); + return; + } + case FlagType::kString: + RecordEvaluationDetails(client->GetStringDetails(key, def_str, ctx)); + return; + case FlagType::kInteger: { + const auto def_val = ParseInt64(def_str); + if (!def_val.has_value()) { + FailStep("default Integer value is not a valid int64: '" + def_str + + "'"); + return; + } + RecordEvaluationDetails(client->GetIntegerDetails(key, *def_val, ctx)); + return; + } + case FlagType::kFloat: { + const auto def_val = ParseDouble(def_str); + if (!def_val.has_value()) { + FailStep("default Float value is not a valid double: '" + def_str + + "'"); + return; + } + RecordEvaluationDetails(client->GetDoubleDetails(key, *def_val, ctx)); + return; + } + case FlagType::kObject: { + const nlohmann::json parsed = + nlohmann::json::parse(def_str, nullptr, false); + if (parsed.is_discarded()) { + FailStep("default Object value is not valid JSON: '" + def_str + "'"); + return; + } + RecordEvaluationDetails( + client->GetObjectDetails(key, JsonToValue(parsed), ctx)); + return; } } + FailStep("unhandled flag type '" + FlagTypeToString(pending.flag_type) + + "' during evaluation"); +} + +THEN(TheResolvedDetailsValueShouldBe, + "the resolved details value should be {string}") { + CheckResolvedValue(CUKE_ARG(1)); +} + +THEN(TheResolvedDetailsValueShouldBeEmpty, + "the resolved details value should be " GHERKIN_EMPTY_ARG) { + CheckResolvedValue(""); +} + +THEN(TheReasonShouldBe, "the reason should be {string}") { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + const std::string expected = CUKE_ARG(1); + if (!result->reason.has_value()) { + FailStep("no reason was returned, expected '" + expected + "'"); + return; + } + ExpectEq(ReasonToString(*result->reason), expected, "reason mismatch"); +} + +THEN(TheReasonShouldBeEmpty, "the reason should be " GHERKIN_EMPTY_ARG) { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + cuke::equal(result->reason.has_value(), false, + "expected no reason, but one was returned"); } THEN(TheVariantShouldBe, "the variant should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.variant.has_value(), false); - } else { - cuke::equal(g_state.last_eval.variant.has_value(), true); - if (g_state.last_eval.variant.has_value()) { - cuke::equal(g_state.last_eval.variant.value(), expected); - } + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + const std::string expected = CUKE_ARG(1); + if (!result->variant.has_value()) { + FailStep("no variant was returned, expected '" + expected + "'"); + return; + } + ExpectEq(*result->variant, expected, "variant mismatch"); +} + +THEN(TheVariantShouldBeEmpty, "the variant should be " GHERKIN_EMPTY_ARG) { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; } + cuke::equal( + result->variant.value_or("").empty(), true, + "expected no variant, got '" + result->variant.value_or("") + "'"); } THEN(TheErrorCodeShouldBe, "the error-code should be {string}") { - std::string expected = CUKE_ARG(1); - if (expected.empty()) { - cuke::equal(g_state.last_eval.error_code.has_value(), false); - } else { - cuke::equal(g_state.last_eval.error_code.has_value(), true); - if (g_state.last_eval.error_code.has_value()) { - cuke::equal(ErrorCodeToString(*g_state.last_eval.error_code), expected); - } + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + const std::string expected = CUKE_ARG(1); + if (!result->error_code.has_value()) { + FailStep("no error-code was returned, expected '" + expected + "'"); + return; + } + ExpectEq(ErrorCodeToString(*result->error_code), expected, + "error-code mismatch"); +} + +// A blank error_code column asserts that the evaluation produced *no* error, +// so this twin carries real meaning rather than just covering a parse quirk. +THEN(TheErrorCodeShouldBeEmpty, "the error-code should be " GHERKIN_EMPTY_ARG) { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; } + cuke::equal(result->error_code.has_value(), false, + "expected no error-code, got '" + + (result->error_code.has_value() + ? ErrorCodeToString(*result->error_code) + : std::string()) + + "'"); } THEN(TheResolvedMetadataIsEmpty, "the resolved metadata is empty") { - cuke::equal(g_state.last_eval.flag_metadata.data.empty(), true); + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + cuke::equal(result->flag_metadata.data.empty(), true, + "expected empty resolved metadata"); } THEN(TheResolvedMetadataShouldContain, "the resolved metadata should contain") { - const cuke::table& t = CUKE_TABLE(); - const auto& metadata_map = g_state.last_eval.flag_metadata.data; - for (const auto& row : t.hashes()) { - std::string key = row["key"].as(); - std::string type = row["metadata_type"].as(); - std::string expected_val = row["value"].as(); - - auto it = metadata_map.find(key); - cuke::equal(it != metadata_map.end(), true); - if (it == metadata_map.end()) { - continue; - } - const auto& var_val = it->second; - if (type == "String") { - cuke::equal(std::holds_alternative(var_val), true); - if (std::holds_alternative(var_val)) { - cuke::equal(std::get(var_val), expected_val); - } - } else if (type == "Integer") { - auto expected = ParseInt64(expected_val); - cuke::equal(expected.has_value(), true, - "Expected integer metadata string is not a valid int64: " + - expected_val); - if (!expected.has_value()) { - continue; - } - bool is_int = std::holds_alternative(var_val); - bool is_double = std::holds_alternative(var_val); - cuke::equal(is_int || is_double, true); - if (is_int) { - cuke::equal(std::get(var_val), expected.value()); - } else if (is_double) { - double d = std::get(var_val); - bool is_integral = !std::isnan(d) && !std::isinf(d) && - d >= -9223372036854775808.0 && - d < 9223372036854775808.0 && std::trunc(d) == d; - cuke::equal(is_integral, true, - "Metadata double value " + std::to_string(d) + - " is not an in-range integral value"); - if (is_integral) { - cuke::equal(static_cast(d), expected.value()); - } - } - } else if (type == "Float") { - auto expected = ParseDouble(expected_val); - cuke::equal(expected.has_value(), true, - "Expected float metadata string is not a valid double: " + - expected_val); - if (!expected.has_value()) { - continue; - } - bool is_double = std::holds_alternative(var_val); - bool is_int = std::holds_alternative(var_val); - cuke::equal(is_double || is_int, true); - if (is_double) { - cuke::equal( - std::abs(std::get(var_val) - expected.value()) < 1e-5, - true); - } else if (is_int) { - cuke::equal(std::abs(static_cast(std::get(var_val)) - - expected.value()) < 1e-5, - true); - } - } else if (type == "Boolean") { - bool expected = (expected_val == "true" || expected_val == "True"); - cuke::equal(std::holds_alternative(var_val), true); - if (std::holds_alternative(var_val)) { - cuke::equal(std::get(var_val), expected); - } - } + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + const cuke::table& table = CUKE_TABLE(); + for (const auto& row : table.hashes()) { + CheckMetadataEntry( + row["key"].as(), row["metadata_type"].as(), + row["value"].as(), result->flag_metadata.data); } } diff --git a/providers/flagd/tests/gherkin/steps/flag_steps.cpp b/providers/flagd/tests/gherkin/steps/flag_steps.cpp index fcf3bf7..89c2365 100644 --- a/providers/flagd/tests/gherkin/steps/flag_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/flag_steps.cpp @@ -2,76 +2,43 @@ #include "defines.hpp" // for GIVEN #include "get_args.hpp" // for CUKE_ARG -#include "providers/flagd/tests/gherkin/test_state.h" - -using openfeature::contrib::flagd::test::g_state; - -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(ABooleanFlagFallback, - "a Boolean-flag with key {string} and a fallback 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(AStringFlagFallback, - "a String-flag with key {string} and a fallback 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(AIntegerFlagFallback, - "a Integer-flag with key {string} and a fallback 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(AFloatFlagFallback, - "a Float-flag with key {string} and a fallback 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(AnObjectFlagFallback, - "a Object-flag with key {string} and a fallback 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)); -} +#include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_context.h" + +namespace { + +using openfeature::contrib::flagd::test::Ctx; +using openfeature::contrib::flagd::test::FlagType; + +void DeclareFlag(std::string key, FlagType type, std::string default_value) { + auto& pending = Ctx().scenario.pending_eval; + pending.declared = true; + pending.flag_key = std::move(key); + pending.flag_type = type; + pending.default_value_str = std::move(default_value); +} + +} // namespace + +// The testbed spells the same step two ways ("default value" and "fallback +// value") for every flag type; one macro keeps the ten definitions in sync. +#define GHERKIN_DECLARE_FLAG_STEPS(fn_prefix, type_name, flag_type) \ + GIVEN(fn_prefix##WithDefault, "a " type_name \ + "-flag with key {string} and a default value " \ + "{string}") { \ + DeclareFlag(CUKE_ARG(1), flag_type, CUKE_ARG(2)); \ + } \ + GIVEN(fn_prefix##WithFallback, \ + "a " type_name \ + "-flag with key {string} and a fallback value " \ + "{string}") { \ + DeclareFlag(CUKE_ARG(1), flag_type, CUKE_ARG(2)); \ + } + +GHERKIN_DECLARE_FLAG_STEPS(BooleanFlag, "Boolean", FlagType::kBoolean) +GHERKIN_DECLARE_FLAG_STEPS(StringFlag, "String", FlagType::kString) +GHERKIN_DECLARE_FLAG_STEPS(IntegerFlag, "Integer", FlagType::kInteger) +GHERKIN_DECLARE_FLAG_STEPS(FloatFlag, "Float", FlagType::kFloat) +GHERKIN_DECLARE_FLAG_STEPS(ObjectFlag, "Object", FlagType::kObject) + +#undef GHERKIN_DECLARE_FLAG_STEPS diff --git a/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp index 8d85e79..fca070d 100644 --- a/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp @@ -1,15 +1,32 @@ -#include "defines.hpp" // for BEFORE, AFTER +#include +#include +#include + +#include "defines.hpp" // for BEFORE_ALL, BEFORE, AFTER_ALL +#include "providers/flagd/tests/gherkin/test_context.h" #include "providers/flagd/tests/gherkin/test_env.h" -#include "providers/flagd/tests/gherkin/test_state.h" -using openfeature::contrib::flagd::test::ResetTestState; +namespace { + +using openfeature::contrib::flagd::test::ResetScenarioState; using openfeature::contrib::flagd::test::SetupGlobalFlagd; +using openfeature::contrib::flagd::test::TeardownGlobalFlagd; -BEFORE(SetupFlagd) { - ResetTestState(); - SetupGlobalFlagd(); -} +} // namespace -AFTER(CleanupFlagd) { - // Do not stop global flagd between scenarios +// flagd is shared by every scenario, so it belongs in BEFORE_ALL rather than +// in BEFORE behind a "have I already done this?" guard. +BEFORE_ALL(StartFlagd) { + std::string error; + if (!SetupGlobalFlagd(&error)) { + // BEFORE_ALL cannot fail a run, and continuing would report every scenario + // as a provider bug rather than an environment problem. + std::cerr << "CRITICAL: could not prepare the flagd test environment: " + << error << '\n'; + std::exit(1); + } } + +AFTER_ALL(StopFlagd) { TeardownGlobalFlagd(); } + +BEFORE(ResetScenario) { ResetScenarioState(); } diff --git a/providers/flagd/tests/gherkin/steps/provider_steps.cpp b/providers/flagd/tests/gherkin/steps/provider_steps.cpp index 9900c63..e20e3d2 100644 --- a/providers/flagd/tests/gherkin/steps/provider_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/provider_steps.cpp @@ -1,7 +1,6 @@ -#include +#include #include #include -#include #include "defines.hpp" // for GIVEN #include "flagd/configuration.h" @@ -9,53 +8,62 @@ #include "get_args.hpp" // for CUKE_ARG #include "openfeature/openfeature_api.h" #include "providers/flagd/tests/gherkin/steps/step_utils.h" +#include "providers/flagd/tests/gherkin/test_context.h" #include "providers/flagd/tests/gherkin/test_env.h" -#include "providers/flagd/tests/gherkin/test_state.h" - -using openfeature::contrib::flagd::test::g_current_selector; -using openfeature::contrib::flagd::test::g_stable_provider; -using openfeature::contrib::flagd::test::g_state; namespace { +using openfeature::contrib::flagd::test::Ctx; +using openfeature::contrib::flagd::test::FailStep; +using openfeature::contrib::flagd::test::FlagdSyncTarget; +using openfeature::contrib::flagd::test::kFlagdSyncPort; +using openfeature::contrib::flagd::test::WaitForGrpcReady; + void InitializeProvider() { - if (g_stable_provider && g_state.selector == g_current_selector) { - g_state.provider = g_stable_provider; + auto& persistent = Ctx().persistent; + const std::string& selector = Ctx().scenario.selector; + + // Reuse the provider when nothing about it would change; building one costs + // a gRPC connection and a full sync handshake. + if (persistent.provider_registered && persistent.provider != nullptr && + persistent.provider_selector == selector) { return; } - if (!openfeature::contrib::flagd::test::WaitForGrpcReady("localhost:8015")) { - std::cerr << "WARNING: Flagd gRPC service not ready on port 8015\n"; + if (!WaitForGrpcReady(FlagdSyncTarget())) { + FailStep("flagd sync service is not reachable on " + FlagdSyncTarget() + + "\nlast lines of the flagd log:\n" + + openfeature::contrib::flagd::test::GlobalFlagdLogTail()); + return; } ::flagd::FlagdProviderConfig config; config.SetHost("localhost"); - config.SetPort(8015); + config.SetPort(kFlagdSyncPort); config.SetDeadlineMs(5000); - if (!g_state.selector.empty()) { - config.SetSelector(g_state.selector); + if (!selector.empty()) { + config.SetSelector(selector); } - g_stable_provider = std::make_shared<::flagd::FlagdProvider>(config); - g_state.provider = g_stable_provider; - g_current_selector = g_state.selector; + auto provider = std::make_shared<::flagd::FlagdProvider>(config); + ::openfeature::OpenFeatureAPI::GetInstance().SetProviderAndWait(provider); - auto& api = ::openfeature::OpenFeatureAPI::GetInstance(); - api.SetProviderAndWait(g_state.provider); + // Cache only after the API accepts the provider; caching earlier would let + // later scenarios reuse a provider that was never registered. + persistent.provider = std::move(provider); + persistent.provider_selector = selector; + persistent.provider_registered = true; } } // namespace 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; + const std::string option = CUKE_ARG(1); + const std::string value = CUKE_ARG(3); + Ctx().scenario.pending_options[option] = value; + if (option == "selector") { + Ctx().scenario.selector = value; } } diff --git a/providers/flagd/tests/gherkin/steps/step_utils.cpp b/providers/flagd/tests/gherkin/steps/step_utils.cpp index 8935e47..ca82da6 100644 --- a/providers/flagd/tests/gherkin/steps/step_utils.cpp +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -2,20 +2,29 @@ #include #include +#include #include #include +#include #include #include +#include "asserts.hpp" // for cuke::equal #include "openfeature/error_code.h" #include "openfeature/general_flag_evaluation_details.h" #include "openfeature/reason.h" #include "openfeature/value.h" -#include "providers/flagd/tests/gherkin/test_state.h" +#include "providers/flagd/tests/gherkin/test_context.h" namespace openfeature::contrib::flagd::test { -std::string g_current_selector; +void FailStep(const std::string& reason) { cuke::equal(true, false, reason); } + +void FailStepNotImplemented(const std::string& what) { + FailStep(what + + " has no representation in FlagdProviderConfig/FlagdProvider yet, " + "so this expectation cannot be verified"); +} std::string ReasonToString(openfeature::Reason reason) { switch (reason) { @@ -63,14 +72,41 @@ std::string ErrorCodeToString(openfeature::ErrorCode error_code) { return "UNKNOWN_ENUM_VALUE"; } +std::string FlagTypeToString(FlagType type) { + switch (type) { + case FlagType::kBoolean: + return "Boolean"; + case FlagType::kString: + return "String"; + case FlagType::kInteger: + return "Integer"; + case FlagType::kFloat: + return "Float"; + case FlagType::kObject: + return "Object"; + } + return "UNKNOWN_ENUM_VALUE"; +} + +std::optional ParseFlagType(const std::string& name) { + if (name == "Boolean") return FlagType::kBoolean; + if (name == "String") return FlagType::kString; + if (name == "Integer") return FlagType::kInteger; + if (name == "Float") return FlagType::kFloat; + if (name == "Object") return FlagType::kObject; + return std::nullopt; +} + void RecordEvaluationDetails( const openfeature::GeneralFlagEvaluationDetails& details) { - g_state.last_eval.resolved_value = details.GetValueAsValue(); - g_state.last_eval.reason = details.GetReason(); - g_state.last_eval.variant = details.GetVariant(); - g_state.last_eval.error_code = details.GetErrorCode(); - g_state.last_eval.error_message = details.GetErrorMessage(); - g_state.last_eval.flag_metadata = details.GetFlagMetadata(); + EvaluationResult& result = Ctx().scenario.last_eval; + result.recorded = true; + result.value = details.GetValueAsValue(); + result.reason = details.GetReason(); + result.variant = details.GetVariant(); + result.error_code = details.GetErrorCode(); + result.error_message = details.GetErrorMessage(); + result.flag_metadata = details.GetFlagMetadata(); } openfeature::Value JsonToValue(const nlohmann::json& json_val) { @@ -112,28 +148,28 @@ nlohmann::json ValueToJson(const openfeature::Value& val) { return val.AsBool().value(); } if (val.IsNumber()) { - double d = val.AsDouble().value(); - int64_t i = val.AsInt().value(); - if (static_cast(i) == d && !std::isnan(d)) { - return i; + const double as_double = val.AsDouble().value(); + // Emit whole numbers as JSON integers so they compare equal to the + // integer literals in the feature files. AsExactInt64 refuses values the + // int64_t cast could not represent. + if (std::optional exact = AsExactInt64(as_double)) { + return *exact; } - return d; + return as_double; } 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) { + for (const auto& [key, value] : *val.AsStructure()) { 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) { + for (const auto& item : *val.AsList()) { arr.push_back(ValueToJson(item)); } return arr; @@ -167,4 +203,47 @@ std::optional ParseDouble(const std::string& str) { } } +std::optional ParseBool(const std::string& str) { + if (str == "true" || str == "True") return true; + if (str == "false" || str == "False") return false; + return std::nullopt; +} + +std::optional AsExactInt64(double value) { + if (std::isnan(value) || std::isinf(value)) { + return std::nullopt; + } + // 2^63 is the first double above the int64_t range; the lower bound is + // exactly representable, the upper bound is not, hence the asymmetry. + constexpr double kMin = -9223372036854775808.0; + constexpr double kMax = 9223372036854775808.0; + if (value < kMin || value >= kMax) { + return std::nullopt; + } + if (std::trunc(value) != value) { + return std::nullopt; + } + return static_cast(value); +} + +bool NearlyEqual(double lhs, double rhs) { + if (lhs == rhs) { + return true; + } + if (std::isnan(lhs) || std::isnan(rhs)) { + return false; + } + constexpr double kRelativeTolerance = 1e-9; + constexpr double kAbsoluteTolerance = 1e-9; + const double diff = std::abs(lhs - rhs); + if (diff <= kAbsoluteTolerance) { + return true; + } + const double scale = std::max(std::abs(lhs), std::abs(rhs)); + if (scale > std::numeric_limits::max() / 2) { + return false; + } + return diff <= kRelativeTolerance * scale; +} + } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/steps/step_utils.h b/providers/flagd/tests/gherkin/steps/step_utils.h index 48a3eed..929cd1a 100644 --- a/providers/flagd/tests/gherkin/steps/step_utils.h +++ b/providers/flagd/tests/gherkin/steps/step_utils.h @@ -1,26 +1,68 @@ #pragma once #include +#include #include #include #include +#include +#include "asserts.hpp" // for cuke::equal #include "openfeature/error_code.h" #include "openfeature/general_flag_evaluation_details.h" #include "openfeature/reason.h" #include "openfeature/value.h" +// cwt-cucumber expands an empty Examples cell to `""`, which combines with the +// quotes already in the step text to reach the matcher as *four* quotes. The +// built-in `{string}` (`"([^"]*)"`, anchored) cannot match that, so steps whose +// value may be empty are registered twice: once with `{string}` and once with +// this literal. A custom parameter would be tidier, but CUSTOM_PARAMETER and +// step registration race in static init across TUs and throw before main(). +#define GHERKIN_EMPTY_ARG "\"\"\"\"" + namespace openfeature::contrib::flagd::test { -extern std::string g_current_selector; +enum class FlagType { kBoolean, kString, kInteger, kFloat, kObject }; + +// Terminal `else` of every dispatch chain: a step that recognises none of its +// inputs must fail rather than silently run zero assertions. +void FailStep(const std::string& reason); + +void FailStepNotImplemented(const std::string& what); + +// cuke::equal only formats "Value X is not equal to Y" when no custom message +// is supplied, so a bare description would throw away the values needed to +// triage the failure. +template +void ExpectEq(const T& actual, const U& expected, std::string_view context) { + cuke::equal( + actual, expected, + std::format("{}: got '{}', expected '{}'", context, actual, expected)); +} std::string ReasonToString(openfeature::Reason reason); std::string ErrorCodeToString(openfeature::ErrorCode error_code); +std::string FlagTypeToString(FlagType type); +std::optional ParseFlagType(const std::string& name); + void RecordEvaluationDetails( const openfeature::GeneralFlagEvaluationDetails& details); + openfeature::Value JsonToValue(const nlohmann::json& json_val); nlohmann::json ValueToJson(const openfeature::Value& val); + std::optional ParseInt64(const std::string& str); std::optional ParseDouble(const std::string& str); +std::optional ParseBool(const std::string& str); + +// Guards the static_cast, which is undefined behaviour unless `value` +// is finite, integral and in range. +std::optional AsExactInt64(double value); + +// Tolerance scales with magnitude, so large expected values do not fail on +// representation error alone. +bool NearlyEqual(double lhs, double rhs); + } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_context.cpp b/providers/flagd/tests/gherkin/test_context.cpp new file mode 100644 index 0000000..b4ef59c --- /dev/null +++ b/providers/flagd/tests/gherkin/test_context.cpp @@ -0,0 +1,54 @@ +#include "providers/flagd/tests/gherkin/test_context.h" + +#include + +#include +#include +#include + +namespace openfeature::contrib::flagd::test { + +ScopedEnv::~ScopedEnv() { RestoreAll(); } + +void ScopedEnv::Set(const std::string& name, const std::string& value) { + if (!saved_.contains(name)) { + const char* current = getenv(name.c_str()); + saved_.emplace(name, current != nullptr + ? std::optional(current) + : std::nullopt); + } + setenv(name.c_str(), value.c_str(), 1); +} + +void ScopedEnv::RestoreAll() { + for (const auto& [name, value] : saved_) { + if (value.has_value()) { + setenv(name.c_str(), value->c_str(), 1); + } else { + unsetenv(name.c_str()); + } + } + saved_.clear(); +} + +TestContext& Ctx() { + static TestContext* context = new TestContext(); + return *context; +} + +void ResetScenarioState() { + // Put the environment back before discarding the record of what was changed. + Ctx().scenario.env.RestoreAll(); + + Ctx().scenario.selector.clear(); + Ctx().scenario.targeting_key.clear(); + Ctx().scenario.context_attributes.clear(); + Ctx().scenario.nested_context_attributes.clear(); + Ctx().scenario.pending_eval = PendingEvaluation(); + Ctx().scenario.last_eval = EvaluationResult(); + Ctx().scenario.pending_options.clear(); + Ctx().scenario.config.reset(); + Ctx().scenario.config_error = false; +} + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_context.h b/providers/flagd/tests/gherkin/test_context.h new file mode 100644 index 0000000..34c8b1f --- /dev/null +++ b/providers/flagd/tests/gherkin/test_context.h @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "flagd/configuration.h" +#include "flagd/provider.h" +#include "openfeature/error_code.h" +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/steps/step_utils.h" + +namespace openfeature::contrib::flagd::test { + +// Saves environment variables before a scenario overwrites them and puts the +// originals back afterwards, so the BEFORE hook need not duplicate cleanup. +class ScopedEnv { + public: + ScopedEnv() = default; + ~ScopedEnv(); + + ScopedEnv(const ScopedEnv&) = delete; + ScopedEnv& operator=(const ScopedEnv&) = delete; + ScopedEnv(ScopedEnv&&) = delete; + ScopedEnv& operator=(ScopedEnv&&) = delete; + + // Sets `name` to `value`, remembering the prior value on first touch. + void Set(const std::string& name, const std::string& value); + + // Restores every variable touched since the last call. Idempotent. + void RestoreAll(); + + private: + std::map> saved_; +}; + +struct PendingEvaluation { + bool declared = false; + std::string flag_key; + FlagType flag_type = FlagType::kBoolean; + std::string default_value_str; +}; + +// Separate from PendingEvaluation so a THEN step cannot silently assert +// against a previous scenario's leftovers. +struct EvaluationResult { + bool recorded = false; + ::openfeature::Value value; + std::optional<::openfeature::Reason> reason; + std::optional variant; + std::optional<::openfeature::ErrorCode> error_code; + std::optional error_message; + ::openfeature::FlagMetadata flag_metadata; +}; + +struct ScenarioState { + std::string selector; + std::string targeting_key; + std::map context_attributes; + std::map> + nested_context_attributes; + + PendingEvaluation pending_eval; + EvaluationResult last_eval; + + // Applied when "a config was initialized" or a provider step runs. + std::map pending_options; + + std::optional<::flagd::FlagdProviderConfig> config; + bool config_error = false; + + ScopedEnv env; +}; + +// Kept alive across scenarios so each one does not pay for a fresh gRPC +// connection and provider handshake. +struct PersistentState { + std::shared_ptr<::flagd::FlagdProvider> provider; + // A scenario asking for a different selector forces a rebuild. + std::string provider_selector; + bool provider_registered = false; +}; + +struct TestContext { + ScenarioState scenario; + PersistentState persistent; +}; + +TestContext& Ctx(); + +// Restores the environment and clears per-scenario state. Persistent state +// (the cached provider) deliberately survives. +void ResetScenarioState(); + +} // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index b3b7326..8354f39 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -3,30 +3,29 @@ #include #include #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 // NOLINT(misc-include-cleaner) - Used for parsing FLAGD_TEST_FLAGS env var +#include #include #include #include #include #include -#include "flagd/provider.h" #include "tools/cpp/runfiles/runfiles.h" using bazel::tools::cpp::runfiles::Runfiles; @@ -36,297 +35,415 @@ namespace openfeature::contrib::flagd::test { using nlohmann::json; +namespace { + +// Fixture files that cannot be loaded yet. flagd's FlagSync rejects the whole +// payload when schema validation fails, and these two deliberately contain +// targeting rules that are only supposed to be caught at evaluation time. +// TODO(#129): re-enable once FlagSync tolerates them. +constexpr std::string_view kUnsupportedFixtures[] = {"edge-case-flags.json", + "custom-ops.json"}; + std::unique_ptr g_flagd; std::string g_scenario_tmp_dir; -std::shared_ptr<::flagd::FlagdProvider> g_stable_provider; + +bool IsUnsupportedFixture(std::string_view filename) { + for (std::string_view unsupported : kUnsupportedFixtures) { + if (filename == unsupported) { + return true; + } + } + return false; +} + +std::string TmpBaseDir() { + const char* env_tmp = getenv("TEST_TMPDIR"); + if (env_tmp != nullptr && *env_tmp != '\0') { + return env_tmp; + } + return fs::current_path().string(); +} + +// Last write wins, but collisions are reported rather than silent. +void MergeObject(json& target, const json& source, std::string_view section, + std::string_view origin) { + for (const auto& [key, value] : source.items()) { + if (target.contains(key)) { + std::cerr << "WARNING: duplicate " << section << " key '" << key + << "' redefined by " << origin + << "; the later definition wins\n"; + } + target[key] = value; + } +} + +std::vector CollectFixtureFiles() { + std::vector files; + const char* env_flags = getenv("FLAGD_TEST_FLAGS"); + if (env_flags != nullptr) { + std::istringstream iss(env_flags); + std::string path; + while (iss >> path) { + files.push_back(path); + } + } + if (!files.empty()) { + return files; + } + + const std::string flags_dir = GetRunfilePath("flagd_testbed/flags"); + if (flags_dir.empty() || !fs::exists(flags_dir)) { + return files; + } + for (const auto& entry : fs::directory_iterator(flags_dir)) { + if (entry.path().extension() == ".json") { + files.push_back(entry.path().string()); + } + } + return files; +} + +// Resolves a fixture path that may be absolute, runfiles-relative, or a bare +// filename inside the testbed's flags directory. +std::string ResolveFixturePath(const std::string& fixture) { + if (fs::exists(fixture)) { + return fixture; + } + std::string resolved = GetRunfilePath(fixture); + if (!resolved.empty() && fs::exists(resolved)) { + return resolved; + } + if (fixture.find("flagd_testbed/flags/") == std::string::npos) { + resolved = GetRunfilePath("flagd_testbed/flags/" + fixture); + if (!resolved.empty() && fs::exists(resolved)) { + return resolved; + } + } + return {}; +} + +} // namespace std::string GetRunfilePath(const std::string& relative_path) { - static std::unique_ptr runfiles; - if (!runfiles) { + static Runfiles* runfiles = [] { std::string error; - runfiles.reset(Runfiles::CreateForTest(&error)); - if (!runfiles) { + Runfiles* created = Runfiles::CreateForTest(&error); + if (created == nullptr) { std::error_code err_code; - auto exe_path = fs::canonical("/proc/self/exe", err_code); + const auto exe_path = fs::canonical("/proc/self/exe", err_code); if (!err_code) { - runfiles.reset(Runfiles::Create(exe_path.string(), &error)); + created = Runfiles::Create(exe_path.string(), &error); } } - if (!runfiles) { - std::cerr << "Failed to create Runfiles: " << error << '\n'; - exit(1); + if (created == nullptr) { + std::cerr << "CRITICAL: failed to create Runfiles: " << error << '\n'; } + return created; + }(); + + if (runfiles == nullptr) { + return {}; } - std::string path = runfiles->Rlocation(relative_path); - if (path.empty()) { - std::cerr << "Failed to resolve runfile: " << relative_path << '\n'; - } - return path; + return runfiles->Rlocation(relative_path); } bool WaitForGrpcReady(const std::string& target, std::chrono::milliseconds timeout) { auto channel = grpc::CreateChannel(target, grpc::InsecureChannelCredentials()); - auto deadline = std::chrono::system_clock::now() + timeout; - return channel->WaitForConnected(deadline); + return channel->WaitForConnected(std::chrono::system_clock::now() + timeout); +} + +std::string FlagdSyncTarget() { + return "localhost:" + std::to_string(kFlagdSyncPort); } FlagdProcess::FlagdProcess(std::string binary_path, - std::vector sources, int port, - std::string log_dir) - : log_dir_(std::move(log_dir)), - binary_path_(std::move(binary_path)), + std::vector sources, int rpc_port, + int sync_port, std::string log_dir) + : binary_path_(std::move(binary_path)), sources_(std::move(sources)), - port_(port) {} + rpc_port_(rpc_port), + sync_port_(sync_port), + log_dir_(std::move(log_dir)) {} FlagdProcess::~FlagdProcess() { Stop(); } -std::string FlagdProcess::GetTmpDir() { - const char* env_tmp = std::getenv("TEST_TMPDIR"); - if (env_tmp) { - return {env_tmp}; +std::string FlagdProcess::LogPath() const { return log_dir_ + "/flagd.log"; } + +std::string FlagdProcess::TailLog(int max_lines) const { + std::ifstream ifs(LogPath()); + if (!ifs.is_open()) { + return "(no flagd log at " + LogPath() + ")"; } - return "."; + std::deque lines; + std::string line; + while (std::getline(ifs, line)) { + lines.push_back(line); + if (static_cast(lines.size()) > max_lines) { + lines.pop_front(); + } + } + std::string result; + for (const std::string& kept : lines) { + result += " | " + kept + '\n'; + } + return result.empty() ? "(flagd log is empty)" : result; } -bool FlagdProcess::Start() { +bool FlagdProcess::Start(std::string* error) { + // Built in the parent: between fork() and execvp() only async-signal-safe + // calls are legal, and allocating (as std::string and nlohmann::json do) can + // deadlock on a malloc lock another thread held at the moment of the fork. + json sources_arr = json::array(); + for (const auto& src : sources_) { + json src_obj = {{"uri", src.path}, {"provider", "file"}}; + if (!src.selector.empty()) { + src_obj["selector"] = src.selector; + } + sources_arr.push_back(src_obj); + } + const std::string sources_arg = sources_arr.dump(); + const std::string rpc_port_arg = std::to_string(rpc_port_); + const std::string sync_port_arg = std::to_string(sync_port_); + const std::string log_path = LogPath(); + const std::string home_dir = TmpBaseDir(); + + std::vector argv = { + binary_path_.data(), + const_cast("start"), + const_cast("--sources"), + const_cast(sources_arg.c_str()), + const_cast("--port"), + const_cast(rpc_port_arg.c_str()), + const_cast("--sync-port"), + const_cast(sync_port_arg.c_str()), + nullptr, + }; + + // Lets the child report an execvp failure instead of the parent having to + // infer it from a readiness timeout several seconds later. + int exec_status[2]; + if (pipe(exec_status) != 0) { + *error = std::string("pipe() failed: ") + strerror(errno); + return false; + } + if (fcntl(exec_status[1], F_SETFD, FD_CLOEXEC) != 0) { + close(exec_status[0]); + close(exec_status[1]); + *error = std::string("fcntl(FD_CLOEXEC) failed: ") + strerror(errno); + return false; + } + pid_ = fork(); if (pid_ == -1) { - std::cerr << "Failed to fork\n"; + close(exec_status[0]); + close(exec_status[1]); + *error = std::string("fork() failed: ") + strerror(errno); return false; } if (pid_ == 0) { - // Terminate immediately if the parent test runner process exits or crashes. + close(exec_status[0]); + + // Terminate if the parent test runner exits or crashes. prctl(PR_SET_PDEATHSIG, SIGKILL); if (getppid() == 1) { _exit(1); } - std::string tmp_dir = GetTmpDir(); - setenv("HOME", tmp_dir.c_str(), 1); + setenv("HOME", home_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); + const 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& src : sources_) { - json src_obj = {{"uri", src.path}, {"provider", "file"}}; - if (!src.selector.empty()) { - src_obj["selector"] = src.selector; - } - sources_arr.push_back(src_obj); - } - 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(const_cast("--sync-port")); - argv.push_back(const_cast("8015")); - argv.push_back(nullptr); - execvp(argv[0], argv.data()); - std::cerr << "Failed to exec flagd: " << strerror(errno) << '\n'; - _exit(1); + + const int exec_errno = errno; + ssize_t ignored = write(exec_status[1], &exec_errno, sizeof(exec_errno)); + static_cast(ignored); + _exit(127); } - return true; -} + close(exec_status[1]); + int child_errno = 0; + const ssize_t got = read(exec_status[0], &child_errno, sizeof(child_errno)); + close(exec_status[0]); -bool FlagdProcess::IsAlive() const { - if (pid_ <= 0) { + if (got == static_cast(sizeof(child_errno))) { + int status = 0; + waitpid(pid_, &status, 0); + pid_ = -1; + *error = "failed to exec '" + binary_path_ + "': " + strerror(child_errno); return false; } - int status; - return waitpid(pid_, &status, WNOHANG) == 0; + 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)); + if (pid_ <= 0) { + return; + } + kill(pid_, SIGTERM); + int status = 0; + const 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; } - pid_ = -1; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); } + pid_ = -1; } -void SetupGlobalFlagd() { +bool SetupGlobalFlagd(std::string* error) { if (g_flagd) { - return; + return true; } - std::cout << "BEFORE hook: Starting global flagd process\n"; - - std::string flagd_bin = GetRunfilePath("flagd_binary/flagd_linux_x86_64"); + const 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); + *error = "could not find the flagd binary in runfiles"; + return false; } - 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; - if (const char* env_flags = std::getenv("FLAGD_TEST_FLAGS")) { - std::istringstream iss(env_flags); - std::string path; - while (iss >> path) { - flags_files.push_back(path); - } + g_scenario_tmp_dir = (fs::path(TmpBaseDir()) / "gherkin_flagd").string(); + std::error_code ec; + fs::create_directories(g_scenario_tmp_dir, ec); + if (ec) { + *error = "could not create " + g_scenario_tmp_dir + ": " + ec.message(); + return false; } - if (flags_files.empty()) { - std::string flags_dir = GetRunfilePath("flagd_testbed/flags"); - if (flags_dir.empty() || !fs::exists(flags_dir)) { - if (fs::exists("../+_repo_rules+flagd_testbed/flags")) { - flags_dir = "../+_repo_rules+flagd_testbed/flags"; - } - } - if (!flags_dir.empty() && fs::exists(flags_dir)) { - for (const auto& entry : fs::directory_iterator(flags_dir)) { - if (entry.path().extension() == ".json") { - flags_files.push_back(entry.path().string()); - } - } - } + const std::vector fixtures = CollectFixtureFiles(); + if (fixtures.empty()) { + *error = + "no flag fixtures found; expected FLAGD_TEST_FLAGS to be set by the " + "Bazel target, or flagd_testbed/flags to be present in runfiles"; + return false; } - json merged_root = json::object(); - merged_root["flags"] = json::object(); - merged_root["metadata"] = json::object(); - merged_root["$evaluators"] = json::object(); + json merged = json::object(); + merged["flags"] = json::object(); + merged["metadata"] = json::object(); + merged["$evaluators"] = json::object(); std::vector sources; - - for (const auto& flag_file : flags_files) { - fs::path p(flag_file); - std::string filename = p.filename().string(); - - std::string runfile_path; - if (fs::exists(flag_file)) { - runfile_path = flag_file; - } else { - runfile_path = GetRunfilePath(flag_file); - if (runfile_path.empty() && - flag_file.find("flagd_testbed/flags/") == std::string::npos) { - runfile_path = GetRunfilePath("flagd_testbed/flags/" + flag_file); - } - } - - if (runfile_path.empty() || !fs::exists(runfile_path)) { - std::cerr << "WARNING: Could not resolve flag file path: " << flag_file - << '\n'; + int merged_count = 0; + + for (const std::string& fixture : fixtures) { + const std::string filename = fs::path(fixture).filename().string(); + if (IsUnsupportedFixture(filename)) { + std::cerr << "NOTE: skipping fixture " << filename + << " (see TODO(#129)); the scenarios that depend on it will " + "fail\n"; continue; } - // TODO(#129): Re-enable edge-case-flags.json and custom-ops.json once - // schema validation in FlagSync handles flags with invalid targeting rules - // that are expected to be caught at evaluation time. - if (filename == "edge-case-flags.json" || filename == "custom-ops.json") { - continue; + const std::string path = ResolveFixturePath(fixture); + if (path.empty()) { + *error = "could not resolve fixture path: " + fixture; + return false; } + // Files named selector-*.json back the selector scenarios, which need each + // file to stay an addressable sync source of its own rather than being + // folded into the combined payload. if (filename.rfind("selector-", 0) == 0) { - // Copy selector file to tmp scenario directory - fs::path dest_selector_path = fs::path(g_scenario_tmp_dir) / filename; - std::error_code ec; - fs::copy_file(runfile_path, dest_selector_path, - fs::copy_options::overwrite_existing, ec); + const fs::path dest = fs::path(g_scenario_tmp_dir) / filename; + fs::copy_file(path, dest, fs::copy_options::overwrite_existing, ec); if (ec) { - std::cerr << "CRITICAL: Could not copy selector file: " << runfile_path - << " to " << dest_selector_path << " - " << ec.message() - << '\n'; - exit(1); + *error = "could not copy selector fixture " + path + " to " + + dest.string() + ": " + ec.message(); + return false; } - // Register selector file source, matching key format - // "rawflags/selector-flags.json" - sources.push_back({ - .path = dest_selector_path.string(), - .selector = "rawflags/" + filename, - }); + sources.push_back( + {.path = dest.string(), .selector = "rawflags/" + filename}); continue; } - std::ifstream ifs(runfile_path); + std::ifstream ifs(path); if (!ifs.is_open()) { - std::cerr << "CRITICAL: Could not open flag file: " << runfile_path - << '\n'; - exit(1); + *error = "could not open fixture " + path; + return false; } - 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"]); - } + json parsed = json::parse(ifs, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) { + *error = "fixture " + path + " is not a JSON object"; + return false; + } + + if (parsed.contains("flags") && parsed["flags"].is_object()) { + MergeObject(merged["flags"], parsed["flags"], "flag", filename); } + if (parsed.contains("metadata") && parsed["metadata"].is_object()) { + // Flag-set metadata is really a per-source concept. Collapsing every + // fixture into one source means the scenarios see a union no real + // deployment would produce, so at least make collisions visible. + // TODO(#129): register each fixture as its own flagd sync source. + MergeObject(merged["metadata"], parsed["metadata"], "flag-set metadata", + filename); + } + const char* evaluators_key = parsed.contains("$evaluators") ? "$evaluators" + : parsed.contains("evaluators") ? "evaluators" + : nullptr; + if (evaluators_key != nullptr && parsed[evaluators_key].is_object()) { + MergeObject(merged["$evaluators"], parsed[evaluators_key], "$evaluator", + filename); + } + ++merged_count; + } + + if (merged_count == 0) { + *error = "every fixture was skipped; nothing to serve"; + return false; } - fs::path dest = fs::path(g_scenario_tmp_dir) / "all_flags.json"; + const fs::path combined = fs::path(g_scenario_tmp_dir) / "all_flags.json"; { - std::ofstream ofs(dest); + std::ofstream ofs(combined); if (!ofs.is_open()) { - std::cerr << "CRITICAL: Could not open output flag file for writing: " - << dest << '\n'; - exit(1); + *error = "could not write " + combined.string(); + return false; } - ofs << merged_root.dump(2); + ofs << merged.dump(2); if (!ofs.good()) { - std::cerr << "CRITICAL: Failed writing to output flag file: " << dest - << '\n'; - exit(1); + *error = "failed while writing " + combined.string(); + return false; } } - // Add all_flags.json as the default (no selector) source - sources.insert(sources.begin(), { - .path = dest.string(), - .selector = "", - }); - - int port = 8013; - g_flagd = std::make_unique(flagd_bin, sources, port, - g_scenario_tmp_dir); - if (!g_flagd->Start()) { - std::cerr << "CRITICAL: Failed to start flagd\n"; - exit(1); + sources.insert(sources.begin(), {.path = combined.string(), .selector = ""}); + + g_flagd = std::make_unique(flagd_bin, sources, kFlagdRpcPort, + kFlagdSyncPort, g_scenario_tmp_dir); + std::string start_error; + if (!g_flagd->Start(&start_error)) { + *error = "could not start flagd: " + start_error; + g_flagd.reset(); + return false; } - if (!WaitForGrpcReady("localhost:8015", std::chrono::milliseconds(5000))) { - std::cerr << "CRITICAL: Flagd failed to become ready on port 8015\n"; - exit(1); + + if (!WaitForGrpcReady(FlagdSyncTarget())) { + *error = "flagd did not become ready on " + FlagdSyncTarget() + + "\nlast lines of " + g_flagd->LogPath() + ":\n" + + g_flagd->TailLog(); + return false; } + return true; +} + +void TeardownGlobalFlagd() { g_flagd.reset(); } + +std::string GlobalFlagdLogTail() { + return g_flagd ? g_flagd->TailLog() : "(flagd was never started)"; } } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_env.h b/providers/flagd/tests/gherkin/test_env.h index 1d8e7b1..e5c9cb5 100644 --- a/providers/flagd/tests/gherkin/test_env.h +++ b/providers/flagd/tests/gherkin/test_env.h @@ -1,23 +1,28 @@ #pragma once +#include + #include -#include #include #include -#include "flagd/provider.h" - namespace openfeature::contrib::flagd::test { -// Helper to resolve Bazel runfiles for test fixtures and binaries. +// flagd exposes the evaluation API on --port and the flag sync API on +// --sync-port. The provider is in-process only today, so it always talks to +// kFlagdSyncPort; kFlagdRpcPort exists because flagd insists on binding it. +inline constexpr int kFlagdRpcPort = 8013; +inline constexpr int kFlagdSyncPort = 8015; + +// Returns an empty string when the path is unknown. std::string GetRunfilePath(const std::string& relative_path); -// Polls the gRPC channel connection state until it reaches GRPC_CHANNEL_READY -// or times out. bool WaitForGrpcReady( const std::string& target, std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)); +std::string FlagdSyncTarget(); + struct FlagdSource { std::string path; std::string selector; @@ -27,28 +32,36 @@ struct FlagdSource { class FlagdProcess { public: FlagdProcess(std::string binary_path, std::vector sources, - int port, std::string log_dir); + int rpc_port, int sync_port, std::string log_dir); ~FlagdProcess(); - bool Start(); + FlagdProcess(const FlagdProcess&) = delete; + FlagdProcess& operator=(const FlagdProcess&) = delete; + + // On failure returns false and fills `error` with the reason, including + // anything the child managed to report before exec failed. + bool Start(std::string* error); void Stop(); - bool IsAlive() const; - private: - std::string GetTmpDir(); + std::string LogPath() const; + std::string TailLog(int max_lines = 40) const; - std::string log_dir_; + private: std::string binary_path_; std::vector sources_; - int port_; + int rpc_port_; + int sync_port_; + std::string log_dir_; 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; +// Writes the merged fixture files and starts the shared flagd instance. Safe +// to call more than once; only the first call does anything. +bool SetupGlobalFlagd(std::string* error); + +// Safe to call when flagd was never started. +void TeardownGlobalFlagd(); -// Initializes the global flagd test process and merges JSON test fixtures. -void SetupGlobalFlagd(); +std::string GlobalFlagdLogTail(); } // namespace openfeature::contrib::flagd::test diff --git a/providers/flagd/tests/gherkin/test_runner.cpp b/providers/flagd/tests/gherkin/test_runner.cpp index e2c3824..b56ad56 100644 --- a/providers/flagd/tests/gherkin/test_runner.cpp +++ b/providers/flagd/tests/gherkin/test_runner.cpp @@ -5,71 +5,78 @@ #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. +// cwt-cucumber's umbrella header does not re-export this one. #include "test_results.hpp" // for cuke::results::test_status +namespace { + +// Rewrites "--flag=value" into separate "--flag" "value" arguments, which is +// the only form cwt-cucumber's option parser accepts. Returns true when the +// argument belonged to `flag`, so the caller knows not to forward it verbatim. +bool TrySplitInlineValue(std::string_view arg, std::string_view flag, + std::vector* args, bool* seen) { + const std::string prefix = std::string(flag) + "="; + if (!arg.starts_with(prefix)) { + return false; + } + args->emplace_back(flag); + args->emplace_back(arg.substr(prefix.size())); + *seen = true; + return true; +} + +// Appends "--flag " from `env_var`, unless the caller already passed +// the flag on the command line. +void AppendFromEnv(const char* env_var, std::string_view flag, bool already_set, + std::vector* args) { + if (already_set) { + return; + } + const char* value = std::getenv(env_var); + if (value == nullptr || *value == '\0') { + return; + } + args->emplace_back(flag); + args->emplace_back(value); +} + +} // namespace + int main(int argc, char* argv[]) { std::vector args; - args.reserve(argc + 4); - args.push_back(argv[0]); + args.reserve(static_cast(argc) + 4); + args.emplace_back(argv[0]); bool has_tags = false; bool has_name = false; for (int i = 1; i < argc; ++i) { - std::string_view arg = argv[i]; + const std::string_view arg = argv[i]; if (arg == "-t" || arg == "--tags") { has_tags = true; - args.push_back(argv[i]); - } else if (arg.rfind("--tags=", 0) == 0) { - has_tags = true; - args.push_back("--tags"); - args.push_back(std::string(arg.substr(7))); + args.emplace_back(arg); } else if (arg == "-n" || arg == "--name") { has_name = true; - args.push_back(argv[i]); - } else if (arg.rfind("--name=", 0) == 0) { - has_name = true; - args.push_back("--name"); - args.push_back(std::string(arg.substr(7))); + args.emplace_back(arg); + } else if (TrySplitInlineValue(arg, "--tags", &args, &has_tags)) { + continue; + } else if (TrySplitInlineValue(arg, "--name", &args, &has_name)) { + continue; } else { - args.push_back(argv[i]); - } - } - - if (!has_tags) { - if (const char* env_tags = std::getenv("GHERKIN_TAGS")) { - if (*env_tags != '\0') { - args.push_back("--tags"); - args.push_back(env_tags); - } + args.emplace_back(arg); } } - if (!has_name) { - if (const char* env_name = std::getenv("GHERKIN_NAME")) { - if (*env_name != '\0') { - args.push_back("--name"); - args.push_back(env_name); - } - } - } - - std::cout << "Running Gherkin tests with " << args.size() - 1 - << " arguments.\n"; - for (size_t i = 1; i < args.size(); ++i) { - std::cout << " arg[" << i << "]: " << args[i] << '\n'; - } + AppendFromEnv("GHERKIN_TAGS", "--tags", has_tags, &args); + AppendFromEnv("GHERKIN_NAME", "--name", has_name, &args); std::vector argv_c; argv_c.reserve(args.size()); - for (const auto& a : args) { - argv_c.push_back(a.c_str()); + for (const std::string& arg : args) { + argv_c.push_back(arg.c_str()); } - cuke::results::test_status status = + const cuke::results::test_status status = cuke::entry_point(static_cast(argv_c.size()), 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 deleted file mode 100644 index 3b9c8e3..0000000 --- a/providers/flagd/tests/gherkin/test_state.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#include "providers/flagd/tests/gherkin/test_state.h" - -#include - -namespace openfeature::contrib::flagd::test { - -TestState g_state; - -void ResetTestState() { - for (const auto& [var, val] : g_state.saved_env_vars) { - if (val.has_value()) { - setenv(var.c_str(), val->c_str(), 1); - } else { - unsetenv(var.c_str()); - } - } - 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 deleted file mode 100644 index 5fb8f7b..0000000 --- a/providers/flagd/tests/gherkin/test_state.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -#include "flagd/provider.h" -#include "openfeature/error_code.h" -#include "openfeature/flag_metadata.h" -#include "openfeature/reason.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; - std::optional<::openfeature::Reason> reason; - std::optional variant; - std::optional<::openfeature::ErrorCode> error_code; - std::optional error_message; - ::openfeature::FlagMetadata flag_metadata; - } last_eval; - - std::optional<::flagd::FlagdProviderConfig> config; - std::string resolved_resolver = "rpc"; - std::string fatal_status_codes_str; - bool config_error = false; - std::map> saved_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 From 702f90d033a88c18fea76abf712e5f4ff57b8894 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Thu, 17 Sep 2026 11:02:32 +0000 Subject: [PATCH 11/12] Fixed TODO flag Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/BUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index 4aca9d0..5208247 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -91,7 +91,7 @@ cc_test( # otherwise run against the sync port and fail for the wrong reason. "GHERKIN_TAGS": "@in-process", }, - # TODO(#91): drop once the suite is green, so CI can enforce it. + # TODO(#135): drop once the suite is green, so CI can enforce it. tags = ["manual"], target_compatible_with = GHERKIN_PLATFORM, deps = [ From b714bc507939cf10a3f149904b15137e302afe6f Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Thu, 17 Sep 2026 11:59:04 +0000 Subject: [PATCH 12/12] Switched to abseil's Statuses Signed-off-by: Marcin Olko --- providers/flagd/tests/gherkin/BUILD | 29 ++- providers/flagd/tests/gherkin/README.md | 30 +++- .../tests/gherkin/steps/config_steps.cpp | 20 ++- .../tests/gherkin/steps/lifecycle_steps.cpp | 7 +- .../flagd/tests/gherkin/steps/step_utils.cpp | 23 +-- providers/flagd/tests/gherkin/test_env.cpp | 166 +++++++++--------- providers/flagd/tests/gherkin/test_env.h | 14 +- 7 files changed, 171 insertions(+), 118 deletions(-) diff --git a/providers/flagd/tests/gherkin/BUILD b/providers/flagd/tests/gherkin/BUILD index 5208247..4494751 100644 --- a/providers/flagd/tests/gherkin/BUILD +++ b/providers/flagd/tests/gherkin/BUILD @@ -39,6 +39,30 @@ GHERKIN_DATA = [ "@flagd_testbed//:flags", ] +# Deliberately NOT built with GHERKIN_COPTS. absl::SourceLocation aliases to +# std::source_location only under C++20, which changes the mangled name of +# every absl error factory. Abseil itself is built at the repo's default +# standard, so a C++20 translation unit calling absl::NotFoundError fails to +# link. This layer pulls in no cucumber headers, so it stays on the default +# standard and can use absl::Status; the C++20 step definitions only ever +# consume the returned Status, never construct one. +cc_library( + name = "test_env", + testonly = True, + srcs = ["test_env.cpp"], + hdrs = ["test_env.h"], + data = GHERKIN_DATA, + tags = ["manual"], + deps = [ + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/status:statusor", + "@abseil-cpp//absl/strings", + "@bazel_tools//tools/cpp/runfiles", + "@com_github_grpc_grpc//:grpc++", + "@nlohmann_json//:json", + ], +) + # Step definitions register from static initializers that nothing in main() # references, so without alwayslink the linker drops every object file here and # every step reports as UNDEFINED. @@ -54,12 +78,10 @@ cc_library( "steps/provider_steps.cpp", "steps/step_utils.cpp", "test_context.cpp", - "test_env.cpp", ], hdrs = [ "steps/step_utils.h", "test_context.h", - "test_env.h", ], copts = GHERKIN_COPTS, data = GHERKIN_DATA, @@ -67,7 +89,10 @@ cc_library( # the library untagged would still make CI fetch the external repos. tags = ["manual"], deps = [ + ":test_env", "//providers/flagd/src:flagd_provider", + "@abseil-cpp//absl/status", + "@abseil-cpp//absl/strings", "@bazel_tools//tools/cpp/runfiles", "@com_github_grpc_grpc//:grpc++", "@cwt_cucumber//:cwt-cucumber", diff --git a/providers/flagd/tests/gherkin/README.md b/providers/flagd/tests/gherkin/README.md index daf5069..527df2e 100644 --- a/providers/flagd/tests/gherkin/README.md +++ b/providers/flagd/tests/gherkin/README.md @@ -44,7 +44,7 @@ bazel run //providers/flagd/tests/gherkin:gherkin_bin -- \ | File | Purpose | |---|---| | `test_runner.cpp` | `main()`; normalises arguments and calls `cuke::entry_point` | -| `test_env.{h,cpp}` | Starts/stops flagd, merges the fixture files, resolves runfiles | +| `test_env.{h,cpp}` | Starts/stops flagd, merges the fixture files, resolves runfiles. **Separate target, not built with C++20** — see below | | `test_context.{h,cpp}` | All mutable test state, plus environment save/restore | | `steps/flag_steps.cpp` | `a -flag with key ...` | | `steps/context_steps.cpp` | `a context containing ...` | @@ -54,7 +54,7 @@ bazel run //providers/flagd/tests/gherkin:gherkin_bin -- \ | `steps/lifecycle_steps.cpp` | `BEFORE_ALL` / `AFTER_ALL` / per-scenario reset | | `steps/step_utils.{h,cpp}` | Conversions, parsing, assertion helpers | -## Two things that will confuse you +## Three things that will confuse you **Empty Scenario-Outline cells arrive as four quote characters.** cwt-cucumber substitutes an empty Examples cell with the literal `""`, which @@ -69,6 +69,32 @@ linker discards every object file that `main()` does not reference, taking the registrations with it — the binary links cleanly and reports *every* step as undefined. The `gherkin_steps` target sets `alwayslink = True`. +**C++20 changes Abseil's ABI, so `test_env` is a separate target.** +cwt-cucumber requires C++20, but the provider and all its dependencies — including +Abseil — build at the repository default. `absl::SourceLocation` aliases to +`std::source_location` only under C++20, which changes the mangled name of every +Abseil error factory. A C++20 translation unit calling `absl::NotFoundError` therefore +fails to link: + +``` +undefined reference to absl::status_internal::MakeErrorImpl<5>( + string_view, std::source_location) +``` + +`test_env.{h,cpp}` pulls in no cucumber headers, so it lives in its own `cc_library` +*without* `GHERKIN_COPTS` and can use `absl::Status` normally. Do not add +`copts = GHERKIN_COPTS` to that target. + +> [!WARNING] +> The step definitions may only **consume** an `absl::Status` — `.ok()`, +> `.message()`, `operator<<`. Constructing one from a C++20 translation unit will +> not link. `absl::Status` is a single `uintptr_t`, so passing it across the +> boundary is layout-safe, but any Abseil API whose *signature* depends on a C++20 +> feature is not usable from `gherkin_steps`. +> +> If a step ever needs to build a `Status`, add a factory to `test_env` and call +> that instead — or move the whole repository to C++20. + ## Known gaps These are real provider gaps, not harness bugs. Scenarios covering them fail or diff --git a/providers/flagd/tests/gherkin/steps/config_steps.cpp b/providers/flagd/tests/gherkin/steps/config_steps.cpp index b7eb684..d76fce4 100644 --- a/providers/flagd/tests/gherkin/steps/config_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -1,9 +1,10 @@ -#include #include #include #include #include +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" #include "asserts.hpp" // for cuke::equal #include "defines.hpp" // for GIVEN, WHEN, THEN #include "flagd/configuration.h" @@ -91,14 +92,15 @@ void CheckFatalStatusCodes(const ::flagd::FlagdProviderConfig& config, // The testbed uses placeholder names ("A, B"). FlagdProviderConfig parses // the list into grpc::StatusCode and drops what it cannot recognise, so a // placeholder can never round-trip. - const auto expected_count = - std::count(expected.begin(), expected.end(), ',') + 1; - cuke::equal(static_cast(actual.size()), - static_cast(expected_count), - "fatalStatusCodes '" + expected + "' produced " + - std::to_string(actual.size()) + - " parsed code(s); FlagdProviderConfig silently discards " - "tokens it cannot map to a grpc::StatusCode"); + const std::vector expected_codes = + absl::StrSplit(expected, ',', absl::SkipWhitespace()); + cuke::equal( + static_cast(actual.size()), + static_cast(expected_codes.size()), + absl::StrCat("fatalStatusCodes '", expected, "' produced ", actual.size(), + " parsed code(s); FlagdProviderConfig silently " + "discards tokens it cannot map to a " + "grpc::StatusCode")); } void CheckOptionValue(const std::string& option, const std::string& expected) { diff --git a/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp index fca070d..18f3279 100644 --- a/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp +++ b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp @@ -1,7 +1,7 @@ #include #include -#include +#include "absl/status/status.h" #include "defines.hpp" // for BEFORE_ALL, BEFORE, AFTER_ALL #include "providers/flagd/tests/gherkin/test_context.h" #include "providers/flagd/tests/gherkin/test_env.h" @@ -17,12 +17,11 @@ using openfeature::contrib::flagd::test::TeardownGlobalFlagd; // flagd is shared by every scenario, so it belongs in BEFORE_ALL rather than // in BEFORE behind a "have I already done this?" guard. BEFORE_ALL(StartFlagd) { - std::string error; - if (!SetupGlobalFlagd(&error)) { + if (const absl::Status status = SetupGlobalFlagd(); !status.ok()) { // BEFORE_ALL cannot fail a run, and continuing would report every scenario // as a provider bug rather than an environment problem. std::cerr << "CRITICAL: could not prepare the flagd test environment: " - << error << '\n'; + << status << '\n'; std::exit(1); } } diff --git a/providers/flagd/tests/gherkin/steps/step_utils.cpp b/providers/flagd/tests/gherkin/steps/step_utils.cpp index ca82da6..4cdf6db 100644 --- a/providers/flagd/tests/gherkin/steps/step_utils.cpp +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -9,6 +9,7 @@ #include #include +#include "absl/strings/numbers.h" #include "asserts.hpp" // for cuke::equal #include "openfeature/error_code.h" #include "openfeature/general_flag_evaluation_details.h" @@ -178,29 +179,19 @@ nlohmann::json ValueToJson(const openfeature::Value& val) { } std::optional ParseInt64(const std::string& str) { - try { - size_t idx = 0; - int64_t val = std::stoll(str, &idx); - if (idx != str.size()) { - return std::nullopt; - } - return val; - } catch (...) { + int64_t val = 0; + if (!absl::SimpleAtoi(str, &val)) { return std::nullopt; } + return val; } std::optional ParseDouble(const std::string& str) { - try { - size_t idx = 0; - double val = std::stod(str, &idx); - if (idx != str.size()) { - return std::nullopt; - } - return val; - } catch (...) { + double val = 0; + if (!absl::SimpleAtod(str, &val)) { return std::nullopt; } + return val; } std::optional ParseBool(const std::string& str) { diff --git a/providers/flagd/tests/gherkin/test_env.cpp b/providers/flagd/tests/gherkin/test_env.cpp index 8354f39..52d4e7b 100644 --- a/providers/flagd/tests/gherkin/test_env.cpp +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -19,13 +19,16 @@ #include #include #include -#include #include #include #include #include #include +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_split.h" #include "tools/cpp/runfiles/runfiles.h" using bazel::tools::cpp::runfiles::Runfiles; @@ -81,21 +84,19 @@ std::vector CollectFixtureFiles() { std::vector files; const char* env_flags = getenv("FLAGD_TEST_FLAGS"); if (env_flags != nullptr) { - std::istringstream iss(env_flags); - std::string path; - while (iss >> path) { - files.push_back(path); - } + files = + absl::StrSplit(env_flags, absl::ByAnyChar(" \t\n"), absl::SkipEmpty()); } if (!files.empty()) { return files; } - const std::string flags_dir = GetRunfilePath("flagd_testbed/flags"); - if (flags_dir.empty() || !fs::exists(flags_dir)) { + const absl::StatusOr flags_dir = + GetRunfilePath("flagd_testbed/flags"); + if (!flags_dir.ok() || !fs::exists(*flags_dir)) { return files; } - for (const auto& entry : fs::directory_iterator(flags_dir)) { + for (const auto& entry : fs::directory_iterator(*flags_dir)) { if (entry.path().extension() == ".json") { files.push_back(entry.path().string()); } @@ -103,28 +104,30 @@ std::vector CollectFixtureFiles() { return files; } -// Resolves a fixture path that may be absolute, runfiles-relative, or a bare -// filename inside the testbed's flags directory. -std::string ResolveFixturePath(const std::string& fixture) { +// A fixture path may be absolute, runfiles-relative, or a bare filename inside +// the testbed's flags directory. +absl::StatusOr ResolveFixturePath(const std::string& fixture) { if (fs::exists(fixture)) { return fixture; } - std::string resolved = GetRunfilePath(fixture); - if (!resolved.empty() && fs::exists(resolved)) { - return resolved; + absl::StatusOr resolved = GetRunfilePath(fixture); + if (resolved.ok() && fs::exists(*resolved)) { + return *resolved; } if (fixture.find("flagd_testbed/flags/") == std::string::npos) { - resolved = GetRunfilePath("flagd_testbed/flags/" + fixture); - if (!resolved.empty() && fs::exists(resolved)) { - return resolved; + resolved = GetRunfilePath(absl::StrCat("flagd_testbed/flags/", fixture)); + if (resolved.ok() && fs::exists(*resolved)) { + return *resolved; } } - return {}; + return absl::NotFoundError( + absl::StrCat("could not resolve fixture path: ", fixture)); } } // namespace -std::string GetRunfilePath(const std::string& relative_path) { +absl::StatusOr GetRunfilePath(const std::string& relative_path) { + static std::string* creation_error = new std::string(); static Runfiles* runfiles = [] { std::string error; Runfiles* created = Runfiles::CreateForTest(&error); @@ -136,15 +139,21 @@ std::string GetRunfilePath(const std::string& relative_path) { } } if (created == nullptr) { - std::cerr << "CRITICAL: failed to create Runfiles: " << error << '\n'; + *creation_error = error; } return created; }(); if (runfiles == nullptr) { - return {}; + return absl::InternalError( + absl::StrCat("failed to create Runfiles: ", *creation_error)); + } + std::string resolved = runfiles->Rlocation(relative_path); + if (resolved.empty()) { + return absl::NotFoundError( + absl::StrCat("no runfile named '", relative_path, "'")); } - return runfiles->Rlocation(relative_path); + return resolved; } bool WaitForGrpcReady(const std::string& target, @@ -155,7 +164,7 @@ bool WaitForGrpcReady(const std::string& target, } std::string FlagdSyncTarget() { - return "localhost:" + std::to_string(kFlagdSyncPort); + return absl::StrCat("localhost:", kFlagdSyncPort); } FlagdProcess::FlagdProcess(std::string binary_path, @@ -169,12 +178,14 @@ FlagdProcess::FlagdProcess(std::string binary_path, FlagdProcess::~FlagdProcess() { Stop(); } -std::string FlagdProcess::LogPath() const { return log_dir_ + "/flagd.log"; } +std::string FlagdProcess::LogPath() const { + return absl::StrCat(log_dir_, "/flagd.log"); +} std::string FlagdProcess::TailLog(int max_lines) const { std::ifstream ifs(LogPath()); if (!ifs.is_open()) { - return "(no flagd log at " + LogPath() + ")"; + return absl::StrCat("(no flagd log at ", LogPath(), ")"); } std::deque lines; std::string line; @@ -186,12 +197,12 @@ std::string FlagdProcess::TailLog(int max_lines) const { } std::string result; for (const std::string& kept : lines) { - result += " | " + kept + '\n'; + absl::StrAppend(&result, " | ", kept, "\n"); } return result.empty() ? "(flagd log is empty)" : result; } -bool FlagdProcess::Start(std::string* error) { +absl::Status FlagdProcess::Start() { // Built in the parent: between fork() and execvp() only async-signal-safe // calls are legal, and allocating (as std::string and nlohmann::json do) can // deadlock on a malloc lock another thread held at the moment of the fork. @@ -225,22 +236,22 @@ bool FlagdProcess::Start(std::string* error) { // infer it from a readiness timeout several seconds later. int exec_status[2]; if (pipe(exec_status) != 0) { - *error = std::string("pipe() failed: ") + strerror(errno); - return false; + return absl::InternalError( + absl::StrCat("pipe() failed: ", strerror(errno))); } if (fcntl(exec_status[1], F_SETFD, FD_CLOEXEC) != 0) { close(exec_status[0]); close(exec_status[1]); - *error = std::string("fcntl(FD_CLOEXEC) failed: ") + strerror(errno); - return false; + return absl::InternalError( + absl::StrCat("fcntl(FD_CLOEXEC) failed: ", strerror(errno))); } pid_ = fork(); if (pid_ == -1) { close(exec_status[0]); close(exec_status[1]); - *error = std::string("fork() failed: ") + strerror(errno); - return false; + return absl::InternalError( + absl::StrCat("fork() failed: ", strerror(errno))); } if (pid_ == 0) { @@ -279,10 +290,10 @@ bool FlagdProcess::Start(std::string* error) { int status = 0; waitpid(pid_, &status, 0); pid_ = -1; - *error = "failed to exec '" + binary_path_ + "': " + strerror(child_errno); - return false; + return absl::InternalError(absl::StrCat("failed to exec '", binary_path_, + "': ", strerror(child_errno))); } - return true; + return absl::OkStatus(); } void FlagdProcess::Stop() { @@ -303,32 +314,32 @@ void FlagdProcess::Stop() { pid_ = -1; } -bool SetupGlobalFlagd(std::string* error) { +absl::Status SetupGlobalFlagd() { if (g_flagd) { - return true; + return absl::OkStatus(); } - const std::string flagd_bin = + const absl::StatusOr flagd_bin = GetRunfilePath("flagd_binary/flagd_linux_x86_64"); - if (flagd_bin.empty()) { - *error = "could not find the flagd binary in runfiles"; - return false; + if (!flagd_bin.ok()) { + return absl::NotFoundError( + absl::StrCat("could not find the flagd binary in runfiles: ", + flagd_bin.status().message())); } g_scenario_tmp_dir = (fs::path(TmpBaseDir()) / "gherkin_flagd").string(); std::error_code ec; fs::create_directories(g_scenario_tmp_dir, ec); if (ec) { - *error = "could not create " + g_scenario_tmp_dir + ": " + ec.message(); - return false; + return absl::InternalError(absl::StrCat( + "could not create ", g_scenario_tmp_dir, ": ", ec.message())); } const std::vector fixtures = CollectFixtureFiles(); if (fixtures.empty()) { - *error = + return absl::FailedPreconditionError( "no flag fixtures found; expected FLAGD_TEST_FLAGS to be set by the " - "Bazel target, or flagd_testbed/flags to be present in runfiles"; - return false; + "Bazel target, or flagd_testbed/flags to be present in runfiles"); } json merged = json::object(); @@ -348,10 +359,9 @@ bool SetupGlobalFlagd(std::string* error) { continue; } - const std::string path = ResolveFixturePath(fixture); - if (path.empty()) { - *error = "could not resolve fixture path: " + fixture; - return false; + const absl::StatusOr path = ResolveFixturePath(fixture); + if (!path.ok()) { + return path.status(); } // Files named selector-*.json back the selector scenarios, which need each @@ -359,26 +369,26 @@ bool SetupGlobalFlagd(std::string* error) { // folded into the combined payload. if (filename.rfind("selector-", 0) == 0) { const fs::path dest = fs::path(g_scenario_tmp_dir) / filename; - fs::copy_file(path, dest, fs::copy_options::overwrite_existing, ec); + fs::copy_file(*path, dest, fs::copy_options::overwrite_existing, ec); if (ec) { - *error = "could not copy selector fixture " + path + " to " + - dest.string() + ": " + ec.message(); - return false; + return absl::InternalError( + absl::StrCat("could not copy selector fixture ", *path, " to ", + dest.string(), ": ", ec.message())); } - sources.push_back( - {.path = dest.string(), .selector = "rawflags/" + filename}); + sources.push_back({.path = dest.string(), + .selector = absl::StrCat("rawflags/", filename)}); continue; } - std::ifstream ifs(path); + std::ifstream ifs(*path); if (!ifs.is_open()) { - *error = "could not open fixture " + path; - return false; + return absl::InternalError( + absl::StrCat("could not open fixture ", *path)); } json parsed = json::parse(ifs, nullptr, false); if (parsed.is_discarded() || !parsed.is_object()) { - *error = "fixture " + path + " is not a JSON object"; - return false; + return absl::InvalidArgumentError( + absl::StrCat("fixture ", *path, " is not a JSON object")); } if (parsed.contains("flags") && parsed["flags"].is_object()) { @@ -403,41 +413,39 @@ bool SetupGlobalFlagd(std::string* error) { } if (merged_count == 0) { - *error = "every fixture was skipped; nothing to serve"; - return false; + return absl::FailedPreconditionError( + "every fixture was skipped; nothing to serve"); } const fs::path combined = fs::path(g_scenario_tmp_dir) / "all_flags.json"; { std::ofstream ofs(combined); if (!ofs.is_open()) { - *error = "could not write " + combined.string(); - return false; + return absl::InternalError( + absl::StrCat("could not write ", combined.string())); } ofs << merged.dump(2); if (!ofs.good()) { - *error = "failed while writing " + combined.string(); - return false; + return absl::InternalError( + absl::StrCat("failed while writing ", combined.string())); } } sources.insert(sources.begin(), {.path = combined.string(), .selector = ""}); - g_flagd = std::make_unique(flagd_bin, sources, kFlagdRpcPort, + g_flagd = std::make_unique(*flagd_bin, sources, kFlagdRpcPort, kFlagdSyncPort, g_scenario_tmp_dir); - std::string start_error; - if (!g_flagd->Start(&start_error)) { - *error = "could not start flagd: " + start_error; + if (const absl::Status started = g_flagd->Start(); !started.ok()) { g_flagd.reset(); - return false; + return absl::InternalError( + absl::StrCat("could not start flagd: ", started.message())); } if (!WaitForGrpcReady(FlagdSyncTarget())) { - *error = "flagd did not become ready on " + FlagdSyncTarget() + - "\nlast lines of " + g_flagd->LogPath() + ":\n" + - g_flagd->TailLog(); - return false; + return absl::UnavailableError(absl::StrCat( + "flagd did not become ready on ", FlagdSyncTarget(), "\nlast lines of ", + g_flagd->LogPath(), ":\n", g_flagd->TailLog())); } - return true; + return absl::OkStatus(); } void TeardownGlobalFlagd() { g_flagd.reset(); } diff --git a/providers/flagd/tests/gherkin/test_env.h b/providers/flagd/tests/gherkin/test_env.h index e5c9cb5..8dfc40e 100644 --- a/providers/flagd/tests/gherkin/test_env.h +++ b/providers/flagd/tests/gherkin/test_env.h @@ -6,6 +6,9 @@ #include #include +#include "absl/status/status.h" +#include "absl/status/statusor.h" + namespace openfeature::contrib::flagd::test { // flagd exposes the evaluation API on --port and the flag sync API on @@ -14,8 +17,7 @@ namespace openfeature::contrib::flagd::test { inline constexpr int kFlagdRpcPort = 8013; inline constexpr int kFlagdSyncPort = 8015; -// Returns an empty string when the path is unknown. -std::string GetRunfilePath(const std::string& relative_path); +absl::StatusOr GetRunfilePath(const std::string& relative_path); bool WaitForGrpcReady( const std::string& target, @@ -38,9 +40,9 @@ class FlagdProcess { FlagdProcess(const FlagdProcess&) = delete; FlagdProcess& operator=(const FlagdProcess&) = delete; - // On failure returns false and fills `error` with the reason, including - // anything the child managed to report before exec failed. - bool Start(std::string* error); + // A failed status carries anything the child managed to report before exec + // failed. + absl::Status Start(); void Stop(); std::string LogPath() const; @@ -57,7 +59,7 @@ class FlagdProcess { // Writes the merged fixture files and starts the shared flagd instance. Safe // to call more than once; only the first call does anything. -bool SetupGlobalFlagd(std::string* error); +absl::Status SetupGlobalFlagd(); // Safe to call when flagd was never started. void TeardownGlobalFlagd();