Skip to content

Commit 4a58c0c

Browse files
authored
Add selftest tui commands for manual prompt verification (#5208)
## Summary Adds a `databricks selftest tui` group (hidden, like the rest of `selftest`) with one subcommand per cmdio prompt entry point: `prompt`, `secret`, `ask`, `ask-yes-no`, `select`, `select-ordered`, `run-select`, `spinner`, `colors`. Each runs a single helper with the simplest meaningful inputs; flags layer in customization (e.g. `prompt --mask --validate`, `select-ordered --filter`, `run-select --rich`, `spinner --elapsed`). The motivation is twofold: I want to (1) sanity-check prompts on different terminals (iTerm2, Terminal.app, Windows console, VS Code, tmux) without having to construct a real workspace flow, and (2) eyeball the visual diff when a prompt's rendering is modified — both to catch regressions and to demonstrate intentional changes side-by-side. Fixture data is drawn from the public Databricks docs so the demo looks like content a user would actually encounter. This pull request and its description were written by Isaac.
1 parent 55f2cab commit 4a58c0c

7 files changed

Lines changed: 509 additions & 19 deletions

File tree

cmd/selftest/tui/ask.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package tui
2+
3+
import (
4+
"github.com/databricks/cli/libs/cmdio"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func newAskCmd() *cobra.Command {
9+
var defaultVal string
10+
cmd := &cobra.Command{
11+
Use: "ask",
12+
Short: "cmdio.Ask (single-line text input with optional default)",
13+
RunE: func(cmd *cobra.Command, args []string) error {
14+
ctx := cmd.Context()
15+
ans, err := cmdio.Ask(ctx, "Enter a value", defaultVal)
16+
if err != nil {
17+
return err
18+
}
19+
cmdio.LogString(ctx, "Entered: "+ans)
20+
return nil
21+
},
22+
}
23+
cmd.Flags().StringVar(&defaultVal, "default", "", "default returned if the user just presses Enter")
24+
return cmd
25+
}
26+
27+
func newAskYesOrNoCmd() *cobra.Command {
28+
return &cobra.Command{
29+
Use: "ask-yes-no",
30+
Short: "cmdio.AskYesOrNo (yes/no question)",
31+
RunE: func(cmd *cobra.Command, args []string) error {
32+
ctx := cmd.Context()
33+
ans, err := cmdio.AskYesOrNo(ctx, "Continue")
34+
if err != nil {
35+
return err
36+
}
37+
if ans {
38+
cmdio.LogString(ctx, "Answer: yes")
39+
} else {
40+
cmdio.LogString(ctx, "Answer: no")
41+
}
42+
return nil
43+
},
44+
}
45+
}

cmd/selftest/tui/colors.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package tui
2+
3+
import (
4+
"github.com/databricks/cli/libs/cmdio"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func newColorsCmd() *cobra.Command {
9+
return &cobra.Command{
10+
Use: "colors",
11+
Short: "Print colored text to verify color support",
12+
RunE: func(cmd *cobra.Command, args []string) error {
13+
ctx := cmd.Context()
14+
swatch := "the quick brown fox jumps over the lazy dog"
15+
cmdio.LogString(ctx, "red: "+cmdio.Red(ctx, swatch))
16+
cmdio.LogString(ctx, "green: "+cmdio.Green(ctx, swatch))
17+
cmdio.LogString(ctx, "yellow: "+cmdio.Yellow(ctx, swatch))
18+
cmdio.LogString(ctx, "blue: "+cmdio.Blue(ctx, swatch))
19+
cmdio.LogString(ctx, "cyan: "+cmdio.Cyan(ctx, swatch))
20+
cmdio.LogString(ctx, "hiblack: "+cmdio.HiBlack(ctx, swatch))
21+
cmdio.LogString(ctx, "hiblue: "+cmdio.HiBlue(ctx, swatch))
22+
return nil
23+
},
24+
}
25+
}

cmd/selftest/tui/fixtures.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"time"
7+
8+
"github.com/databricks/cli/libs/cmdio"
9+
)
10+
11+
type spinnerMessage struct {
12+
text string
13+
duration time.Duration
14+
}
15+
16+
var spinnerMessages = []spinnerMessage{
17+
{"Initializing...", time.Second},
18+
{"Loading configuration", time.Second},
19+
{"Connecting to workspace", time.Second},
20+
{"Processing files", time.Second},
21+
{"Finalizing", time.Second},
22+
}
23+
24+
// databricksFeatures is a stable list of Databricks product / feature names
25+
// used as fixture data for the prompt scenarios. Drawn from the public docs
26+
// (https://docs.databricks.com) so the demo data looks like something a user
27+
// would actually encounter.
28+
var databricksFeatures = []string{
29+
"unity-catalog",
30+
"delta-lake",
31+
"delta-sharing",
32+
"photon",
33+
"mlflow",
34+
"mosaic-ai",
35+
"genie",
36+
"lakeflow-connect",
37+
"lakeflow-jobs",
38+
"vector-search",
39+
"model-serving",
40+
"feature-store",
41+
"databricks-sql",
42+
"ai-playground",
43+
"foundation-models",
44+
"lakehouse-monitoring",
45+
"liquid-clustering",
46+
"predictive-optimization",
47+
"governed-tags",
48+
"lakeflow-designer",
49+
}
50+
51+
// buildItems uses zero-padded ids so the alphabetical Select scenario has
52+
// a stable sort order.
53+
func buildItems(n int) []cmdio.Tuple {
54+
n = min(n, len(databricksFeatures))
55+
items := make([]cmdio.Tuple, 0, n)
56+
for i := range n {
57+
items = append(items, cmdio.Tuple{
58+
Name: databricksFeatures[i],
59+
Id: fmt.Sprintf("id-%02d", i+1),
60+
})
61+
}
62+
return items
63+
}
64+
65+
// buildFilterItems returns 15 items where 5 share the substring "lake", so
66+
// progressive typing narrows the list, and a non-matching substring ("xyz")
67+
// hits the "No results" path.
68+
func buildFilterItems() []cmdio.Tuple {
69+
names := []string{
70+
"lakehouse-monitoring",
71+
"lakeflow-connect",
72+
"lakeflow-jobs",
73+
"delta-lake",
74+
"lakebase-postgres",
75+
"unity-catalog",
76+
"mosaic-ai",
77+
"vector-search",
78+
"model-serving",
79+
"feature-store",
80+
"ai-playground",
81+
"genie-spaces",
82+
"mlflow-tracking",
83+
"liquid-clustering",
84+
"predictive-optimization",
85+
}
86+
items := make([]cmdio.Tuple, 0, len(names))
87+
for i, name := range names {
88+
items = append(items, cmdio.Tuple{
89+
Name: name,
90+
Id: fmt.Sprintf("id-%02d", i+1),
91+
})
92+
}
93+
return items
94+
}
95+
96+
// buildLongItems uses fully-qualified workspace URLs as ids so that the
97+
// rendered field overflows a typical terminal width.
98+
func buildLongItems() []cmdio.Tuple {
99+
hosts := []string{
100+
"https://adb-1234567890123456.78.azuredatabricks.net/?o=1234567890123456",
101+
"https://adb-2345678901234567.89.azuredatabricks.net/?o=2345678901234567",
102+
"https://acme-prod.cloud.databricks.com/?o=3456789012345678",
103+
"https://acme-staging.cloud.databricks.com/?o=4567890123456789",
104+
"https://acme-dev.cloud.databricks.com/?o=5678901234567890",
105+
"https://1234567890123456.7.gcp.databricks.com/?o=6789012345678901",
106+
"https://2345678901234567.8.gcp.databricks.com/?o=7890123456789012",
107+
"https://field-eng-east.cloud.databricks.com/?o=8901234567890123",
108+
}
109+
items := make([]cmdio.Tuple, 0, len(hosts))
110+
for i, host := range hosts {
111+
items = append(items, cmdio.Tuple{
112+
Name: fmt.Sprintf("workspace-%02d", i+1),
113+
Id: host,
114+
})
115+
}
116+
return items
117+
}
118+
119+
// clusterItem mirrors libs/databrickscfg/cfgpickers/clusters.go's
120+
// compatibleCluster: State, Access, and Runtime are exposed as methods so
121+
// the Active/Inactive templates exercise text/template's method-resolution
122+
// path, and State returns a pre-rendered colored string (matching the
123+
// renderedState cache in production) so the demo also exercises ANSI codes
124+
// emitted from inside a template.
125+
type clusterItem struct {
126+
Name string
127+
Id string
128+
129+
access string
130+
runtimeName string
131+
renderedState string
132+
}
133+
134+
func (c clusterItem) Access() string { return c.access }
135+
func (c clusterItem) Runtime() string { return c.runtimeName }
136+
func (c clusterItem) State() string { return c.renderedState }
137+
138+
func buildClusterItems(ctx context.Context) []clusterItem {
139+
green := func(s string) string { return cmdio.Green(ctx, s) }
140+
red := func(s string) string { return cmdio.Red(ctx, s) }
141+
blue := func(s string) string { return cmdio.Blue(ctx, s) }
142+
return []clusterItem{
143+
{Name: "shared-autoscaling-prod", Id: "0123-456789-abcdef01", access: "Shared", runtimeName: "DBR 14.3 LTS", renderedState: green("RUNNING")},
144+
{Name: "ml-gpu-experiments", Id: "0123-456789-abcdef02", access: "Assigned", runtimeName: "DBR 15.0 ML", renderedState: red("TERMINATED")},
145+
{Name: "job-compute-bronze-etl", Id: "0123-456789-abcdef03", access: "Shared", runtimeName: "DBR 13.3 LTS", renderedState: green("RUNNING")},
146+
{Name: "interactive-analytics", Id: "0123-456789-abcdef04", access: "Assigned", runtimeName: "DBR 14.3", renderedState: blue("PENDING")},
147+
{Name: "photon-streaming-realtime", Id: "0123-456789-abcdef05", access: "Shared", runtimeName: "DBR 14.3 Photon", renderedState: green("RUNNING")},
148+
{Name: "single-node-dev", Id: "0123-456789-abcdef06", access: "Assigned", runtimeName: "DBR 14.3 LTS", renderedState: red("TERMINATED")},
149+
{Name: "all-purpose-shared", Id: "0123-456789-abcdef07", access: "Shared", runtimeName: "DBR 15.0", renderedState: green("RUNNING")},
150+
{Name: "legacy-data-eng", Id: "0123-456789-abcdef08", access: "Assigned", runtimeName: "DBR 12.2 LTS", renderedState: red("TERMINATED")},
151+
}
152+
}
153+
154+
// profileItem mirrors the profile picker in cmd/auth/token.go: regular items
155+
// have a Host, the trailing meta items do not (so the {{if .Host}} branch fires).
156+
type profileItem struct {
157+
Name string
158+
Host string
159+
}
160+
161+
// buildProfileItems returns 6 profile-shaped items across AWS / Azure / GCP
162+
// hosts plus the two trailing meta-rows ("Create a new profile", "Enter a
163+
// host URL manually") used by the real profile picker.
164+
func buildProfileItems() []profileItem {
165+
return []profileItem{
166+
{Name: "DEFAULT", Host: "https://acme.cloud.databricks.com"},
167+
{Name: "production", Host: "https://acme-prod.cloud.databricks.com"},
168+
{Name: "staging", Host: "https://acme-stg.cloud.databricks.com"},
169+
{Name: "field-eng", Host: "https://field-eng.cloud.databricks.com"},
170+
{Name: "azure-personal", Host: "https://adb-1234567890123456.78.azuredatabricks.net"},
171+
{Name: "gcp-sandbox", Host: "https://1234567890123456.7.gcp.databricks.com"},
172+
{Name: "Create a new profile"},
173+
{Name: "Enter a host URL manually"},
174+
}
175+
}

cmd/selftest/tui/prompt.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package tui
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"strings"
7+
8+
"github.com/databricks/cli/libs/cmdio"
9+
"github.com/spf13/cobra"
10+
)
11+
12+
func newPromptCmd() *cobra.Command {
13+
var (
14+
defaultVal string
15+
mask bool
16+
validate bool
17+
)
18+
cmd := &cobra.Command{
19+
Use: "prompt",
20+
Short: "cmdio.RunPrompt (single-line text input)",
21+
RunE: func(cmd *cobra.Command, args []string) error {
22+
ctx := cmd.Context()
23+
opts := cmdio.PromptOptions{
24+
Label: "Enter a value",
25+
Default: defaultVal,
26+
}
27+
if mask {
28+
opts.Mask = '*'
29+
}
30+
if validate {
31+
opts.Validate = func(input string) error {
32+
if !strings.Contains(input, "://") {
33+
return errors.New("value must contain '://'")
34+
}
35+
return nil
36+
}
37+
}
38+
value, err := cmdio.RunPrompt(ctx, opts)
39+
if err != nil {
40+
return err
41+
}
42+
if mask {
43+
cmdio.LogString(ctx, fmt.Sprintf("Entered %d characters", len(value)))
44+
return nil
45+
}
46+
cmdio.LogString(ctx, "Entered: "+value)
47+
return nil
48+
},
49+
}
50+
cmd.Flags().StringVar(&defaultVal, "default", "", "pre-fill the input with this value")
51+
cmd.Flags().BoolVar(&mask, "mask", false, "echo input as '*'")
52+
cmd.Flags().BoolVar(&validate, "validate", false, "require '://' in input")
53+
return cmd
54+
}
55+
56+
func newSecretCmd() *cobra.Command {
57+
return &cobra.Command{
58+
Use: "secret",
59+
Short: "cmdio.Secret (masked password input)",
60+
RunE: func(cmd *cobra.Command, args []string) error {
61+
ctx := cmd.Context()
62+
value, err := cmdio.Secret(ctx, "Personal access token")
63+
if err != nil {
64+
return err
65+
}
66+
cmdio.LogString(ctx, fmt.Sprintf("Entered %d characters", len(value)))
67+
return nil
68+
},
69+
}
70+
}

0 commit comments

Comments
 (0)