返回 DeepSeek-Reasonix
shell_completion_test.go
根目录 / internal / cli / shell_completion_test.go
1 package cli
2
3 import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "reflect"
8 "slices"
9 "strconv"
10 "strings"
11 "testing"
12 )
13
14 func TestCLICompletionCoversRunDispatch(t *testing.T) {
15 fset := token.NewFileSet()
16 file, err := parser.ParseFile(fset, "cli.go", nil, 0)
17 if err != nil {
18 t.Fatal(err)
19 }
20
21 // Dispatch lives in RunWithBuildInfo (Run is a thin BuildInfo wrapper).
22 var dispatch *ast.SwitchStmt
23 for _, declaration := range file.Decls {
24 function, ok := declaration.(*ast.FuncDecl)
25 if !ok || function.Name.Name != "RunWithBuildInfo" {
26 continue
27 }
28 ast.Inspect(function.Body, func(node ast.Node) bool {
29 switchStatement, ok := node.(*ast.SwitchStmt)
30 if !ok {
31 return true
32 }
33 identifier, ok := switchStatement.Tag.(*ast.Ident)
34 if ok && identifier.Name == "cmd" {
35 dispatch = switchStatement
36 return false
37 }
38 return true
39 })
40 }
41 if dispatch == nil {
42 t.Fatal("RunWithBuildInfo cmd dispatch switch not found")
43 }
44
45 root := cliCompletionRootSpec()
46 for _, statement := range dispatch.Body.List {
47 clause, ok := statement.(*ast.CaseClause)
48 if !ok {
49 continue
50 }
51 for _, expression := range clause.List {
52 literal, ok := expression.(*ast.BasicLit)
53 if !ok || literal.Kind != token.STRING {
54 continue
55 }
56 command, err := strconv.Unquote(literal.Value)
57 if err != nil {
58 t.Fatal(err)
59 }
60 if !completionRegistryKnowsRootToken(&root, command) {
61 t.Errorf("Run dispatch command %q is missing from the shell completion registry", command)
62 }
63 }
64 }
65 }
66
67 func TestCLICompletionListsRootAndNestedCommands(t *testing.T) {
68 root := cliCompletionRootSpec()
69 values := func(cliCompletionValueKind) []string { return nil }
70
71 got := cliCompletionCandidatesWithValues(root, 1, []string{"reasonix", "co"}, values)
72 if want := []string{"config", "completion"}; !reflect.DeepEqual(got, want) {
73 t.Fatalf("root completion = %v, want %v", got, want)
74 }
75
76 got = cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "mcp", "b"}, values)
77 if want := []string{"browse"}; !reflect.DeepEqual(got, want) {
78 t.Fatalf("mcp subcommand completion = %v, want %v", got, want)
79 }
80
81 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "remote", "serve", "st"}, values)
82 // "st" prefix matches start, stop, and status (registry order).
83 if want := []string{"start", "stop", "status"}; !reflect.DeepEqual(got, want) {
84 t.Fatalf("remote serve completion = %v, want %v", got, want)
85 }
86 }
87
88 func TestCLICompletionListsCommandFlags(t *testing.T) {
89 root := cliCompletionRootSpec()
90 values := func(cliCompletionValueKind) []string { return nil }
91
92 got := cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "run", "--m"}, values)
93 for _, want := range []string{"--model", "--max-steps", "--metrics"} {
94 if !containsCompletionValue(got, want) {
95 t.Errorf("run flag completion missing %q: %v", want, got)
96 }
97 }
98
99 got = cliCompletionCandidatesWithValues(root, 1, []string{"reasonix", "--d"}, values)
100 if !containsCompletionValue(got, "--dir") {
101 t.Errorf("root flag completion missing %q: %v", "--dir", got)
102 }
103 got = cliCompletionCandidatesWithValues(root, 1, []string{"reasonix", "--p"}, values)
104 if !containsCompletionValue(got, "--permission-mode") {
105 t.Errorf("root flag completion missing %q: %v", "--permission-mode", got)
106 }
107
108 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "mcp", "add", "--h"}, values)
109 for _, want := range []string{"--http", "--header", "--help"} {
110 if !containsCompletionValue(got, want) {
111 t.Errorf("mcp add flag completion missing %q: %v", want, got)
112 }
113 }
114 }
115
116 func TestCLICompletionUsesConfiguredModelsAndSessionIDs(t *testing.T) {
117 root := cliCompletionRootSpec()
118 values := func(kind cliCompletionValueKind) []string {
119 switch kind {
120 case cliCompletionModelValue:
121 return []string{"deepseek/deepseek-chat", "mimo/mimo-v2"}
122 case cliCompletionSessionValue:
123 return []string{"alpha-session", "beta-session"}
124 default:
125 return nil
126 }
127 }
128
129 got := cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "run", "--model", "deep"}, values)
130 if want := []string{"deepseek/deepseek-chat"}; !reflect.DeepEqual(got, want) {
131 t.Fatalf("model completion = %v, want %v", got, want)
132 }
133
134 got = cliCompletionCandidatesWithValues(root, 1, []string{"reasonix", "--model=mi"}, values)
135 if want := []string{"--model=mimo/mimo-v2"}; !reflect.DeepEqual(got, want) {
136 t.Fatalf("inline model completion = %v, want %v", got, want)
137 }
138
139 got = cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "--resume", "b"}, values)
140 if want := []string{"beta-session"}; !reflect.DeepEqual(got, want) {
141 t.Fatalf("session completion = %v, want %v", got, want)
142 }
143 }
144
145 func TestCompletionCommandPrintsShellScripts(t *testing.T) {
146 isolateCLIConfigHome(t)
147 for _, shell := range []string{"bash", "zsh", "fish"} {
148 t.Run(shell, func(t *testing.T) {
149 out := captureStdout(t, func() {
150 if code := Run([]string{"completion", shell}, "test-version"); code != 0 {
151 t.Fatalf("completion %s exit code = %d", shell, code)
152 }
153 })
154 if !strings.Contains(out, "reasonix completion __complete") {
155 t.Fatalf("completion %s script does not route to the shared registry:\n%s", shell, out)
156 }
157 if shell == "fish" && strings.Contains(out, "complete -c reasonix -f ") {
158 t.Fatal("fish completion must not use -f so path flags can fall back to files")
159 }
160 })
161 }
162 }
163
164 func TestCLICompletionTaskNestedAndRunAblate(t *testing.T) {
165 root := cliCompletionRootSpec()
166 values := func(cliCompletionValueKind) []string { return nil }
167
168 got := cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "task", "st"}, values)
169 for _, want := range []string{"status", "stop"} {
170 if !containsCompletionValue(got, want) {
171 t.Fatalf("task prefix st missing %q: %v", want, got)
172 }
173 }
174 got = cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "task", "mon"}, values)
175 if !containsCompletionValue(got, "monitor") {
176 t.Fatalf("task monitor missing: %v", got)
177 }
178 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "monitor", "st"}, values)
179 if !containsCompletionValue(got, "status") || !containsCompletionValue(got, "stop") {
180 t.Fatalf("task monitor st = %v", got)
181 }
182 got = cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "run", "--a"}, values)
183 if !containsCompletionValue(got, "--ablate") {
184 t.Fatalf("run --a missing --ablate: %v", got)
185 }
186 }
187
188 func TestCLICompletionOptionalResumeThenFlag(t *testing.T) {
189 root := cliCompletionRootSpec()
190 values := func(kind cliCompletionValueKind) []string {
191 if kind == cliCompletionSessionValue {
192 return []string{"alpha-session"}
193 }
194 return nil
195 }
196 // Interactive root --resume [QUERY] is optional: after --resume, --m offers --model.
197 got := cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "--resume", "--m"}, values)
198 if !containsCompletionValue(got, "--model") {
199 t.Fatalf("optional --resume then --m = %v, want --model", got)
200 }
201 // Inline optional --resume=QUERY still completes sessions.
202 got = cliCompletionCandidatesWithValues(root, 1, []string{"reasonix", "--resume=a"}, values)
203 if !containsCompletionValue(got, "--resume=alpha-session") {
204 t.Fatalf("inline optional --resume= = %v, want --resume=alpha-session", got)
205 }
206 }
207
208 func TestCLICompletionRunServeResumeRequiresValue(t *testing.T) {
209 root := cliCompletionRootSpec()
210 values := func(kind cliCompletionValueKind) []string {
211 if kind == cliCompletionSessionValue {
212 return []string{"alpha-session", "beta-session"}
213 }
214 return nil
215 }
216 // run --resume is required: completing after --resume must offer sessions, not --model.
217 got := cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "run", "--resume", "--m"}, values)
218 if containsCompletionValue(got, "--model") {
219 t.Fatalf("run --resume must not treat next flag as free: %v", got)
220 }
221 // Prefix "--m" matches no configured session IDs.
222 if len(got) != 0 {
223 t.Fatalf("run --resume --m = %v, want empty (no session starts with --m)", got)
224 }
225 // Separated form with prefix "b" completes sessions.
226 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "run", "--resume", "b"}, values)
227 if want := []string{"beta-session"}; !reflect.DeepEqual(got, want) {
228 t.Fatalf("run --resume b = %v, want %v", got, want)
229 }
230 // serve --resume is a required file path: empty candidates for shell path fallback,
231 // never dynamic session branch IDs that fail open/loadResumableSession.
232 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "serve", "--resume", "a"}, values)
233 if len(got) != 0 {
234 t.Fatalf("serve --resume path value = %v, want empty for file fallback", got)
235 }
236 if containsCompletionValue(got, "alpha-session") {
237 t.Fatalf("serve --resume must not complete session IDs: %v", got)
238 }
239 // Inline required session form (run only).
240 got = cliCompletionCandidatesWithValues(root, 2, []string{"reasonix", "run", "--resume=b"}, values)
241 if want := []string{"--resume=beta-session"}; !reflect.DeepEqual(got, want) {
242 t.Fatalf("run --resume=b = %v, want %v", got, want)
243 }
244 }
245
246 func TestCLICompletionTaskPerOperationFlags(t *testing.T) {
247 root := cliCompletionRootSpec()
248 values := func(cliCompletionValueKind) []string { return nil }
249
250 // status must not advertise machine-only --project-root.
251 got := cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "status", "--p"}, values)
252 if containsCompletionValue(got, "--project-root") {
253 t.Fatalf("task status must not offer --project-root: %v", got)
254 }
255 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "status", "--j"}, values)
256 if !containsCompletionValue(got, "--json") {
257 t.Fatalf("task status missing --json: %v", got)
258 }
259
260 // events has --jsonl/--after/--follow.
261 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "events", "--"}, values)
262 for _, want := range []string{"--json", "--jsonl", "--after", "--follow", "--dir"} {
263 if !containsCompletionValue(got, want) {
264 t.Fatalf("task events missing %q: %v", want, got)
265 }
266 }
267 if containsCompletionValue(got, "--project-root") {
268 t.Fatalf("task events must not offer --project-root: %v", got)
269 }
270
271 // stop has control flags.
272 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "stop", "--"}, values)
273 for _, want := range []string{"--expected-version", "--reason", "--idempotency-key", "--json", "--dir"} {
274 if !containsCompletionValue(got, want) {
275 t.Fatalf("task stop missing %q: %v", want, got)
276 }
277 }
278
279 // machine list still has --project-root.
280 got = cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "task", "list", "--p"}, values)
281 if !containsCompletionValue(got, "--project-root") {
282 t.Fatalf("task list missing --project-root: %v", got)
283 }
284
285 // tmux attach has --session.
286 got = cliCompletionCandidatesWithValues(root, 4, []string{"reasonix", "task", "tmux", "attach", "--s"}, values)
287 if !containsCompletionValue(got, "--session") {
288 t.Fatalf("task tmux attach missing --session: %v", got)
289 }
290 }
291
292 func TestCLICompletionPathFlagReturnsEmptyForShellFallback(t *testing.T) {
293 root := cliCompletionRootSpec()
294 values := func(cliCompletionValueKind) []string { return []string{"should-not-appear"} }
295 got := cliCompletionCandidatesWithValues(root, 3, []string{"reasonix", "run", "--dir", "do"}, values)
296 if len(got) != 0 {
297 t.Fatalf("path flag value candidates = %v, want empty for shell file fallback", got)
298 }
299 }
300
301 func containsCompletionValue(values []string, target string) bool {
302 return slices.Contains(values, target)
303 }
304
305 func completionRegistryKnowsRootToken(root *cliCompletionSpec, token string) bool {
306 if strings.HasPrefix(token, "-") {
307 flag, _ := cliCompletionLookupFlag(root, token)
308 return flag != nil
309 }
310 return cliCompletionLookupSubcommand(root, token) != nil
311 }
312
312 lines GO