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