-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathcli_matrix_test.go
More file actions
190 lines (177 loc) · 6.94 KB
/
Copy pathcli_matrix_test.go
File metadata and controls
190 lines (177 loc) · 6.94 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
package main
import (
"bytes"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"
"sync"
"testing"
)
// CLI invocation matrix: run the REAL binary with a spread of argument
// combinations and validate exit codes and output. Everything here is
// machine-independent by construction — no docker/k3d/helm, no network, no
// cluster, no interactive prompts (stdin is closed, so the CLI is in its
// non-interactive mode). Anything that needs real tools lives in the e2e
// workflow (.github/workflows/test.yml), not here.
var (
matrixBinOnce sync.Once
matrixBinPath string
matrixBinErr error
)
// matrixBin builds the CLI once per test process.
func matrixBin(t *testing.T) string {
t.Helper()
matrixBinOnce.Do(func() {
dir, err := os.MkdirTemp("", "of-cli-matrix")
if err != nil {
matrixBinErr = err
return
}
// .exe on Windows: os/exec resolves executables via PATHEXT, so an
// extensionless binary is "not found" even by explicit path.
name := "openframe-matrix-test"
if runtime.GOOS == "windows" {
name += ".exe"
}
matrixBinPath = filepath.Join(dir, name)
out, err := exec.Command("go", "build", "-o", matrixBinPath, ".").CombinedOutput()
if err != nil {
matrixBinErr = err
matrixBinPath = ""
_ = os.RemoveAll(dir)
t.Logf("build output: %s", out)
}
})
if matrixBinErr != nil {
t.Fatalf("building test binary: %v", matrixBinErr)
}
return matrixBinPath
}
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// runMatrix executes the binary in a hermetic environment: isolated HOME (no
// ~/.openframe state), no kubeconfig, update checks off, WSL forwarding off
// (so the matrix behaves identically on native Windows), stdin closed.
func runMatrix(t *testing.T, args ...string) (stdout, stderr string, exitCode int) {
t.Helper()
home := t.TempDir()
cmd := exec.Command(matrixBin(t), args...)
cmd.Env = append(os.Environ(),
"HOME="+home,
"USERPROFILE="+home,
"KUBECONFIG="+filepath.Join(home, "no-such-kubeconfig"),
"OPENFRAME_NO_UPDATE_CHECK=1",
"OPENFRAME_NO_WSL_FORWARD=1",
)
var outBuf, errBuf bytes.Buffer
cmd.Stdout, cmd.Stderr = &outBuf, &errBuf
err := cmd.Run()
exitCode = 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = ee.ExitCode()
} else {
t.Fatalf("running %v: %v", args, err)
}
}
return ansiRe.ReplaceAllString(outBuf.String(), ""), ansiRe.ReplaceAllString(errBuf.String(), ""), exitCode
}
func TestCLIMatrix(t *testing.T) {
cases := []struct {
name string
args []string
wantExit int
contains []string // matched against stdout+stderr, ANSI-stripped
absent []string
}{
// ---- happy read-only surface -----------------------------------
{"version", []string{"--version"}, 0, []string{"dev"}, nil},
{"root help", []string{"--help"}, 0,
[]string{"Available Commands", "cluster", "app", "bootstrap", "prerequisites", "update"}, nil},
{"app help lists subcommands", []string{"app", "--help"}, 0,
[]string{"install", "upgrade", "status", "access", "uninstall"}, nil},
{"cluster help lists subcommands", []string{"cluster", "--help"}, 0,
[]string{"create", "delete", "list", "status", "cleanup"}, nil},
{"update help lists subcommands", []string{"update", "--help"}, 0,
[]string{"check", "rollback"}, nil},
{"completion bash", []string{"completion", "bash"}, 0, []string{"openframe"}, nil},
{"completion zsh", []string{"completion", "zsh"}, 0, []string{"openframe"}, nil},
{"completion fish", []string{"completion", "fish"}, 0, []string{"openframe"}, nil},
{"completion powershell", []string{"completion", "powershell"}, 0, []string{"openframe"}, nil},
// Rollback with no prior update: clean offline no-op, exit 0.
{"update rollback with nothing saved", []string{"update", "rollback"}, 0,
[]string{"No previous version"}, nil},
// ---- unknown surface ---------------------------------------------
{"unknown command", []string{"bogus"}, 1, []string{"unknown command"}, nil},
{"unknown root flag", []string{"--bogus"}, 1, []string{"unknown flag"}, nil},
{"unknown update flag", []string{"update", "--bogus"}, 1, []string{"unknown flag"}, nil},
// Removed/legacy flags must fail loudly, not be silently ignored.
{"removed --github-branch", []string{"app", "install", "--github-branch", "x"}, 1,
[]string{"unknown flag: --github-branch"}, nil},
{"legacy --deployment-mode", []string{"app", "install", "--deployment-mode", "oss"}, 1,
[]string{"unknown flag: --deployment-mode"}, nil},
// ---- flag/arg validation (parse-time, before any gate) ------------
{"non-numeric --nodes", []string{"cluster", "create", "x", "--nodes", "abc"}, 1,
[]string{`invalid argument "abc"`}, nil},
{"non-bool --prune", []string{"app", "upgrade", "--prune=banana"}, 1,
[]string{"invalid argument"}, nil},
{"bootstrap too many args", []string{"bootstrap", "a", "b"}, 1,
[]string{"accepts at most 1 arg"}, nil},
{"bootstrap invalid cluster name", []string{"bootstrap", "Invalid_Name", "--non-interactive"}, 1,
[]string{"is invalid", "hyphens"}, nil},
// ---- command-level guards (fail fast, no cluster contact) ---------
{"upgrade ref+sync mutually exclusive", []string{"app", "upgrade", "--ref", "x", "--sync"}, 1,
[]string{"mutually exclusive"}, nil},
{"uninstall non-interactive needs --yes", []string{"app", "uninstall"}, 1,
[]string{"--yes", "non-interactive"}, nil},
{"install with unknown context", []string{"app", "install", "--context", "no-such", "--non-interactive", "--dry-run"}, 1,
[]string{`could not use context "no-such"`}, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
stdout, stderr, code := runMatrix(t, tc.args...)
combined := stdout + "\n" + stderr
if code != tc.wantExit {
t.Errorf("exit = %d, want %d\noutput:\n%s", code, tc.wantExit, combined)
}
for _, want := range tc.contains {
if !strings.Contains(combined, want) {
t.Errorf("output missing %q:\n%s", want, combined)
}
}
for _, banned := range tc.absent {
if strings.Contains(combined, banned) {
t.Errorf("output must not contain %q:\n%s", banned, combined)
}
}
})
}
}
// TestCLIMatrix_MachineOutputsOnStdout: script-facing outputs (--version,
// completion scripts) must land on STDOUT — piping them must work.
func TestCLIMatrix_MachineOutputsOnStdout(t *testing.T) {
for _, args := range [][]string{{"--version"}, {"completion", "bash"}} {
stdout, _, code := runMatrix(t, args...)
if code != 0 {
t.Errorf("%v: exit %d", args, code)
}
if strings.TrimSpace(stdout) == "" {
t.Errorf("%v: stdout is empty — machine output must go to stdout", args)
}
}
}
// TestCLIMatrix_SilentHelpIsQuiet: --silent must not decorate even trivial
// read-only commands with the logo.
func TestCLIMatrix_SilentVersion(t *testing.T) {
stdout, stderr, code := runMatrix(t, "--silent", "--version")
if code != 0 {
t.Fatalf("exit %d (stderr: %s)", code, stderr)
}
if strings.Contains(stdout+stderr, "Bootstrapper") {
t.Error("--silent leaked the logo banner")
}
}