diff --git a/MODULE.bazel b/MODULE.bazel index 6f2f34f..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", @@ -42,3 +43,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..53e5057 --- /dev/null +++ b/providers/flagd/cwt_cucumber.BUILD @@ -0,0 +1,38 @@ +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/'); + 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) > $@ + """, +) + +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/.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/BUILD b/providers/flagd/tests/gherkin/BUILD new file mode 100644 index 0000000..d067bef --- /dev/null +++ b/providers/flagd/tests/gherkin/BUILD @@ -0,0 +1,69 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary", "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", +] + +DATA = [ + "@flagd_binary//:flagd", + "@flagd_testbed//:features", + "@flagd_testbed//:flags", +] + +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", +] + +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", + ], + deps = DEPS, +) + +cc_test( + name = "gherkin_test", + srcs = SRCS, + args = [ + "$(locations @flagd_testbed//:features)", + ], + 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"], + 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 new file mode 100644 index 0000000..8c4962c --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -0,0 +1,277 @@ +#include +#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; + +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); + 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); +} + +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); + } 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); + } + } + + 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; + } +} + +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); +} + +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") { + 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(); +} 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..dc24a77 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -0,0 +1,245 @@ +#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::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::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") { + 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.value()); + } + } 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); + 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.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 == expected, true, + "Actual JSON does not match expected JSON. Actual: " + + actual.dump() + ", Expected: " + 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") { + 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) { + cuke::equal(static_cast(std::get(var_val)), + 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); + } + } + } +} 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..9900c63 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/provider_steps.cpp @@ -0,0 +1,68 @@ +#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; + } + + 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); + 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); +} + +} // 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..8935e47 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -0,0 +1,170 @@ +#include "providers/flagd/tests/gherkin/steps/step_utils.h" + +#include +#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()) { + double d = val.AsDouble().value(); + int64_t i = val.AsInt().value(); + if (static_cast(i) == d && !std::isnan(d)) { + return i; + } + return d; + } + 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; +} + +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 new file mode 100644 index 0000000..48a3eed --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#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); +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 new file mode 100644 index 0000000..b3b7326 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -0,0 +1,332 @@ +#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 + +#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 "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; +} + +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) + : log_dir_(std::move(log_dir)), + binary_path_(std::move(binary_path)), + sources_(std::move(sources)), + 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) { + // 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); + + 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& 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); + } + + 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); + 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; + 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(); + 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(); + + 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; + } + + // 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; + 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 + << '\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); + 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(), { + .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); + } + 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 new file mode 100644 index 0000000..1d8e7b1 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#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); + +// 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; +}; + +// Manages a background Go flagd server subprocess during test execution. +class FlagdProcess { + public: + FlagdProcess(std::string binary_path, std::vector sources, + int port, std::string log_dir); + ~FlagdProcess(); + + bool Start(); + void Stop(); + bool IsAlive() const; + + private: + std::string GetTmpDir(); + + std::string log_dir_; + std::string binary_path_; + std::vector sources_; + 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..e2c3824 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_runner.cpp @@ -0,0 +1,76 @@ +#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 "test_results.hpp" // for cuke::results::test_status + +int main(int argc, char* argv[]) { + 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::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; + 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(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 new file mode 100644 index 0000000..3b9c8e3 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_state.cpp @@ -0,0 +1,20 @@ +#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 new file mode 100644 index 0000000..5fb8f7b --- /dev/null +++ b/providers/flagd/tests/gherkin/test_state.h @@ -0,0 +1,52 @@ +#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