1package app
2
3import (
4 "context"
5 "errors"
6 "io"
7 "math"
8 "reflect"
9 "strconv"
10 "strings"
11 "testing"
12
13 "github.com/bnema/vev/internal/ports"
14 "github.com/stretchr/testify/require"
15)
16
17func TestParseCmdArgs(t *testing.T) {
18 tests := []struct {
19 name string
20 args []string
21 want cmdInvocation
22 wantErr bool
23 }{
24 {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
25 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
26 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
27 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
28 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
29 {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
30 {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
31 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
32 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
33 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
34 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
35 {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
36 {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
37 {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
38 {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
39 {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
40 {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
41 }
42 for _, tt := range tests {
43 t.Run(tt.name, func(t *testing.T) {
44 got, err := parseArgs(tt.args)
45 if tt.wantErr {
46 var usage *usageError
47 if !errors.As(err, &usage) {
48 t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
49 }
50 return
51 }
52 if err != nil {
53 t.Fatalf("parseArgs(%q): %v", tt.args, err)
54 }
55 if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
56 t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
57 }
58 })
59 }
60}
61
62func TestExitCodes(t *testing.T) {
63 tests := []struct {
64 name string
65 err error
66 want int
67 }{
68 {"success", nil, 0},
69 {"command", errors.New("command failed"), 1},
70 {"usage", &usageError{msg: "bad args"}, 2},
71 {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
72 }
73 for _, tt := range tests {
74 t.Run(tt.name, func(t *testing.T) {
75 if got := ExitCode(tt.err); got != tt.want {
76 t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
77 }
78 })
79 }
80}
81
82func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
83 called := false
84 out := new(strings.Builder)
85 err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
86 stdout: out,
87 getenv: func(string) string { return "" },
88 dial: func(context.Context, string) (ports.Transport, error) {
89 called = true
90 return nil, errors.New("must not dial")
91 },
92 })
93 if err != nil {
94 t.Fatalf("runCmdWithDeps(help): %v", err)
95 }
96 if called {
97 t.Fatal("help dialed daemon")
98 }
99 for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
100 if !strings.Contains(out.String(), want) {
101 t.Fatalf("help %q missing %q", out.String(), want)
102 }
103 }
104 for _, hidden := range []string{"session-picker", "resize-pane"} {
105 if strings.Contains(out.String(), hidden) {
106 t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
107 }
108 }
109}
110
111func TestMoveCommandHelpUsesExactUsages(t *testing.T) {
112 for _, tt := range []struct {
113 slug, usage, desc string
114 }{
115 {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move the focused pane to another live tab"},
116 {"move-tab", "move-tab <destination-session>", "Move the active tab to another live session"},
117 } {
118 t.Run(tt.slug, func(t *testing.T) {
119 got := cmdHelp(cmdInvocation{slug: tt.slug})
120 want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc
121 if got != want {
122 t.Fatalf("cmdHelp() = %q, want %q", got, want)
123 }
124 })
125 }
126}
127
128func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
129 for _, tt := range []struct {
130 slug string
131 args []string
132 }{
133 {slug: "move-pane", args: []string{"work", "t_dest"}},
134 {slug: "move-tab", args: []string{"work"}},
135 } {
136 t.Run(tt.slug, func(t *testing.T) {
137 parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
138 require.NoError(t, err)
139 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
140 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
141 stdout: io.Discard,
142 getenv: func(string) string { return "" },
143 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
144 }))
145 require.Len(t, transport.sent, 1)
146 request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
147 require.NoError(t, err)
148 require.Equal(t, tt.slug, request.Slug)
149 require.Equal(t, tt.args, request.Args)
150 require.Equal(t, ports.ProtocolVersion, request.Version)
151 })
152 }
153}
154
155func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
156 for _, invocation := range []cmdInvocation{
157 {slug: "move-pane"},
158 {slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
159 {slug: "move-tab"},
160 {slug: "move-tab", args: []string{"work", "extra"}},
161 } {
162 t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
163 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
164 Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",
165 })}}
166 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
167 stdout: io.Discard,
168 getenv: func(string) string { return "" },
169 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
170 })
171 if ExitCode(err) != 2 {
172 t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
173 }
174 })
175 }
176}
177
178func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
179 for _, slug := range []string{
180 "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
181 "consume-or-expel-pane-left", "consume-or-expel-pane-right",
182 } {
183 t.Run(slug, func(t *testing.T) {
184 invocation, err := parseArgs([]string{"cmd", "--self", slug})
185 require.NoError(t, err)
186 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
187 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
188 stdout: io.Discard,
189 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
190 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
191 }))
192 require.Len(t, transport.sent, 1)
193 request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
194 require.NoError(t, err)
195 require.Equal(t, slug, request.Slug)
196 require.True(t, request.Self)
197 require.Equal(t, "work", request.TargetSession)
198 require.Equal(t, "t_abc", request.TargetTab)
199 require.Equal(t, "p_def", request.TargetPane)
200 })
201 }
202}
203
204func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
205 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
206 out := new(strings.Builder)
207 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
208 stdout: out,
209 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
210 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
211 })
212 if err != nil {
213 t.Fatalf("runCmdWithDeps: %v", err)
214 }
215 if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
216 t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
217 }
218 req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
219 if err != nil {
220 t.Fatalf("decode request: %v", err)
221 }
222 if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
223 t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
224 }
225 if out.String() != "done\n" {
226 t.Fatalf("output = %q", out.String())
227 }
228}
229
230func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
231 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
232 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
233 stdout: io.Discard,
234 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
235 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
236 })
237 if err != nil {
238 t.Fatalf("runCmdWithDeps: %v", err)
239 }
240 req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
241 if err != nil {
242 t.Fatalf("decode request: %v", err)
243 }
244 if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
245 t.Fatalf("VEV locator request = %+v", req)
246 }
247}
248
249func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
250 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
251 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
252 stdout: io.Discard,
253 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
254 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
255 })
256 if err != nil {
257 t.Fatalf("runCmdWithDeps: %v", err)
258 }
259 req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
260 if err != nil {
261 t.Fatalf("decode request: %v", err)
262 }
263 if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
264 t.Fatalf("explicit targeting request = %+v", req)
265 }
266}
267
268func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
269 dialed := false
270 err := runCmdWithDeps(context.Background(), cmdInvocation{
271 slug: "toast",
272 args: make([]string, math.MaxUint16+1),
273 }, cmdDeps{
274 stdout: io.Discard,
275 getenv: func(string) string { return "" },
276 dial: func(context.Context, string) (ports.Transport, error) {
277 dialed = true
278 return nil, errors.New("must not dial")
279 },
280 })
281
282 if ExitCode(err) != 2 {
283 t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
284 }
285 if dialed {
286 t.Fatal("oversized command dialed daemon")
287 }
288}
289
290func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
291 dials := 0
292 dialErr := errors.New("unreachable")
293 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
294 stdout: io.Discard,
295 getenv: func(string) string { return "" },
296 dial: func(context.Context, string) (ports.Transport, error) {
297 dials++
298 return nil, dialErr
299 },
300 })
301 if ExitCode(err) != 3 || dials != 1 {
302 t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
303 }
304 if !errors.Is(err, dialErr) {
305 t.Fatalf("error=%v does not wrap dial error", err)
306 }
307}
308
309func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
310 tests := []struct {
311 name string
312 result ports.CommandResult
313 wantCode int
314 }{
315 {
316 name: "invalid command arguments are usage errors",
317 result: ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
318 wantCode: 2,
319 },
320 {
321 name: "missing runtime target is a command failure",
322 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
323 wantCode: 1,
324 },
325 {
326 name: "resize not in split remains an exit one failure",
327 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"},
328 wantCode: 1,
329 },
330 {
331 name: "resize minimum remains an exit one failure",
332 result: ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"},
333 wantCode: 1,
334 },
335 }
336 for _, tt := range tests {
337 t.Run(tt.name, func(t *testing.T) {
338 transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}}
339 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
340 stdout: io.Discard,
341 getenv: func(string) string { return "" },
342 dial: func(context.Context, string) (ports.Transport, error) { return transport, nil },
343 })
344 if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
345 t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
346 }
347 })
348 }
349}
350
351type cmdTestTransport struct {
352 sent []ports.Frame
353 recv ports.Frame
354 recvErr error
355 recvCalls int
356 closeCalls int
357}
358
359func (t *cmdTestTransport) Send(frame ports.Frame) error {
360 t.sent = append(t.sent, frame)
361 return nil
362}
363
364func (t *cmdTestTransport) Recv() (ports.Frame, error) {
365 t.recvCalls++
366 return t.recv, t.recvErr
367}
368
369func (t *cmdTestTransport) Close() error {
370 t.closeCalls++
371 return nil
372}