diff --git a/go.mod b/go.mod index a7615ad..0be4ea7 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/Flagsmith/flagsmith-cli/v2 go 1.26 require ( - github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0 + github.com/Flagsmith/flagsmith-go-client/v5 v5.1.1-0.20260807113230-bc111cdba75a github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.19.0 github.com/itchyny/gojq v0.12.19 diff --git a/go.sum b/go.sum index 521a9a2..aadd62c 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0 h1:/1KMxWHfzo8I7FnZ0mnMw+ICM4HVZvCEYTJ4idM2VEU= -github.com/Flagsmith/flagsmith-go-client/v5 v5.1.0/go.mod h1:CjTs0XAIJXG+08lf4h51W9cjvrNaK92TLVYtjPrnI3w= +github.com/Flagsmith/flagsmith-go-client/v5 v5.1.1-0.20260807113230-bc111cdba75a h1:u/v/5yCC+SaFeEip8lVMRS3w9yZrlNrrOLW/qmAGAqU= +github.com/Flagsmith/flagsmith-go-client/v5 v5.1.1-0.20260807113230-bc111cdba75a/go.mod h1:CjTs0XAIJXG+08lf4h51W9cjvrNaK92TLVYtjPrnI3w= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= diff --git a/internal/cmd/cmd_test.go b/internal/cmd/cmd_test.go index c0d3477..7e6c690 100644 --- a/internal/cmd/cmd_test.go +++ b/internal/cmd/cmd_test.go @@ -6069,7 +6069,6 @@ func TestEvaluate(t *testing.T) { if items["value"] != float64(25) || items["enabled"] != false { t.Errorf("flag = %+v, want the resolved value", items) } - // Omitted until the SDK API returns them: absent, never faked. for _, absent := range []string{"reason", "variant"} { if _, ok := items[absent]; ok { t.Errorf("flag = %+v, want %q omitted rather than invented", items, absent) @@ -6080,6 +6079,86 @@ func TestEvaluate(t *testing.T) { } }) + t.Run("json carries the reason and variant the SDK API returned", func(t *testing.T) { + // Given + f := evalEnv(t) + flags := sdkFlagsFrom(defaultFeatures()) + flags[0]["reason"] = "DEFAULT" + flags[1]["reason"] = "SPLIT; weight=30" + flags[1]["variant"] = "treatment" + f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = flags + + // When + out, err := run("", "evaluate", "--json") + + // Then + if err != nil { + t.Fatalf("evaluate --json: %v\noutput: %s", err, out) + } + resolved, _ := evalDoc(t, out)["flags"].(map[string]any) + items, _ := resolved["max_items"].(map[string]any) + if items["reason"] != "SPLIT; weight=30" || items["variant"] != "treatment" { + t.Errorf("flag = %+v, want the reason and variant it resolved by", items) + } + // A standard feature has no variant to report; the schema calls that null, + // and an EvaluationResult that is already a subset omits it. + banner, _ := resolved["onboarding_banner"].(map[string]any) + if banner["reason"] != "DEFAULT" { + t.Errorf("flag = %+v, want the reason it resolved by", banner) + } + if _, ok := banner["variant"]; ok { + t.Errorf("flag = %+v, want no variant where none applied", banner) + } + }) + + t.Run("the table adds VARIANT when SDK API returns a variant", func(t *testing.T) { + // Given + f := evalEnv(t) + flags := sdkFlagsFrom(defaultFeatures()) + flags[0]["reason"] = "DEFAULT" + flags[1]["reason"] = "TARGETING_MATCH; segment=power users" + flags[1]["variant"] = "treatment" + f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = flags + + // When + out, err := run("", "evaluate") + + // Then + if err != nil { + t.Fatalf("evaluate: %v\noutput: %s", err, out) + } + for _, want := range []string{ + "VARIANT", "REASON", + "TARGETING_MATCH; segment=power users", "treatment", "DEFAULT", + } { + if !strings.Contains(out, want) { + t.Errorf("output = %q, want it to contain %q", out, want) + } + } + }) + + t.Run("the table drops the columns an SDK API left empty", func(t *testing.T) { + // Given + f := evalEnv(t) + flags := sdkFlagsFrom(defaultFeatures()) + flags[0]["reason"], flags[1]["reason"] = "DEFAULT", "DEFAULT" + f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = flags + + // When + out, err := run("", "evaluate") + + // Then + if !strings.Contains(out, "REASON") { + t.Errorf("output = %q, want the reason column", out) + } + if strings.Contains(out, "VARIANT") { + t.Errorf("output = %q, want no variant column where nothing was bucketed", out) + } + if err != nil { + t.Fatalf("evaluate: %v\noutput: %s", err, out) + } + }) + t.Run("--js is the frontend SDK's hydration state", func(t *testing.T) { // Given f := evalEnv(t) @@ -6303,6 +6382,32 @@ func TestEvaluate(t *testing.T) { if strings.Contains(out, "onboarding_banner") { t.Errorf("output = %q, want only the named feature", out) } + for _, absent := range []string{"Reason", "Variant"} { + if strings.Contains(out, absent) { + t.Errorf("output = %q, want no %q the SDK API never reported", out, absent) + } + } + }) + + t.Run("a single feature's detail view says why it resolved", func(t *testing.T) { + // Given + f := evalEnv(t) + flags := sdkFlagsFrom(defaultFeatures()) + flags[1]["reason"], flags[1]["variant"] = "SPLIT; weight=30", "treatment" + f.sdkEnvFlags["WqXhZk8sVY3dGgTqZ9pJmN"] = flags + + // When + out, err := run("", "evaluate", "max_items") + + // Then + if err != nil { + t.Fatalf("evaluate max_items: %v\noutput: %s", err, out) + } + for _, want := range []string{"Variant", "treatment", "Reason", "SPLIT; weight=30"} { + if !strings.Contains(out, want) { + t.Errorf("output = %q, want %q", out, want) + } + } }) t.Run("an unknown feature errors with a hint", func(t *testing.T) { @@ -6582,6 +6687,26 @@ func TestEvaluateIdentity(t *testing.T) { } }) + t.Run("an identity's variant and reason survive the identify call", func(t *testing.T) { + // Given + f := evalEnv(t) + flags := overridden() + flags[1]["reason"], flags[1]["variant"] = "SPLIT; weight=50", "treatment" + f.sdkIdentityFlags["user-123"] = flags + + // When + out, err := run("", "evaluate", "max_items", "--identity", "user-123", "--json") + + // Then + if err != nil { + t.Fatalf("evaluate --identity --json: %v\noutput: %s", err, out) + } + doc := evalDoc(t, out) + if doc["reason"] != "SPLIT; weight=50" || doc["variant"] != "treatment" { + t.Errorf("doc = %+v, want the identity's reason and variant", doc) + } + }) + // The flag commands name this --identifier; eval names it --identity. Both // spellings work here, so nobody has to remember which command uses which. for _, spelling := range []string{"--identity", "--identifier", "-i"} { diff --git a/internal/cmd/evaluate.go b/internal/cmd/evaluate.go index 66b0d2f..2a0a5cb 100644 --- a/internal/cmd/evaluate.go +++ b/internal/cmd/evaluate.go @@ -23,10 +23,18 @@ type evalView struct { Feature string Enabled bool Value any + Reason string + Variant string } func newEvalView(f flagsmith.Flag) evalView { - return evalView{Feature: f.FeatureName, Enabled: f.Enabled, Value: f.Value} + return evalView{ + Feature: f.FeatureName, + Enabled: f.Enabled, + Value: f.Value, + Reason: f.Reason, + Variant: f.Variant, + } } const evaluationResultSchema = "https://raw.githubusercontent.com/Flagsmith/flagsmith/main/sdk/evaluation-result.json" @@ -44,10 +52,18 @@ type evalFlag struct { Name string `json:"name"` Enabled bool `json:"enabled"` Value any `json:"value"` + Reason string `json:"reason,omitempty"` + Variant string `json:"variant,omitempty"` } func newEvalFlag(v evalView) evalFlag { - return evalFlag{Name: v.Feature, Enabled: v.Enabled, Value: v.Value} + return evalFlag{ + Name: v.Feature, + Enabled: v.Enabled, + Value: v.Value, + Reason: v.Reason, + Variant: v.Variant, + } } func newEvalResult(views []evalView) evalResult { @@ -190,26 +206,51 @@ func renderEvaluation(cmd *cobra.Command, sdkURL string, views []evalView, singl "Warning: no flags to hydrate from — an SDK given this state will wait for a fetch") } } + var anyReason, anyVariant bool + for _, v := range views { + anyReason = anyReason || v.Reason != "" + anyVariant = anyVariant || v.Variant != "" + } opts := outputOpts() opts.JSON = opts.JSON || evalJSFlag return output.Render(cmd.OutOrStdout(), doc, opts, func(w io.Writer) error { if single { v := views[0] - return output.Detail(w, []output.Field{ + fields := []output.Field{ {Label: "Feature", Value: v.Feature}, {Label: "Enabled", Value: boolState(v.Enabled)}, {Label: "Value", Value: valueDisplay(v.Value)}, - }) + } + if v.Variant != "" { + fields = append(fields, output.Field{Label: "Variant", Value: v.Variant}) + } + if v.Reason != "" { + fields = append(fields, output.Field{Label: "Reason", Value: v.Reason}) + } + return output.Detail(w, fields) } if len(views) == 0 { fmt.Fprintln(w, "No flags.") return nil } + headers := []string{"FEATURE", "ENABLED", "VALUE"} + if anyVariant { + headers = append(headers, "VARIANT") + } + if anyReason { + headers = append(headers, "REASON") + } rows := make([][]string, len(views)) for i, v := range views { rows[i] = []string{v.Feature, boolState(v.Enabled), truncateValue(valueDisplay(v.Value))} + if anyVariant { + rows[i] = append(rows[i], v.Variant) + } + if anyReason { + rows[i] = append(rows[i], v.Reason) + } } - if err := output.Table(w, []string{"FEATURE", "ENABLED", "VALUE"}, rows); err != nil { + if err := output.Table(w, headers, rows); err != nil { return err } fmt.Fprintf(w, "\n%d %s\n", len(views), plural(len(views), "flag", "flags"))