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..53d6da3 --- /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..4494751 --- /dev/null +++ b/providers/flagd/tests/gherkin/BUILD @@ -0,0 +1,148 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") + +# 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", +] + +# 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", +] + +# 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", +] + +# 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. +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", + ], + hdrs = [ + "steps/step_utils.h", + "test_context.h", + ], + 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 = [ + ":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", + "@nlohmann_json//:json", + "@openfeature_cpp_sdk//openfeature", + "@openfeature_cpp_sdk//openfeature:openfeature_api", + ], + alwayslink = True, +) + +cc_test( + name = "gherkin_test", + 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(#135): 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", + ], +) + +# 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": "$(rootpaths @flagd_testbed//:flags)", + }, + tags = ["manual"], + target_compatible_with = GHERKIN_PLATFORM, + deps = [ + ":gherkin_steps", + "@cwt_cucumber//:cwt-cucumber", + ], +) diff --git a/providers/flagd/tests/gherkin/README.md b/providers/flagd/tests/gherkin/README.md new file mode 100644 index 0000000..527df2e --- /dev/null +++ b/providers/flagd/tests/gherkin/README.md @@ -0,0 +1,114 @@ +# 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. **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 ...` | +| `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 | + +## 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 +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`. + +**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 +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 new file mode 100644 index 0000000..d76fce4 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/config_steps.cpp @@ -0,0 +1,276 @@ +#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" +#include "get_args.hpp" // for CUKE_ARG +#include "grpcpp/support/status.h" +#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::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; + } + + // 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 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) { + 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") { + ExpectEq(config.GetHost(), expected, "option 'host'"); + } else if (option == "port") { + ExpectIntEquals(config.GetPort(), expected, option); + } else if (option == "tls") { + 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") { + ExpectIntEquals(config.GetDeadlineMs(), expected, option); + } else if (option == "streamDeadlineMs") { + ExpectIntEquals(config.GetStreamDeadlineMs(), expected, option); + } else if (option == "retryBackoffMs") { + ExpectIntEquals(config.GetRetryBackoffMs(), expected, option); + } else if (option == "retryBackoffMaxMs") { + ExpectIntEquals(config.GetRetryBackoffMaxMs(), expected, option); + } else if (option == "retryGracePeriod") { + ExpectIntEquals(config.GetRetryGracePeriod(), expected, option); + } else if (option == "keepAliveTime") { + ExpectIntEquals(config.GetKeepAliveTimeMs(), expected, option); + } else if (option == "offlinePollIntervalMs") { + ExpectIntEquals(config.GetOfflinePollIntervalMs(), expected, option); + } else if (option == "targetUri") { + ExpectOptionalEquals(config.GetTargetUri(), expected, option); + } else if (option == "certPath") { + ExpectOptionalEquals(config.GetCertPath(), expected, option); + } else if (option == "socketPath") { + ExpectOptionalEquals(config.GetSocketPath(), expected, option); + } else if (option == "selector") { + ExpectOptionalEquals(config.GetSelector(), expected, option); + } else if (option == "providerId") { + ExpectOptionalEquals(config.GetProviderId(), expected, option); + } else if (option == "offlineFlagSourcePath") { + ExpectOptionalEquals(config.GetOfflineFlagSourcePath(), expected, option); + } else if (option == "fatalStatusCodes") { + CheckFatalStatusCodes(config, expected); + } else { + FailStep("unknown config option '" + option + "'"); + } +} + +} // namespace + +GIVEN(AnEnvironmentVariableWithValue, + "an environment variable {string} with value {string}") { + 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") { + // 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; + + for (const auto& [option, value] : Ctx().scenario.pending_options) { + if (IsUnmodelledOption(option)) { + continue; + } + 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; + } + } + + 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}") { + CheckOptionValue(CUKE_ARG(1), CUKE_ARG(3)); +} + +THEN(TheOptionOfTypeShouldHaveEmptyValue, + "the option {string} of type {string} should have the " + "value " GHERKIN_EMPTY_ARG) { + CheckOptionValue(CUKE_ARG(1), ""); +} + +THEN(WeShouldHaveAnError, "we should have an error") { + // 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 new file mode 100644 index 0000000..028e578 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/context_steps.cpp @@ -0,0 +1,81 @@ +#include + +#include "defines.hpp" // for GIVEN +#include "get_args.hpp" // for CUKE_ARG +#include "openfeature/value.h" +#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::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") { + 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}") { + 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}") { + 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 new file mode 100644 index 0000000..6852ab6 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/evaluation_steps.cpp @@ -0,0 +1,401 @@ +#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_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::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; + +::openfeature::EvaluationContext BuildEvaluationContext() { + ::openfeature::EvaluationContext::Builder builder; + const auto& scenario = Ctx().scenario; + + if (!scenario.targeting_key.empty()) { + builder.WithTargetingKey(scenario.targeting_key); + } + for (const auto& [key, value] : scenario.context_attributes) { + builder.WithAttribute(key, value); + } + 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(object)); + } + return builder.build(); +} + +// 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; +} + +void CheckResolvedValue(const std::string& expected_str) { + const auto* result = RequireEvaluation(); + if (result == nullptr) { + return; + } + + 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; + } + 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; + } + 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; + } + const nlohmann::json actual = ValueToJson(result->value); + cuke::equal(actual == expected, true, + "resolved Object mismatch. Actual: " + actual.dump() + + ", expected: " + expected.dump()); + return; + } + } + FailStep("unhandled flag type in resolved-value assertion"); +} + +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") { + const auto expected = ParseInt64(expected_val); + if (!expected.has_value()) { + FailStep("expected Integer metadata is not a valid int64: '" + + expected_val + "'"); + return; + } + 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") { + const auto expected = ParseDouble(expected_val); + if (!expected.has_value()) { + FailStep("expected Float metadata is not a valid double: '" + + expected_val + "'"); + return; + } + 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; + } + 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; + } + 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 + "'"); + } +} + +} // 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}") { + 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}") { + 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") { + 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 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 new file mode 100644 index 0000000..89c2365 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/flag_steps.cpp @@ -0,0 +1,44 @@ +#include + +#include "defines.hpp" // for GIVEN +#include "get_args.hpp" // for CUKE_ARG +#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 new file mode 100644 index 0000000..18f3279 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/lifecycle_steps.cpp @@ -0,0 +1,31 @@ +#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" + +namespace { + +using openfeature::contrib::flagd::test::ResetScenarioState; +using openfeature::contrib::flagd::test::SetupGlobalFlagd; +using openfeature::contrib::flagd::test::TeardownGlobalFlagd; + +} // namespace + +// 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) { + 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: " + << status << '\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 new file mode 100644 index 0000000..e20e3d2 --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/provider_steps.cpp @@ -0,0 +1,76 @@ +#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_context.h" +#include "providers/flagd/tests/gherkin/test_env.h" + +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() { + 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 (!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(kFlagdSyncPort); + config.SetDeadlineMs(5000); + if (!selector.empty()) { + config.SetSelector(selector); + } + + auto provider = std::make_shared<::flagd::FlagdProvider>(config); + ::openfeature::OpenFeatureAPI::GetInstance().SetProviderAndWait(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}") { + 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; + } +} + +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..4cdf6db --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.cpp @@ -0,0 +1,240 @@ +#include "providers/flagd/tests/gherkin/steps/step_utils.h" + +#include +#include +#include +#include +#include +#include +#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" +#include "openfeature/reason.h" +#include "openfeature/value.h" +#include "providers/flagd/tests/gherkin/test_context.h" + +namespace openfeature::contrib::flagd::test { + +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) { + 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"; +} + +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) { + 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) { + 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()) { + 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 as_double; + } + if (val.IsString()) { + return val.AsString().value(); + } + if (val.IsStructure()) { + nlohmann::json obj = nlohmann::json::object(); + for (const auto& [key, value] : *val.AsStructure()) { + obj[key] = ValueToJson(value); + } + return obj; + } + if (val.IsList()) { + nlohmann::json arr = nlohmann::json::array(); + for (const auto& item : *val.AsList()) { + arr.push_back(ValueToJson(item)); + } + return arr; + } + return nullptr; +} + +std::optional ParseInt64(const std::string& str) { + int64_t val = 0; + if (!absl::SimpleAtoi(str, &val)) { + return std::nullopt; + } + return val; +} + +std::optional ParseDouble(const std::string& str) { + double val = 0; + if (!absl::SimpleAtod(str, &val)) { + return std::nullopt; + } + return val; +} + +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 new file mode 100644 index 0000000..929cd1a --- /dev/null +++ b/providers/flagd/tests/gherkin/steps/step_utils.h @@ -0,0 +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 { + +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 new file mode 100644 index 0000000..52d4e7b --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.cpp @@ -0,0 +1,457 @@ +#include "providers/flagd/tests/gherkin/test_env.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#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; +namespace fs = std::filesystem; + +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; + +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) { + files = + absl::StrSplit(env_flags, absl::ByAnyChar(" \t\n"), absl::SkipEmpty()); + } + if (!files.empty()) { + return files; + } + + 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)) { + if (entry.path().extension() == ".json") { + files.push_back(entry.path().string()); + } + } + return files; +} + +// 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; + } + absl::StatusOr resolved = GetRunfilePath(fixture); + if (resolved.ok() && fs::exists(*resolved)) { + return *resolved; + } + if (fixture.find("flagd_testbed/flags/") == std::string::npos) { + resolved = GetRunfilePath(absl::StrCat("flagd_testbed/flags/", fixture)); + if (resolved.ok() && fs::exists(*resolved)) { + return *resolved; + } + } + return absl::NotFoundError( + absl::StrCat("could not resolve fixture path: ", fixture)); +} + +} // namespace + +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); + if (created == nullptr) { + std::error_code err_code; + const auto exe_path = fs::canonical("/proc/self/exe", err_code); + if (!err_code) { + created = Runfiles::Create(exe_path.string(), &error); + } + } + if (created == nullptr) { + *creation_error = error; + } + return created; + }(); + + if (runfiles == nullptr) { + 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 resolved; +} + +bool WaitForGrpcReady(const std::string& target, + std::chrono::milliseconds timeout) { + auto channel = + grpc::CreateChannel(target, grpc::InsecureChannelCredentials()); + return channel->WaitForConnected(std::chrono::system_clock::now() + timeout); +} + +std::string FlagdSyncTarget() { + return absl::StrCat("localhost:", kFlagdSyncPort); +} + +FlagdProcess::FlagdProcess(std::string 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)), + rpc_port_(rpc_port), + sync_port_(sync_port), + log_dir_(std::move(log_dir)) {} + +FlagdProcess::~FlagdProcess() { Stop(); } + +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 absl::StrCat("(no flagd log at ", LogPath(), ")"); + } + 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) { + absl::StrAppend(&result, " | ", kept, "\n"); + } + return result.empty() ? "(flagd log is empty)" : result; +} + +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. + 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) { + 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]); + return absl::InternalError( + absl::StrCat("fcntl(FD_CLOEXEC) failed: ", strerror(errno))); + } + + pid_ = fork(); + if (pid_ == -1) { + close(exec_status[0]); + close(exec_status[1]); + return absl::InternalError( + absl::StrCat("fork() failed: ", strerror(errno))); + } + + if (pid_ == 0) { + close(exec_status[0]); + + // Terminate if the parent test runner exits or crashes. + prctl(PR_SET_PDEATHSIG, SIGKILL); + if (getppid() == 1) { + _exit(1); + } + + setenv("HOME", home_dir.c_str(), 1); + + 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); + } + + execvp(argv[0], argv.data()); + + const int exec_errno = errno; + ssize_t ignored = write(exec_status[1], &exec_errno, sizeof(exec_errno)); + static_cast(ignored); + _exit(127); + } + + 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]); + + if (got == static_cast(sizeof(child_errno))) { + int status = 0; + waitpid(pid_, &status, 0); + pid_ = -1; + return absl::InternalError(absl::StrCat("failed to exec '", binary_path_, + "': ", strerror(child_errno))); + } + return absl::OkStatus(); +} + +void FlagdProcess::Stop() { + 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; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + pid_ = -1; +} + +absl::Status SetupGlobalFlagd() { + if (g_flagd) { + return absl::OkStatus(); + } + + const absl::StatusOr flagd_bin = + GetRunfilePath("flagd_binary/flagd_linux_x86_64"); + 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) { + return absl::InternalError(absl::StrCat( + "could not create ", g_scenario_tmp_dir, ": ", ec.message())); + } + + const std::vector fixtures = CollectFixtureFiles(); + if (fixtures.empty()) { + 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"); + } + + json merged = json::object(); + merged["flags"] = json::object(); + merged["metadata"] = json::object(); + merged["$evaluators"] = json::object(); + + std::vector sources; + 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; + } + + const absl::StatusOr path = ResolveFixturePath(fixture); + if (!path.ok()) { + return path.status(); + } + + // 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) { + const fs::path dest = fs::path(g_scenario_tmp_dir) / filename; + fs::copy_file(*path, dest, fs::copy_options::overwrite_existing, ec); + if (ec) { + return absl::InternalError( + absl::StrCat("could not copy selector fixture ", *path, " to ", + dest.string(), ": ", ec.message())); + } + sources.push_back({.path = dest.string(), + .selector = absl::StrCat("rawflags/", filename)}); + continue; + } + + std::ifstream ifs(*path); + if (!ifs.is_open()) { + return absl::InternalError( + absl::StrCat("could not open fixture ", *path)); + } + json parsed = json::parse(ifs, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) { + return absl::InvalidArgumentError( + absl::StrCat("fixture ", *path, " is not a JSON object")); + } + + 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) { + 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()) { + return absl::InternalError( + absl::StrCat("could not write ", combined.string())); + } + ofs << merged.dump(2); + if (!ofs.good()) { + 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, + kFlagdSyncPort, g_scenario_tmp_dir); + if (const absl::Status started = g_flagd->Start(); !started.ok()) { + g_flagd.reset(); + return absl::InternalError( + absl::StrCat("could not start flagd: ", started.message())); + } + + if (!WaitForGrpcReady(FlagdSyncTarget())) { + return absl::UnavailableError(absl::StrCat( + "flagd did not become ready on ", FlagdSyncTarget(), "\nlast lines of ", + g_flagd->LogPath(), ":\n", g_flagd->TailLog())); + } + return absl::OkStatus(); +} + +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 new file mode 100644 index 0000000..8dfc40e --- /dev/null +++ b/providers/flagd/tests/gherkin/test_env.h @@ -0,0 +1,69 @@ +#pragma once + +#include + +#include +#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 +// --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; + +absl::StatusOr GetRunfilePath(const std::string& relative_path); + +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; +}; + +// Manages a background Go flagd server subprocess during test execution. +class FlagdProcess { + public: + FlagdProcess(std::string binary_path, std::vector sources, + int rpc_port, int sync_port, std::string log_dir); + ~FlagdProcess(); + + FlagdProcess(const FlagdProcess&) = delete; + FlagdProcess& operator=(const FlagdProcess&) = delete; + + // A failed status carries anything the child managed to report before exec + // failed. + absl::Status Start(); + void Stop(); + + std::string LogPath() const; + std::string TailLog(int max_lines = 40) const; + + private: + std::string binary_path_; + std::vector sources_; + int rpc_port_; + int sync_port_; + std::string log_dir_; + pid_t pid_ = -1; +}; + +// Writes the merged fixture files and starts the shared flagd instance. Safe +// to call more than once; only the first call does anything. +absl::Status SetupGlobalFlagd(); + +// Safe to call when flagd was never started. +void TeardownGlobalFlagd(); + +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 new file mode 100644 index 0000000..b56ad56 --- /dev/null +++ b/providers/flagd/tests/gherkin/test_runner.cpp @@ -0,0 +1,83 @@ +#include +#include +#include +#include +#include +#include + +// 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(static_cast(argc) + 4); + args.emplace_back(argv[0]); + + bool has_tags = false; + bool has_name = false; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "-t" || arg == "--tags") { + has_tags = true; + args.emplace_back(arg); + } else if (arg == "-n" || arg == "--name") { + has_name = true; + args.emplace_back(arg); + } else if (TrySplitInlineValue(arg, "--tags", &args, &has_tags)) { + continue; + } else if (TrySplitInlineValue(arg, "--name", &args, &has_name)) { + continue; + } else { + args.emplace_back(arg); + } + } + + 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 std::string& arg : args) { + argv_c.push_back(arg.c_str()); + } + + 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; +}