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