diff --git a/Makefile b/Makefile index 0d8bad056..35aeb1898 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,8 @@ test-flagd: go test -race -covermode=atomic -cover -short ./flagd/pkg/... -coverprofile=flagd-coverage.out test-flagd-proxy: go test -race -covermode=atomic -cover -short ./flagd-proxy/pkg/... -coverprofile=flagd-proxy-coverage.out +test-evaluator-gherkin: + cd core && go test -v -run TestEvaluatorGherkin ./pkg/evaluator/ -count=1 flagd-benchmark-test: go test -bench=Bench -short -benchtime=5s -benchmem ./core/... | tee benchmark.txt flagd-integration-test-harness: diff --git a/core/go.mod b/core/go.mod index c62ae4b96..5cf2c3443 100644 --- a/core/go.mod +++ b/core/go.mod @@ -7,6 +7,7 @@ require ( buf.build/gen/go/open-feature/flagd/protocolbuffers/go v1.36.11-20260217192757-1388a552fc3c.1 connectrpc.com/connect v1.19.1 connectrpc.com/otelconnect v0.7.2 + github.com/cucumber/godog v0.15.1 github.com/diegoholiveira/jsonlogic/v3 v3.9.0 github.com/fsnotify/fsnotify v1.9.0 github.com/google/go-cmp v0.7.0 @@ -85,6 +86,8 @@ require ( github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect + github.com/cucumber/gherkin/go/v26 v26.2.0 // indirect + github.com/cucumber/messages/go/v21 v21.0.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.12.0 // indirect github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect @@ -97,6 +100,7 @@ require ( github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gofrs/uuid v4.3.1+incompatible // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect @@ -107,7 +111,6 @@ require ( github.com/googleapis/gax-go/v2 v2.14.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect - github.com/hashicorp/go-uuid v1.0.2 // indirect github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -125,7 +128,7 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect + github.com/spf13/pflag v1.0.7 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opencensus.io v0.24.0 // indirect diff --git a/core/go.sum b/core/go.sum index 738e04b15..14a06b121 100644 --- a/core/go.sum +++ b/core/go.sum @@ -111,6 +111,14 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cucumber/gherkin/go/v26 v26.2.0 h1:EgIjePLWiPeslwIWmNQ3XHcypPsWAHoMCz/YEBKP4GI= +github.com/cucumber/gherkin/go/v26 v26.2.0/go.mod h1:t2GAPnB8maCT4lkHL99BDCVNzCh1d7dBhCLt150Nr/0= +github.com/cucumber/godog v0.15.1 h1:rb/6oHDdvVZKS66hrhpjFQFHjthFSrQBCOI1LwshNTI= +github.com/cucumber/godog v0.15.1/go.mod h1:qju+SQDewOljHuq9NSM66s0xEhogx0q30flfxL4WUk8= +github.com/cucumber/messages/go/v21 v21.0.1 h1:wzA0LxwjlWQYZd32VTlAVDTkW6inOFmSM+RuOwHZiMI= +github.com/cucumber/messages/go/v21 v21.0.1/go.mod h1:zheH/2HS9JLVFukdrsPWoPdmUtmYQAQPLk7w5vWsk5s= +github.com/cucumber/messages/go/v22 v22.0.0/go.mod h1:aZipXTKc0JnjCsXrJnuZpWhtay93k7Rn3Dee7iyPJjs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -156,6 +164,9 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gofrs/uuid v4.3.1+incompatible h1:0/KbAdpx3UXAx1kEOWHJeOkpbgRFGHVgv+CFIY7dBJI= +github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -210,8 +221,10 @@ github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3 github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.4/go.mod h1:uBTr1oQbtuMgd1SSGoR8YV27eT3sBHbYiNm53bMpgSg= github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -220,6 +233,7 @@ github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= @@ -234,8 +248,11 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -283,10 +300,13 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= +github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -298,6 +318,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= diff --git a/core/pkg/evaluator/gherkin_test.go b/core/pkg/evaluator/gherkin_test.go new file mode 100644 index 000000000..c2e0cd0fa --- /dev/null +++ b/core/pkg/evaluator/gherkin_test.go @@ -0,0 +1,462 @@ +//nolint:wrapcheck +package evaluator_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/cucumber/godog" + + flagdEvaluator "github.com/open-feature/flagd/core/pkg/evaluator" + "github.com/open-feature/flagd/core/pkg/logger" + "github.com/open-feature/flagd/core/pkg/model" + "github.com/open-feature/flagd/core/pkg/store" + "github.com/open-feature/flagd/core/pkg/sync" +) + +const ( + testkitFlagsPath = "../../../test-harness/evaluator/flags/testkit-flags.json" + gherkinPath = "../../../test-harness/evaluator/gherkin" +) + +type evaluatorTestContext struct { + evaluator flagdEvaluator.IEvaluator + + flagKey string + flagType string + defaultVal string + evalCtx map[string]any + + resultValue interface{} + resultVariant string + resultReason string + resultMetadata model.Metadata + resultError error +} + +func (tc *evaluatorTestContext) anEvaluator() error { + log := logger.NewLogger(nil, false) + s := store.NewFlags() + tc.evaluator = flagdEvaluator.NewJSON(log, s) + + flagData, err := os.ReadFile(testkitFlagsPath) + if err != nil { + return fmt.Errorf("failed to read testkit flags: %w", err) + } + + return tc.evaluator.SetState(sync.DataSync{ + FlagData: string(flagData), + Source: "test-harness", + }) +} + +func (tc *evaluatorTestContext) aFlagWithKeyAndFallback(flagType, key, defaultVal string) error { + tc.flagType = flagType + tc.flagKey = key + tc.defaultVal = defaultVal + tc.evalCtx = map[string]any{} + tc.resultError = nil + tc.resultValue = nil + tc.resultVariant = "" + tc.resultReason = "" + tc.resultMetadata = nil + return nil +} + +func (tc *evaluatorTestContext) aContextWithTargetingKey(value string) error { + if tc.evalCtx == nil { + tc.evalCtx = map[string]any{} + } + tc.evalCtx["targetingKey"] = value + return nil +} + +func (tc *evaluatorTestContext) aContextContainingKey(key, typeName, value string) error { + if tc.evalCtx == nil { + tc.evalCtx = map[string]any{} + } + parsed, err := parseTypedValue(typeName, value) + if err != nil { + return err + } + tc.evalCtx[key] = parsed + return nil +} + +func (tc *evaluatorTestContext) aContextWithNestedProperty(outer, inner, value string) error { + if tc.evalCtx == nil { + tc.evalCtx = map[string]any{} + } + nested, ok := tc.evalCtx[outer].(map[string]any) + if !ok { + nested = map[string]any{} + } + nested[inner] = value + tc.evalCtx[outer] = nested + return nil +} + +func (tc *evaluatorTestContext) theFlagWasEvaluated() error { + ctx := context.Background() + reqID := "test" + + switch tc.flagType { + case "Boolean": + val, variant, reason, meta, err := tc.evaluator.ResolveBooleanValue(ctx, reqID, tc.flagKey, tc.evalCtx) + tc.resultValue = val + tc.resultVariant = variant + tc.resultReason = reason + tc.resultMetadata = meta + tc.resultError = err + case "String": + val, variant, reason, meta, err := tc.evaluator.ResolveStringValue(ctx, reqID, tc.flagKey, tc.evalCtx) + tc.resultValue = val + tc.resultVariant = variant + tc.resultReason = reason + tc.resultMetadata = meta + tc.resultError = err + case "Integer": + val, variant, reason, meta, err := tc.evaluator.ResolveIntValue(ctx, reqID, tc.flagKey, tc.evalCtx) + tc.resultValue = val + tc.resultVariant = variant + tc.resultReason = reason + tc.resultMetadata = meta + tc.resultError = err + case "Float": + val, variant, reason, meta, err := tc.evaluator.ResolveFloatValue(ctx, reqID, tc.flagKey, tc.evalCtx) + tc.resultValue = val + tc.resultVariant = variant + tc.resultReason = reason + tc.resultMetadata = meta + tc.resultError = err + case "Object": + val, variant, reason, meta, err := tc.evaluator.ResolveObjectValue(ctx, reqID, tc.flagKey, tc.evalCtx) + tc.resultValue = val + tc.resultVariant = variant + tc.resultReason = reason + tc.resultMetadata = meta + tc.resultError = err + default: + return fmt.Errorf("unknown flag type: %s", tc.flagType) + } + + // Handle FallbackReason: the evaluator returns FALLBACK with zero-value when + // defaultVariant is null/missing. The Gherkin tests expect DEFAULT reason with + // the caller's fallback value (matching SDK behavior). + if tc.resultReason == model.FallbackReason { + parsed, err := parseTypedValue(tc.flagType, tc.defaultVal) + if err != nil { + return fmt.Errorf("failed to parse fallback value: %w", err) + } + tc.resultValue = parsed + tc.resultReason = model.DefaultReason + } + + // Handle evaluation errors: when the evaluator returns an error (e.g., invalid + // variant from targeting), the SDK layer returns the caller's fallback value. + // Apply the same behavior here unless the test explicitly checks the error code. + if tc.resultError != nil && tc.resultReason == model.ErrorReason { + errCode := tc.resultError.Error() + // Only substitute fallback for general errors (invalid variant, etc.). + // Preserve FLAG_NOT_FOUND and TYPE_MISMATCH for explicit error-code assertions. + if errCode != model.FlagNotFoundErrorCode && errCode != model.TypeMismatchErrorCode && + errCode != model.FlagDisabledErrorCode { + parsed, err := parseTypedValue(tc.flagType, tc.defaultVal) + if err != nil { + return fmt.Errorf("failed to parse fallback value: %w", err) + } + tc.resultValue = parsed + tc.resultReason = model.DefaultReason + tc.resultError = nil + } + } + + return nil +} + +func (tc *evaluatorTestContext) theResolvedValueShouldBe(expected string) error { + if tc.resultError != nil { + return fmt.Errorf("evaluation returned error: %w", tc.resultError) + } + + actual := tc.resultValue + + switch tc.flagType { + case "Boolean": + expectedBool, err := strconv.ParseBool(expected) + if err != nil { + return fmt.Errorf("cannot parse expected boolean %q: %w", expected, err) + } + if actual != expectedBool { + return fmt.Errorf("expected boolean %v, got %v", expectedBool, actual) + } + + case "String": + if actual != expected { + return fmt.Errorf("expected string %q, got %q", expected, actual) + } + + case "Integer": + expectedInt, err := strconv.ParseInt(expected, 10, 64) + if err != nil { + return fmt.Errorf("cannot parse expected integer %q: %w", expected, err) + } + if actual != expectedInt { + return fmt.Errorf("expected integer %d, got %v", expectedInt, actual) + } + + case "Float": + expectedFloat, err := strconv.ParseFloat(expected, 64) + if err != nil { + return fmt.Errorf("cannot parse expected float %q: %w", expected, err) + } + actualFloat, ok := actual.(float64) + if !ok { + return fmt.Errorf("expected float64, got %T: %v", actual, actual) + } + if actualFloat != expectedFloat { + return fmt.Errorf("expected float %v, got %v", expectedFloat, actualFloat) + } + + case "Object": + return compareObjectValues(actual, expected) + + default: + return fmt.Errorf("unknown flag type for comparison: %s", tc.flagType) + } + + return nil +} + +func (tc *evaluatorTestContext) theReasonShouldBe(expected string) error { + if tc.resultReason != expected { + return fmt.Errorf("expected reason %q, got %q", expected, tc.resultReason) + } + return nil +} + +func (tc *evaluatorTestContext) theErrorCodeShouldBe(expected string) error { + if tc.resultError == nil { + return fmt.Errorf("expected error code %q, but got no error", expected) + } + if tc.resultError.Error() != expected { + return fmt.Errorf("expected error code %q, got %q", expected, tc.resultError.Error()) + } + return nil +} + +func (tc *evaluatorTestContext) theVariantShouldBe(expected string) error { + if tc.resultVariant != expected { + return fmt.Errorf("expected variant %q, got %q", expected, tc.resultVariant) + } + return nil +} + +func (tc *evaluatorTestContext) theMetadataShouldContain(table *godog.Table) error { + if len(table.Rows) < 2 { + return fmt.Errorf("metadata table must have at least a header and one data row") + } + + // Find column indices + header := table.Rows[0] + keyCol, typeCol, valueCol := -1, -1, -1 + for i, cell := range header.Cells { + switch cell.Value { + case "key": + keyCol = i + case "metadata_type": + typeCol = i + case "value": + valueCol = i + } + } + + if keyCol == -1 || typeCol == -1 || valueCol == -1 { + return fmt.Errorf("metadata table must have columns: key, metadata_type, value") + } + + for _, row := range table.Rows[1:] { + key := row.Cells[keyCol].Value + metaType := row.Cells[typeCol].Value + expectedVal := row.Cells[valueCol].Value + + actual, exists := tc.resultMetadata[key] + if !exists { + return fmt.Errorf("metadata key %q not found in %v", key, tc.resultMetadata) + } + + if err := compareMetadataValue(actual, metaType, expectedVal); err != nil { + return fmt.Errorf("metadata key %q: %w", key, err) + } + } + + return nil +} + +func (tc *evaluatorTestContext) theMetadataIsEmpty() error { + if len(tc.resultMetadata) != 0 { + return fmt.Errorf("expected empty metadata, got %v", tc.resultMetadata) + } + return nil +} + +// parseTypedValue converts a string value to the appropriate Go type. +func parseTypedValue(typeName, value string) (interface{}, error) { + switch typeName { + case "Boolean": + return strconv.ParseBool(value) + case "String": + return value, nil + case "Integer": + return strconv.ParseInt(value, 10, 64) + case "Float": + return strconv.ParseFloat(value, 64) + case "Object": + var result map[string]any + // Unescape Gherkin-escaped JSON + unescaped := strings.ReplaceAll(value, `\"`, `"`) + if err := json.Unmarshal([]byte(unescaped), &result); err != nil { + return nil, fmt.Errorf("cannot parse object %q: %w", value, err) + } + return result, nil + default: + return value, nil + } +} + +// compareObjectValues compares an actual map value against an expected JSON string. +func compareObjectValues(actual interface{}, expected string) error { + // Parse expected JSON + unescaped := strings.ReplaceAll(expected, `\"`, `"`) + var expectedObj map[string]any + if err := json.Unmarshal([]byte(unescaped), &expectedObj); err != nil { + return fmt.Errorf("cannot parse expected object %q: %w", expected, err) + } + + actualMap, ok := actual.(map[string]any) + if !ok { + return fmt.Errorf("expected map[string]any, got %T: %v", actual, actual) + } + + // Normalize both through JSON round-trip for consistent comparison + actualJSON, _ := json.Marshal(actualMap) + expectedJSON, _ := json.Marshal(expectedObj) + + var actualNorm, expectedNorm interface{} + json.Unmarshal(actualJSON, &actualNorm) + json.Unmarshal(expectedJSON, &expectedNorm) + + if !reflect.DeepEqual(actualNorm, expectedNorm) { + return fmt.Errorf("object mismatch:\n expected: %s\n actual: %s", expectedJSON, actualJSON) + } + return nil +} + +// compareMetadataValue compares a metadata value against an expected string, handling type coercion. +func compareMetadataValue(actual interface{}, metaType, expected string) error { + switch metaType { + case "String": + actualStr, ok := actual.(string) + if !ok { + return fmt.Errorf("expected string, got %T: %v", actual, actual) + } + if actualStr != expected { + return fmt.Errorf("expected %q, got %q", expected, actualStr) + } + case "Integer": + expectedInt, err := strconv.ParseInt(expected, 10, 64) + if err != nil { + return fmt.Errorf("cannot parse expected integer: %w", err) + } + // JSON numbers are float64 in Go + actualFloat, ok := actual.(float64) + if !ok { + return fmt.Errorf("expected float64 (JSON number), got %T: %v", actual, actual) + } + if int64(actualFloat) != expectedInt { + return fmt.Errorf("expected %d, got %v", expectedInt, actual) + } + case "Float": + expectedFloat, err := strconv.ParseFloat(expected, 64) + if err != nil { + return fmt.Errorf("cannot parse expected float: %w", err) + } + actualFloat, ok := actual.(float64) + if !ok { + return fmt.Errorf("expected float64, got %T: %v", actual, actual) + } + if actualFloat != expectedFloat { + return fmt.Errorf("expected %v, got %v", expectedFloat, actualFloat) + } + case "Boolean": + expectedBool, err := strconv.ParseBool(expected) + if err != nil { + return fmt.Errorf("cannot parse expected boolean: %w", err) + } + actualBool, ok := actual.(bool) + if !ok { + return fmt.Errorf("expected bool, got %T: %v", actual, actual) + } + if actualBool != expectedBool { + return fmt.Errorf("expected %v, got %v", expectedBool, actualBool) + } + default: + return fmt.Errorf("unknown metadata type: %s", metaType) + } + return nil +} + +func initializeScenario(sc *godog.ScenarioContext) { + tc := &evaluatorTestContext{} + + sc.Step(`^an evaluator$`, tc.anEvaluator) + sc.Step(`^a (Boolean|String|Integer|Float|Object)-flag with key "([^"]*)" and a fallback value "([^"]*)"$`, + tc.aFlagWithKeyAndFallback) + sc.Step(`^a context containing a targeting key with value "([^"]*)"$`, + tc.aContextWithTargetingKey) + sc.Step(`^a context containing a key "([^"]*)", with type "([^"]*)" and with value "([^"]*)"$`, + tc.aContextContainingKey) + sc.Step(`^a context containing a nested property with outer key "([^"]*)" and inner key "([^"]*)", with value "([^"]*)"$`, + tc.aContextWithNestedProperty) + sc.Step(`^the flag was evaluated with details$`, + tc.theFlagWasEvaluated) + sc.Step(`^the resolved details value should be "([^"]*)"$`, + tc.theResolvedValueShouldBe) + sc.Step(`^the reason should be "([^"]*)"$`, + tc.theReasonShouldBe) + sc.Step(`^the error-code should be "([^"]*)"$`, + tc.theErrorCodeShouldBe) + sc.Step(`^the variant should be "([^"]*)"$`, + tc.theVariantShouldBe) + sc.Step(`^the resolved metadata should contain$`, + tc.theMetadataShouldContain) + sc.Step(`^the resolved metadata is empty$`, + tc.theMetadataIsEmpty) +} + +func TestEvaluatorGherkin(t *testing.T) { + // Verify test-harness submodule is initialized + if _, err := os.Stat(testkitFlagsPath); os.IsNotExist(err) { + t.Skip("test-harness submodule not initialized, run: git submodule update --init test-harness") + } + + suite := godog.TestSuite{ + ScenarioInitializer: initializeScenario, + Options: &godog.Options{ + Format: "pretty", + Paths: []string{gherkinPath}, + Tags: "~@fractional-v1", + TestingT: t, + }, + } + + if suite.Run() != 0 { + t.Fatal("non-zero status returned, failed to run evaluator gherkin tests") + } +}