From 3265c83aa4bbac2bebce4d3e69f716b8af921a1b Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Wed, 24 Jun 2026 12:26:41 -0400 Subject: [PATCH 1/7] docs: design for `testtrack show` split-weights command --- .../2026-06-24-show-split-weights-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-24-show-split-weights-design.md diff --git a/docs/superpowers/specs/2026-06-24-show-split-weights-design.md b/docs/superpowers/specs/2026-06-24-show-split-weights-design.md new file mode 100644 index 0000000..7e3362a --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-show-split-weights-design.md @@ -0,0 +1,86 @@ +# Design: `testtrack show ` + +## Problem + +There is no way to read the current variant weights of an ongoing split / feature +gate directly from the CLI. Today the only options are: + +- `testtrack sync` (mutates the local schema with remote weights), then open the file +- the TestTrack admin UI +- a raw `curl` against `api/v*/split_registry` + +A read-only command fills this gap and reuses the CLI's existing server-read plumbing. + +## Command surface + +- `testtrack show ` — required single arg (`cobra.ExactArgs(1)`). + The arg is the fully-qualified split name (e.g. + `retail.cash_in_portfolios_q2_2026_enabled`) and is matched verbatim against + registry keys. No auto-prefixing — predictable, and matches how `sync` keys off + full names. +- `--json` flag — emit the raw weights map for scripting. +- Uses `TESTTRACK_CLI_URL` via `servers.New()`, exactly like `sync` (errors clearly + if unset). + +## Implementation + +New file `cmds/show.go`, registered in its `init()` via +`rootCmd.AddCommand(showCommand)`, mirroring `cmds/sync.go`. + +```go +func Show(name string, asJSON bool) error { + server, err := servers.New() + if err != nil { + return err + } + + var registry serializers.RemoteRegistry + if err := server.Get("api/v2/split_registry.json", ®istry); 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) + } + // print split.Weights — table by default, JSON if asJSON +} +``` + +Reuses `serializers.RemoteRegistry` / `RemoteRegistrySplit` as-is (they already +deserialize `api/v2/split_registry.json`). No struct changes, no new dependencies. + +## Output + +Default — readable, one variant per line, sorted by variant name for deterministic +output: + +``` +retail.cash_in_portfolios_q2_2026_enabled + false 100% + true 0% +``` + +`--json`: + +``` +{"false":100,"true":0} +``` + +## Testing + +Follows the repo pattern: the `fakeserver` package + `servers.IServer` interface +inject a fake registry response. Tests assert: + +1. weights found → correct table / JSON output +2. split absent → error including the hint +3. `TESTTRACK_CLI_URL` unset → error from `servers.New()` + +## Scope / YAGNI + +Out of scope: `--local`, listing all splits, assignment counts, showing the +`feature_gate` flag (the v2 struct doesn't carry it today; a separate enhancement). + +## Docs + +Add a short `testtrack show` entry to `README.md` near the `sync` section. From 582c7d5e0352146707ac70735c2acefd9322aae9 Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Wed, 24 Jun 2026 13:07:27 -0400 Subject: [PATCH 2/7] feat: add `testtrack show` to read a split's remote weights Reading live split weights previously required `sync` (which mutates the local schema), the admin UI, or a raw curl. `show` is a read-only lookup against the split registry so devs can check a feature gate's current weights without side effects. --- README.md | 4 ++ cmds/show.go | 102 ++++++++++++++++++++++++++++++++++++++++++++++ cmds/show_test.go | 96 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 cmds/show.go create mode 100644 cmds/show_test.go diff --git a/README.md b/README.md index ceeef01..9b15ac8 100644 --- a/README.md +++ b/README.md @@ -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= 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= testtrack show ` (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. diff --git a/cmds/show.go b/cmds/show.go new file mode 100644 index 0000000..8a20bf5 --- /dev/null +++ b/cmds/show.go @@ -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 ", + 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", ®istry); 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 { + b.WriteString(fmt.Sprintf("\n %-*s %*d%%", nameWidth, variant, weightWidth, weights[variant])) + } + return b.String(), nil +} diff --git a/cmds/show_test.go b/cmds/show_test.go new file mode 100644 index 0000000..6b77e43 --- /dev/null +++ b/cmds/show_test.go @@ -0,0 +1,96 @@ +package cmds + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/Betterment/testtrack-cli/serializers" +) + +// 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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // variant names are left-padded to the widest name, weights right-aligned + expected := name + "\n false 100%\n true 0%" + if output != expected { + t.Errorf("expected:\n%q\ngot:\n%q", expected, output) + } + + // variants must be sorted: "false" before "true" + if strings.Index(output, "false") > strings.Index(output, "true") { + t.Errorf("expected variants sorted by name, got:\n%s", 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) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var roundTripped map[string]int + if err := json.Unmarshal([]byte(output), &roundTripped); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + if roundTripped["false"] != 100 || roundTripped["true"] != 0 { + t.Errorf("JSON did not round-trip to the weights map, got: %s", output) + } +} + +func TestShowRequiresServerURL(t *testing.T) { + t.Setenv("TESTTRACK_CLI_URL", "") + + if err := Show("any.split", false); err == nil { + t.Fatal("expected an error when TESTTRACK_CLI_URL is unset, got nil") + } +} + +func TestSplitWeightsNotFound(t *testing.T) { + server := &fakeServer{registry: registryWith("some.other.split", map[string]int{"true": 100})} + + _, err := splitWeights(server, "no.such.split", false) + if err == nil { + t.Fatal("expected an error for a missing split, got nil") + } + if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "testtrack sync") { + t.Errorf("expected not-found error with hint, got: %v", err) + } +} From 6bd72ec20b93ff6e06ab454a77d3278a4b2b4f54 Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Thu, 25 Jun 2026 17:03:17 -0400 Subject: [PATCH 3/7] fix: use fmt.Fprintf to satisfy staticcheck QF1012 --- cmds/show.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmds/show.go b/cmds/show.go index 8a20bf5..85ae0da 100644 --- a/cmds/show.go +++ b/cmds/show.go @@ -96,7 +96,7 @@ func formatWeights(name string, weights map[string]int, asJSON bool) (string, er var b strings.Builder b.WriteString(name) for _, variant := range variants { - b.WriteString(fmt.Sprintf("\n %-*s %*d%%", nameWidth, variant, weightWidth, weights[variant])) + fmt.Fprintf(&b, "\n %-*s %*d%%", nameWidth, variant, weightWidth, weights[variant]) } return b.String(), nil } From 6221186cbac3db984d67b03940ba0f8c5e68ed27 Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Thu, 25 Jun 2026 17:28:55 -0400 Subject: [PATCH 4/7] fix: exclude non-Go doc dirs from make test package list The new docs/ design-spec dir broke 'go test', which only excluded doc/. --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8ce3795..f93fb4a 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ 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 From 34b9be5c99544f3df365d0ceb24258cc45f5f36f Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Thu, 25 Jun 2026 17:29:37 -0400 Subject: [PATCH 5/7] chore: remove committed design spec from docs/ --- .../2026-06-24-show-split-weights-design.md | 86 ------------------- 1 file changed, 86 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-24-show-split-weights-design.md diff --git a/docs/superpowers/specs/2026-06-24-show-split-weights-design.md b/docs/superpowers/specs/2026-06-24-show-split-weights-design.md deleted file mode 100644 index 7e3362a..0000000 --- a/docs/superpowers/specs/2026-06-24-show-split-weights-design.md +++ /dev/null @@ -1,86 +0,0 @@ -# Design: `testtrack show ` - -## Problem - -There is no way to read the current variant weights of an ongoing split / feature -gate directly from the CLI. Today the only options are: - -- `testtrack sync` (mutates the local schema with remote weights), then open the file -- the TestTrack admin UI -- a raw `curl` against `api/v*/split_registry` - -A read-only command fills this gap and reuses the CLI's existing server-read plumbing. - -## Command surface - -- `testtrack show ` — required single arg (`cobra.ExactArgs(1)`). - The arg is the fully-qualified split name (e.g. - `retail.cash_in_portfolios_q2_2026_enabled`) and is matched verbatim against - registry keys. No auto-prefixing — predictable, and matches how `sync` keys off - full names. -- `--json` flag — emit the raw weights map for scripting. -- Uses `TESTTRACK_CLI_URL` via `servers.New()`, exactly like `sync` (errors clearly - if unset). - -## Implementation - -New file `cmds/show.go`, registered in its `init()` via -`rootCmd.AddCommand(showCommand)`, mirroring `cmds/sync.go`. - -```go -func Show(name string, asJSON bool) error { - server, err := servers.New() - if err != nil { - return err - } - - var registry serializers.RemoteRegistry - if err := server.Get("api/v2/split_registry.json", ®istry); 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) - } - // print split.Weights — table by default, JSON if asJSON -} -``` - -Reuses `serializers.RemoteRegistry` / `RemoteRegistrySplit` as-is (they already -deserialize `api/v2/split_registry.json`). No struct changes, no new dependencies. - -## Output - -Default — readable, one variant per line, sorted by variant name for deterministic -output: - -``` -retail.cash_in_portfolios_q2_2026_enabled - false 100% - true 0% -``` - -`--json`: - -``` -{"false":100,"true":0} -``` - -## Testing - -Follows the repo pattern: the `fakeserver` package + `servers.IServer` interface -inject a fake registry response. Tests assert: - -1. weights found → correct table / JSON output -2. split absent → error including the hint -3. `TESTTRACK_CLI_URL` unset → error from `servers.New()` - -## Scope / YAGNI - -Out of scope: `--local`, listing all splits, assignment counts, showing the -`feature_gate` flag (the v2 struct doesn't carry it today; a separate enhancement). - -## Docs - -Add a short `testtrack show` entry to `README.md` near the `sync` section. From 683692f36c539ac4d8a164f30ea346dd48a86591 Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Thu, 25 Jun 2026 19:29:10 -0400 Subject: [PATCH 6/7] chore: bump version to 1.9.0 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f93fb4a..48d6742 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ SHELL = /bin/sh -VERSION=1.8.0 +VERSION=1.9.0 BUILD=`git rev-parse HEAD` LDFLAGS=-ldflags "-w -s \ From 084d8196a9dba96d76c0afda13738e5746a7f086 Mon Sep 17 00:00:00 2001 From: Dan Egan Date: Thu, 25 Jun 2026 20:06:48 -0400 Subject: [PATCH 7/7] test: use testify for show command tests Adopt require to match the rest of the suite and add a width-alignment case. --- cmds/show_test.go | 59 +++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 33 deletions(-) diff --git a/cmds/show_test.go b/cmds/show_test.go index 6b77e43..fe8ac8a 100644 --- a/cmds/show_test.go +++ b/cmds/show_test.go @@ -1,12 +1,11 @@ package cmds import ( - "encoding/json" "net/http" - "strings" "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. @@ -40,20 +39,27 @@ func TestSplitWeightsHumanReadable(t *testing.T) { server := &fakeServer{registry: registryWith(name, map[string]int{"false": 100, "true": 0})} output, err := splitWeights(server, name, false) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } + require.NoError(t, err) - // variant names are left-padded to the widest name, weights right-aligned + // 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%" - if output != expected { - t.Errorf("expected:\n%q\ngot:\n%q", expected, output) - } + require.Equal(t, expected, output) +} - // variants must be sorted: "false" before "true" - if strings.Index(output, "false") > strings.Index(output, "true") { - t.Errorf("expected variants sorted by name, got:\n%s", 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) { @@ -62,35 +68,22 @@ func TestSplitWeightsJSON(t *testing.T) { server := &fakeServer{registry: registryWith(name, weights)} output, err := splitWeights(server, name, true) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var roundTripped map[string]int - if err := json.Unmarshal([]byte(output), &roundTripped); err != nil { - t.Fatalf("output is not valid JSON: %v", err) - } - if roundTripped["false"] != 100 || roundTripped["true"] != 0 { - t.Errorf("JSON did not round-trip to the weights map, got: %s", output) - } + require.NoError(t, err) + require.JSONEq(t, `{"false":100,"true":0}`, output) } func TestShowRequiresServerURL(t *testing.T) { t.Setenv("TESTTRACK_CLI_URL", "") - if err := Show("any.split", false); err == nil { - t.Fatal("expected an error when TESTTRACK_CLI_URL is unset, got nil") - } + 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) - if err == nil { - t.Fatal("expected an error for a missing split, got nil") - } - if !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "testtrack sync") { - t.Errorf("expected not-found error with hint, got: %v", err) - } + require.Error(t, err) + require.Contains(t, err.Error(), "not found") + require.Contains(t, err.Error(), "testtrack sync") }