Skip to content
Merged
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
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
SHELL = /bin/sh

VERSION=1.8.0
VERSION=1.9.0
BUILD=`git rev-parse HEAD`

LDFLAGS=-ldflags "-w -s \
-X github.com/Betterment/testtrack-cli/cmds.version=${VERSION} \
-X github.com/Betterment/testtrack-cli/cmds.build=${BUILD}"

PACKAGES=$$(find . -maxdepth 1 -type d ! -path '.' ! -path './.*' ! -path './vendor' ! -path './dist' ! -path './script' ! -path './doc')
PACKAGES=$$(find . -maxdepth 1 -type d ! -path '.' ! -path './.*' ! -path './vendor' ! -path './dist' ! -path './script' ! -path './doc' ! -path './docs' ! -path './dev_docs' ! -path './bin')

all: test

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ If you have a large organization, you may wish to tag ownership of splits to a s

If you want to ensure that your local split assignments are in sync with your remote (production) assignments, you can run `TESTTRACK_CLI_URL=<base_url> testtrack sync` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack sync`) from your project directory to pull the assignments from your remote server into your local `schema.{json,yml}` file.

### Showing a split's current weights

To read the current variant weights of a split from a remote server without modifying your local schema, run `TESTTRACK_CLI_URL=<base_url> testtrack show <split>` (e.g. `TESTTRACK_CLI_URL=https://tt.example.com testtrack show my_app.my_feature_enabled`). Pass the fully-qualified split name; it is matched verbatim against the remote registry. Add `--json` to print the weights as a JSON map for scripting. This also works against a local `testtrack server` (e.g. `TESTTRACK_CLI_URL=http://localhost:8297 testtrack show ...`).

## How to Contribute

We would love for you to contribute! Anything that benefits the majority of TestTrack users—from a documentation fix to an entirely new feature—is encouraged.
Expand Down
102 changes: 102 additions & 0 deletions cmds/show.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cmds

import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"

"github.com/Betterment/testtrack-cli/serializers"
"github.com/Betterment/testtrack-cli/servers"
"github.com/spf13/cobra"
)

var showJSON bool

var showDoc = `
Show the current variant weights for a split from the remote TestTrack server.

Reads the split registry from the server configured by TESTTRACK_CLI_URL and
prints the weights for the named split. It does not modify the local schema.

The split name is matched verbatim against the remote registry, so pass the
fully-qualified name (e.g. my_app.my_feature_enabled).
`

func init() {
showCommand.Flags().BoolVar(&showJSON, "json", false, "output weights as JSON")
rootCmd.AddCommand(showCommand)
}

var showCommand = &cobra.Command{
Use: "show <split>",
Short: "Show remote variant weights for a split",
Long: showDoc,
Args: cobra.ExactArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
return Show(args[0], showJSON)
},
}

// Show prints the remote variant weights for the named split.
func Show(name string, asJSON bool) error {
server, err := servers.New()
if err != nil {
return err
}

output, err := splitWeights(server, name, asJSON)
if err != nil {
return err
}

fmt.Println(output)
return nil
}

// splitWeights fetches the named split from the server and renders its weights.
func splitWeights(server servers.IServer, name string, asJSON bool) (string, error) {
var registry serializers.RemoteRegistry
if err := server.Get("api/v2/split_registry", &registry); err != nil {
return "", err
}

split, ok := registry.Splits[name]
if !ok {
return "", fmt.Errorf("split %q not found in remote registry; check the name or run `testtrack sync`", name)
}

return formatWeights(name, split.Weights, asJSON)
}

// formatWeights renders a split's variant weights for display.
func formatWeights(name string, weights map[string]int, asJSON bool) (string, error) {
if asJSON {
bytes, err := json.Marshal(weights)
if err != nil {
return "", err
}
return string(bytes), nil
}

variants := make([]string, 0, len(weights))
nameWidth, weightWidth := 0, 0
for variant, weight := range weights {
variants = append(variants, variant)
if len(variant) > nameWidth {
nameWidth = len(variant)
}
if w := len(strconv.Itoa(weight)); w > weightWidth {
weightWidth = w
}
}
sort.Strings(variants)

var b strings.Builder
b.WriteString(name)
for _, variant := range variants {
fmt.Fprintf(&b, "\n %-*s %*d%%", nameWidth, variant, weightWidth, weights[variant])
}
return b.String(), nil
}
89 changes: 89 additions & 0 deletions cmds/show_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cmds

import (
"net/http"
"testing"

"github.com/Betterment/testtrack-cli/serializers"
"github.com/stretchr/testify/require"
)

// fakeServer is a test double for servers.IServer that returns a canned registry.
type fakeServer struct {
registry serializers.RemoteRegistry
}

func (f *fakeServer) Get(_ string, v interface{}) error {
*v.(*serializers.RemoteRegistry) = f.registry
return nil
}

func (f *fakeServer) Post(_ string, _ interface{}) (*http.Response, error) {
return nil, nil
}

func (f *fakeServer) Delete(_ string) error {
return nil
}

func registryWith(name string, weights map[string]int) serializers.RemoteRegistry {
return serializers.RemoteRegistry{
Splits: map[string]serializers.RemoteRegistrySplit{
name: {Weights: weights},
},
}
}

func TestSplitWeightsHumanReadable(t *testing.T) {
name := "retail.cash_in_portfolios_q2_2026_enabled"
server := &fakeServer{registry: registryWith(name, map[string]int{"false": 100, "true": 0})}

output, err := splitWeights(server, name, false)
require.NoError(t, err)

// variant names are left-padded to the widest name, weights right-aligned,
// and variants sorted: "false" before "true"
expected := name + "\n false 100%\n true 0%"
require.Equal(t, expected, output)
}

func TestSplitWeightsAlignsToWidestVariantAndWeight(t *testing.T) {
name := "my_app.checkout_experiment"
server := &fakeServer{registry: registryWith(name, map[string]int{
"control": 5,
"treatment": 95,
})}

output, err := splitWeights(server, name, false)
require.NoError(t, err)

// names left-aligned to "treatment" (9), weights right-aligned to width 2
expected := name + "\n control 5%\n treatment 95%"
require.Equal(t, expected, output)
}

func TestSplitWeightsJSON(t *testing.T) {
name := "retail.cash_in_portfolios_q2_2026_enabled"
weights := map[string]int{"false": 100, "true": 0}
server := &fakeServer{registry: registryWith(name, weights)}

output, err := splitWeights(server, name, true)
require.NoError(t, err)
require.JSONEq(t, `{"false":100,"true":0}`, output)
}

func TestShowRequiresServerURL(t *testing.T) {
t.Setenv("TESTTRACK_CLI_URL", "")

err := Show("any.split", false)
require.Error(t, err)
}

func TestSplitWeightsNotFound(t *testing.T) {
server := &fakeServer{registry: registryWith("some.other.split", map[string]int{"true": 100})}

_, err := splitWeights(server, "no.such.split", false)
require.Error(t, err)
require.Contains(t, err.Error(), "not found")
require.Contains(t, err.Error(), "testtrack sync")
}
Loading