diff --git a/.github/workflows/ci-format-test-lint.yml b/.github/workflows/ci-format-test-lint.yml index 68476c3..a46ac98 100644 --- a/.github/workflows/ci-format-test-lint.yml +++ b/.github/workflows/ci-format-test-lint.yml @@ -11,6 +11,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + submodules: 'recursive' - uses: bazel-contrib/setup-bazel@0.18.0 with: @@ -28,6 +30,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + submodules: 'recursive' - uses: bazel-contrib/setup-bazel@0.18.0 with: diff --git a/BUILD b/BUILD index ffd0fb0..6066694 100644 --- a/BUILD +++ b/BUILD @@ -1 +1,8 @@ +load("@rules_gherkin//gherkin:defs.bzl", "gherkin_library") + package(default_visibility = ["//visibility:public"]) + +gherkin_library( + name = "openfeature_gherkin_spec_features", + srcs = glob(["spec/specification/assets/gherkin/evaluation.feature"]), +) \ No newline at end of file diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..c0128f5 --- /dev/null +++ b/Gemfile @@ -0,0 +1,3 @@ +source 'https://rubygems.org' +gem 'cucumber', '~> 10.2.0' +gem 'cucumber-wire', '~> 8.0.0' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a0f4b35 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,56 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + bigdecimal (4.0.1) + bigdecimal (4.0.1-java) + builder (3.3.0) + cucumber (10.2.0) + base64 (~> 0.2) + builder (~> 3.2) + cucumber-ci-environment (> 9, < 12) + cucumber-core (> 15, < 17) + cucumber-cucumber-expressions (> 17, < 20) + cucumber-html-formatter (> 21, < 23) + diff-lcs (~> 1.5) + logger (~> 1.6) + mini_mime (~> 1.1) + multi_test (~> 1.1) + sys-uname (~> 1.3) + cucumber-ci-environment (11.0.0) + cucumber-core (15.4.0) + cucumber-gherkin (> 27, < 40) + cucumber-messages (> 26, < 33) + cucumber-tag-expressions (> 5, < 9) + cucumber-cucumber-expressions (19.0.0) + bigdecimal + cucumber-gherkin (38.0.0) + cucumber-messages (>= 31, < 33) + cucumber-html-formatter (22.3.0) + cucumber-messages (> 23, < 33) + cucumber-messages (32.0.1) + cucumber-tag-expressions (8.1.0) + cucumber-wire (8.0.0) + cucumber-core (> 11, < 16) + cucumber-cucumber-expressions (> 14, < 20) + diff-lcs (1.6.2) + ffi (1.17.3-java) + ffi (1.17.3-x86_64-linux-gnu) + logger (1.7.0) + memoist3 (1.0.0) + mini_mime (1.1.5) + multi_test (1.1.0) + sys-uname (1.5.0) + ffi (~> 1.1) + memoist3 (~> 1.0.0) + +PLATFORMS + universal-java-11 + x86_64-linux + +DEPENDENCIES + cucumber (~> 10.2.0) + cucumber-wire (~> 8.0.0) + +BUNDLED WITH + 2.4.19 diff --git a/MODULE.bazel b/MODULE.bazel index 622f68c..3cf159f 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -3,6 +3,26 @@ module(name = "openfeature_cpp_sdk") bazel_dep(name = "googletest", version = "1.17.0.bcr.2") bazel_dep(name = "abseil-cpp", version = "20250814.2") bazel_dep(name = "rules_cc", version = "0.2.17") +bazel_dep(name = "cucumber-cpp", version = "0.8.0.bcr.1") + +bazel_dep(name = "rules_gherkin", version = "0.2.0") +bazel_dep(name = "rules_ruby", version = "0.21.1") + +# Hermetic Ruby Toolchain Configuration +ruby = use_extension("@rules_ruby//ruby:extensions.bzl", "ruby") +ruby.toolchain( + name = "ruby", + version = "3.2.2", # JRuby or standard MRI version suitable for execution +) + +# Cucumber Gem provisioning via Bundler integration +ruby.bundle_fetch( + name = "cucumber", + gemfile = "//:Gemfile", + gemfile_lock = "//:Gemfile.lock", +) +use_repo(ruby, "cucumber", "ruby", "ruby_toolchains") +register_toolchains("@ruby_toolchains//:all") # Hedron's Compile Commands Extractor for Bazel # https://github.com/hedronvision/bazel-compile-commands-extractor diff --git a/spec b/spec new file mode 160000 index 0000000..eefdf43 --- /dev/null +++ b/spec @@ -0,0 +1 @@ +Subproject commit eefdf439c5a5b69ccde036c3c6959a4a6c17e08c diff --git a/test/BUILD b/test/BUILD index 72c9ef8..aff340c 100644 --- a/test/BUILD +++ b/test/BUILD @@ -1,10 +1,12 @@ load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") +package( + default_visibility = ["//visibility:public"], +) + cc_library( name = "mock_feature_provider", - testonly = True, hdrs = ["mocks/mock_feature_provider.h"], - visibility = ["//test:__pkg__"], deps = [ "//openfeature:provider", "@googletest//:gtest", diff --git a/test/e2e/BUILD b/test/e2e/BUILD new file mode 100644 index 0000000..9a3ef3b --- /dev/null +++ b/test/e2e/BUILD @@ -0,0 +1,60 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("@rules_gherkin//gherkin:defs.bzl", "gherkin_test") + +package( + default_visibility = ["//visibility:public"], +) + +cc_library( + name = "context_storing_provider", + srcs = [ + "context_storing_provider.cpp", + ], + hdrs = [ + "context_storing_provider.h", + ], + include_prefix = "test/e2e", + deps = [ + "//openfeature:evaluation_context", + "//openfeature:flag_metadata", + "//openfeature:metadata", + "//openfeature:provider", + "//openfeature:reason", + "//openfeature:value", + "//openfeature:resolution_details", + "@cucumber-cpp//:cucumber-cpp", + "@googletest//:gtest", + ], +) + +cc_library( + name = "flag_test", + hdrs = [ + "flag_test.h", + ], + include_prefix = "test/e2e", +) + +cc_library( + name = "state", + hdrs = [ + "state.h", + ], + include_prefix = "test/e2e", + deps = [ + "//openfeature:client", + "//openfeature:evaluation_context", + "//openfeature:provider", + "//openfeature:resolution_details", + "//openfeature:value", + ":flag_test", + ], +) + +gherkin_test( + name = "openfeature_gherkin_e2e_tests", + steps = "//test/e2e/steps:gherkin_step_definitions", + deps = [ + "//:openfeature_gherkin_spec_features", + ], +) diff --git a/test/e2e/context_storing_provider.cpp b/test/e2e/context_storing_provider.cpp new file mode 100644 index 0000000..c07e110 --- /dev/null +++ b/test/e2e/context_storing_provider.cpp @@ -0,0 +1,75 @@ +#include "test/e2e/context_storing_provider.h" + +#include + +#include +#include + +#include "openfeature/flag_metadata.h" +#include "openfeature/reason.h" +#include "openfeature/value.h" + +using cucumber::ScenarioScope; + +namespace openfeature_e2e { + +openfeature::Metadata ContextStoringProvider::GetMetadata() const { + return openfeature::Metadata{"ContextStoringProvider"}; +} + +std::unique_ptr +ContextStoringProvider::GetBooleanEvaluation( + std::string_view key, bool default_value, + const openfeature::EvaluationContext& ctx) { + last_ctx = ctx; + + return std::make_unique( + default_value, openfeature::Reason::kDefault, "default-variant", + openfeature::FlagMetadata{}, std::nullopt, ""); +} + +std::unique_ptr +ContextStoringProvider::GetStringEvaluation( + std::string_view key, std::string_view default_value, + const openfeature::EvaluationContext& ctx) { + last_ctx = ctx; + std::string default_str(default_value); + return std::make_unique( + default_str, openfeature::Reason::kDefault, "default-variant", + openfeature::FlagMetadata{}, std::nullopt, ""); +} + +std::unique_ptr +ContextStoringProvider::GetIntegerEvaluation( + std::string_view key, int64_t default_value, + const openfeature::EvaluationContext& ctx) { + last_ctx = ctx; + + return std::make_unique( + default_value, openfeature::Reason::kDefault, "default-variant", + openfeature::FlagMetadata{}, std::nullopt, ""); +} + +std::unique_ptr +ContextStoringProvider::GetDoubleEvaluation( + std::string_view key, double default_value, + const openfeature::EvaluationContext& ctx) { + last_ctx = ctx; + + return std::make_unique( + default_value, openfeature::Reason::kDefault, "default-variant", + openfeature::FlagMetadata{}, std::nullopt, ""); +} + +std::unique_ptr +ContextStoringProvider::GetObjectEvaluation( + std::string_view key, const openfeature::Value default_value, + const openfeature::EvaluationContext& ctx) { + last_ctx = ctx; + + return std::make_unique( + default_value, openfeature::Reason::kDefault, "default-variant", + openfeature::FlagMetadata{}, std::nullopt, ""); +} + +} // namespace openfeature_e2e diff --git a/test/e2e/context_storing_provider.h b/test/e2e/context_storing_provider.h new file mode 100644 index 0000000..4422f41 --- /dev/null +++ b/test/e2e/context_storing_provider.h @@ -0,0 +1,47 @@ +#ifndef CPP_SDK_INCLUDE_TEST_E2E_CONTEXT_STORING_PROVIDER_H_ +#define CPP_SDK_INCLUDE_TEST_E2E_CONTEXT_STORING_PROVIDER_H_ + +#include +#include +#include + +#include "openfeature/evaluation_context.h" +#include "openfeature/metadata.h" +#include "openfeature/provider.h" +#include "openfeature/resolution_details.h" + +namespace openfeature_e2e { + +// A simple provider that stores the last evaluation context it received. +class ContextStoringProvider : public openfeature::FeatureProvider { + public: + mutable openfeature::EvaluationContext last_ctx = + openfeature::EvaluationContext::Builder().build(); + + ~ContextStoringProvider() override = default; + + openfeature::Metadata GetMetadata() const override; + + std::unique_ptr GetBooleanEvaluation( + std::string_view key, bool default_value, + const openfeature::EvaluationContext& ctx) override; + + std::unique_ptr GetStringEvaluation( + std::string_view key, std::string_view default_value, + const openfeature::EvaluationContext& ctx) override; + + std::unique_ptr GetIntegerEvaluation( + std::string_view key, int64_t default_value, + const openfeature::EvaluationContext& ctx) override; + + std::unique_ptr GetDoubleEvaluation( + std::string_view key, double default_value, + const openfeature::EvaluationContext& ctx) override; + + std::unique_ptr GetObjectEvaluation( + std::string_view key, openfeature::Value default_value, + const openfeature::EvaluationContext& ctx) override; +}; + +} // namespace openfeature_e2e +#endif // CPP_SDK_INCLUDE_TEST_E2E_CONTEXT_STORING_PROVIDER_H_ diff --git a/test/e2e/flag_test.h b/test/e2e/flag_test.h new file mode 100644 index 0000000..5f64956 --- /dev/null +++ b/test/e2e/flag_test.h @@ -0,0 +1,16 @@ +#ifndef CPP_SDK_INCLUDE_TEST_E2E_FLAG_TEST_H_ +#define CPP_SDK_INCLUDE_TEST_E2E_FLAG_TEST_H_ + +#include +#include + +namespace openfeature_e2e { + +struct FlagTest { + std::string type; + std::string name; + std::any default_value; +}; + +} // namespace openfeature_e2e +#endif // CPP_SDK_INCLUDE_TEST_E2E_FLAG_TEST_H_ \ No newline at end of file diff --git a/test/e2e/state.h b/test/e2e/state.h new file mode 100644 index 0000000..907e4ce --- /dev/null +++ b/test/e2e/state.h @@ -0,0 +1,31 @@ +#ifndef CPP_SDK_INCLUDE_TEST_E2E_STATE_H_ +#define CPP_SDK_INCLUDE_TEST_E2E_STATE_H_ + +#include +#include +#include +#include + +#include "openfeature/client.h" +#include "openfeature/evaluation_context.h" +#include "openfeature/provider.h" +#include "openfeature/resolution_details.h" +#include "openfeature/value.h" +#include "test/e2e/flag_test.h" + +namespace openfeature_e2e { + +struct State { + std::shared_ptr client; + std::shared_ptr provider; + FlagTest flag; + std::unique_ptr context; + std::unique_ptr invocation_context; + std::vector levels; + + openfeature::Value last_evaluation_value; +}; + +// TODO: Update struct after implementing hooks and flag evaluation details. +} // namespace openfeature_e2e +#endif // CPP_SDK_INCLUDE_TEST_E2E_STATE_H_ \ No newline at end of file diff --git a/test/e2e/steps/BUILD b/test/e2e/steps/BUILD new file mode 100644 index 0000000..b8a6204 --- /dev/null +++ b/test/e2e/steps/BUILD @@ -0,0 +1,20 @@ +load("@rules_gherkin//gherkin:defs.bzl", "cc_gherkin_steps") + +package(default_visibility = ["//visibility:public"]) + +cc_gherkin_steps( + name = "gherkin_step_definitions", + srcs = ["minimal_steps.cpp"], + deps = [ + "//openfeature:evaluation_context", + "//openfeature:openfeature_api", + "//openfeature/memory_provider:flag", + "//openfeature/memory_provider:in_memory_provider", + "//test/e2e:context_storing_provider", + "//test/e2e:state", + "//test:mock_feature_provider", + "@cucumber-cpp//:cucumber-cpp", + "@googletest//:gtest", + ], + visibility = ["//test/e2e:__subpackages__"], +) \ No newline at end of file diff --git a/test/e2e/steps/minimal_steps.cpp b/test/e2e/steps/minimal_steps.cpp new file mode 100644 index 0000000..bcdf998 --- /dev/null +++ b/test/e2e/steps/minimal_steps.cpp @@ -0,0 +1,302 @@ +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "openfeature/evaluation_context.h" +#include "openfeature/memory_provider/flag.h" +#include "openfeature/memory_provider/in_memory_provider.h" +#include "openfeature/openfeature_api.h" +#include "test/e2e/context_storing_provider.h" +#include "test/e2e/state.h" +#include "test/mocks/mock_feature_provider.h" + +using ::cucumber::ScenarioScope; +using ::testing::_; +using ::testing::Return; + +// Helper to create simple static flags for the InMemoryProvider. +template +openfeature::Flag CreateStaticFlag(T value) { + return openfeature::Flag( + {{"default", value}}, "default", + [value](const openfeature::Flag&, + const openfeature::EvaluationContext&) { return value; }, + openfeature::FlagMetadata{}); +} + +std::shared_ptr CreateStableProvider() { + std::unordered_map flags; + + // Set up the static flags expected by the basic evaluation tests. + flags["boolean-flag"] = CreateStaticFlag(true); + flags["string-flag"] = CreateStaticFlag("hi"); + flags["integer-flag"] = CreateStaticFlag(10); + flags["float-flag"] = CreateStaticFlag(0.5); + + // Object flag setup. + std::map obj_map; + obj_map["showImages"] = openfeature::Value(true); + obj_map["title"] = openfeature::Value("Check out these pics!"); + obj_map["imagesPerPage"] = openfeature::Value(100); + flags["object-flag"] = + CreateStaticFlag(openfeature::Value(obj_map)); + + // Context-aware flag setup. + auto context_evaluator = [](const openfeature::Flag&, + const openfeature::EvaluationContext& ctx) + -> absl::StatusOr { + const std::any* customer = ctx.GetValue("customer"); + if (customer && std::any_cast(*customer) == "false") { + return "INTERNAL"; + } + return "EXTERNAL"; + }; + + flags["context-aware"] = openfeature::Flag( + {{"internal", "INTERNAL"}, {"external", "EXTERNAL"}}, "external", + context_evaluator, openfeature::FlagMetadata{}); + + return std::make_shared(std::move(flags)); +} + +std::shared_ptr CreateMockErrorProvider() { + std::shared_ptr mock = + std::make_shared(); + EXPECT_CALL(*mock, GetMetadata()) + .WillRepeatedly(Return(openfeature::Metadata{"MockFeatureProvider"})); + EXPECT_CALL(*mock, Shutdown()).WillRepeatedly(Return(absl::OkStatus())); + EXPECT_CALL(*mock, Init(_)) + .WillOnce(Return(absl::UnknownError("Simulated Error"))); + return mock; +} + +std::shared_ptr CreateMockNotReadyProvider() { + std::shared_ptr mock = + std::make_shared(); + EXPECT_CALL(*mock, GetMetadata()) + .WillRepeatedly(Return(openfeature::Metadata{"MockFeatureProvider"})); + EXPECT_CALL(*mock, Shutdown()).WillRepeatedly(Return(absl::OkStatus())); + EXPECT_CALL(*mock, Init(_)) + .WillOnce(testing::Invoke([](const openfeature::EvaluationContext&) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + return absl::OkStatus(); + })); + return mock; +} + +GIVEN("^a (.*) provider$") { + REGEX_PARAM(std::string, status_type); + ScenarioScope state; + + if (status_type == "stable" || status_type == "ready") { + state->provider = CreateStableProvider(); + openfeature::OpenFeatureAPI::GetInstance().SetProviderAndWait( + state->provider); + } else if (status_type == "error") { + state->provider = CreateMockErrorProvider(); + openfeature::OpenFeatureAPI::GetInstance().SetProviderAndWait( + state->provider); + } else if (status_type == "not ready") { + state->provider = CreateMockNotReadyProvider(); + openfeature::OpenFeatureAPI::GetInstance().SetProvider(state->provider); + } + + state->client = openfeature::OpenFeatureAPI::GetInstance().GetClient(); +} + +THEN("^the provider status should be \"([^\"]*)\"$") { + REGEX_PARAM(std::string, expected_status_str); + ScenarioScope state; + + openfeature::ProviderStatus actual_status = + state->client->GetProviderStatus(); + openfeature::ProviderStatus expected_status = + openfeature::ProviderStatus::kReady; + + if (expected_status_str == "READY") { + expected_status = openfeature::ProviderStatus::kReady; + } else if (expected_status_str == "NOT_READY") { + expected_status = openfeature::ProviderStatus::kNotReady; + } else if (expected_status_str == "ERROR") { + expected_status = openfeature::ProviderStatus::kError; + } else if (expected_status_str == "FATAL") { + expected_status = openfeature::ProviderStatus::kFatal; + } else if (expected_status_str == "STALE") { + expected_status = openfeature::ProviderStatus::kStale; + } + + EXPECT_EQ(actual_status, expected_status); +} + +WHEN( + "^a boolean flag with key \"([^\"]*)\" is evaluated with default value " + "\"([^\"]*)\"$") { + REGEX_PARAM(std::string, key); + REGEX_PARAM(std::string, default_val_str); + ScenarioScope state; + + bool default_val = (default_val_str == "true"); + state->last_evaluation_value = + openfeature::Value(state->client->GetBooleanValue(key, default_val)); +} + +THEN("^the resolved boolean value should be \"([^\"]*)\"$") { + REGEX_PARAM(std::string, expected_str); + ScenarioScope state; + bool expected = (expected_str == "true"); + EXPECT_EQ(state->last_evaluation_value.AsBool().value(), expected); +} + +WHEN( + "^a string flag with key \"([^\"]*)\" is evaluated with default value " + "\"([^\"]*)\"$") { + REGEX_PARAM(std::string, key); + REGEX_PARAM(std::string, default_val); + ScenarioScope state; + + state->last_evaluation_value = + openfeature::Value(state->client->GetStringValue(key, default_val)); +} + +THEN("^the resolved string value should be \"([^\"]*)\"$") { + REGEX_PARAM(std::string, expected); + ScenarioScope state; + EXPECT_EQ(state->last_evaluation_value.AsString().value(), expected); +} + +WHEN( + "^an integer flag with key \"([^\"]*)\" is evaluated with default value " + "(\\d+)$") { + REGEX_PARAM(std::string, key); + REGEX_PARAM(int64_t, default_val); + ScenarioScope state; + + state->last_evaluation_value = + openfeature::Value(state->client->GetIntegerValue(key, default_val)); +} + +THEN("^the resolved integer value should be (\\d+)$") { + REGEX_PARAM(int64_t, expected); + ScenarioScope state; + EXPECT_EQ(state->last_evaluation_value.AsInt().value(), expected); +} + +WHEN( + "^a float flag with key \"([^\"]*)\" is evaluated with default value " + "([\\d\\.]+)$") { + REGEX_PARAM(std::string, key); + REGEX_PARAM(double, default_val); + ScenarioScope state; + + state->last_evaluation_value = + openfeature::Value(state->client->GetDoubleValue(key, default_val)); +} + +THEN("^the resolved float value should be ([\\d\\.]+)$") { + REGEX_PARAM(double, expected); + ScenarioScope state; + EXPECT_DOUBLE_EQ(state->last_evaluation_value.AsDouble().value(), expected); +} + +WHEN( + "^an object flag with key \"([^\"]*)\" is evaluated with a null default " + "value$") { + REGEX_PARAM(std::string, key); + ScenarioScope state; + + state->last_evaluation_value = + state->client->GetObjectValue(key, openfeature::Value()); +} + +THEN( + "^the resolved object value should be contain fields \"([^\"]*)\", " + "\"([^\"]*)\", and \"([^\"]*)\", with values \"([^\"]*)\", \"([^\"]*)\" " + "and (\\d+), respectively$") { + REGEX_PARAM(std::string, f1); + REGEX_PARAM(std::string, f2); + REGEX_PARAM(std::string, f3); + REGEX_PARAM(std::string, v1_str); + REGEX_PARAM(std::string, v2); + REGEX_PARAM(int64_t, v3); + ScenarioScope state; + + const std::map* structure = + state->last_evaluation_value.AsStructure(); + ASSERT_NE(structure, nullptr); + + EXPECT_EQ(structure->at(f1).AsBool().value(), (v1_str == "true")); + EXPECT_EQ(structure->at(f2).AsString().value(), v2); + EXPECT_EQ(structure->at(f3).AsInt().value(), v3); +} + +WHEN( + "^context contains keys \"([^\"]*)\", \"([^\"]*)\", \"([^\"]*)\", " + "\"([^\"]*)\" with values \"([^\"]*)\", \"([^\"]*)\", (\\d+), " + "\"([^\"]*)\"$") { + REGEX_PARAM(std::string, k1); + REGEX_PARAM(std::string, k2); + REGEX_PARAM(std::string, k3); + REGEX_PARAM(std::string, k4); + REGEX_PARAM(std::string, v1); + REGEX_PARAM(std::string, v2); + REGEX_PARAM(int64_t, v3); + REGEX_PARAM(std::string, v4); + ScenarioScope state; + + openfeature::EvaluationContext ctx = openfeature::EvaluationContext::Builder() + .WithAttribute(k1, v1) + .WithAttribute(k2, v2) + .WithAttribute(k3, v3) + .WithAttribute(k4, v4) + .build(); + + state->context = std::make_unique(ctx); +} + +WHEN( + "^a flag with key \"([^\"]*)\" is evaluated with default value " + "\"([^\"]*)\"$") { + REGEX_PARAM(std::string, key); + REGEX_PARAM(std::string, default_val); + ScenarioScope state; + + // Evaluate using the context built in the previous step. + if (state->context) { + state->last_evaluation_value = openfeature::Value( + state->client->GetStringValue(key, default_val, *state->context)); + } else { + state->last_evaluation_value = + openfeature::Value(state->client->GetStringValue(key, default_val)); + } +} + +THEN("^the resolved string response should be \"([^\"]*)\"$") { + REGEX_PARAM(std::string, expected); + ScenarioScope state; + + EXPECT_EQ(state->last_evaluation_value.AsString().value(), expected); +} + +THEN("^the resolved flag value is \"([^\"]*)\" when the context is empty$") { + REGEX_PARAM(std::string, expected); + ScenarioScope state; + + // Evaluate context-aware flag with an empty context. + openfeature::EvaluationContext empty_ctx = + openfeature::EvaluationContext::Builder().build(); + std::string actual = + state->client->GetStringValue("context-aware", "EXTERNAL", empty_ctx); + + EXPECT_EQ(actual, expected); +} + +// TODO: Enable more Gherkin scenarios as the SDK functionality expands, e.g. +// around event hooks, detailed evaluation, etc. \ No newline at end of file