Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
127 changes: 126 additions & 1 deletion internal/cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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"} {
Expand Down
51 changes: 46 additions & 5 deletions internal/cmd/evaluate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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"))
Expand Down