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/adapters/sessionwire"
15 "github.com/bnema/vev/internal/ports"
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/wire"
19 "github.com/bnema/vev/internal/usecase/daemon"
20 "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"
22)
23
24// mustServerEnvelope serializes a semantic server message into the serialized
25// protobuf envelope exchanged by wire.Transport.
26func mustServerEnvelope(message protocol.ServerMessage) wire.Envelope {
27 raw, err := sessionwire.EncodeServerMessage(message)
28 if err != nil {
29 panic(err)
30 }
31 return wire.Envelope{Payload: raw}
32}
33
34// mustClientEnvelope serializes a semantic client message into the serialized
35// protobuf envelope exchanged by wire.Transport.
36func mustClientEnvelope(message protocol.ClientMessage) wire.Envelope {
37 raw, err := sessionwire.EncodeClientMessage(message)
38 if err != nil {
39 panic(err)
40 }
41 return wire.Envelope{Payload: raw}
42}
43
44// mustPreambleResponse builds the canned server preamble acceptance the
45// typed client connection expects before application traffic.
46func mustPreambleResponse() wire.Envelope {
47 raw, err := sessionwire.EncodePreambleResponseForTest()
48 if err != nil {
49 panic(err)
50 }
51 return wire.Envelope{Payload: raw}
52}
53
54// decodeClientEnvelope unwraps a sent envelope into its semantic client
55// message, failing the test on a malformed payload.
56func decodeClientEnvelope(t *testing.T, envelope wire.Envelope) protocol.ClientMessage {
57 t.Helper()
58 message, err := sessionwire.DecodeClientEnvelope(envelope.Payload)
59 require.NoError(t, err)
60 return message
61}
62
63func TestParseCmdArgs(t *testing.T) {
64 tests := []struct {
65 name string
66 args []string
67 want cmdInvocation
68 wantErr bool
69 }{
70 {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
71 {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
72 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
73 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
74 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
75 {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
76 {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
77 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
78 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
79 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
80 {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
81 {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
82 {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
83 {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
84 {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
85 {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
86 {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
87 }
88 for _, tt := range tests {
89 t.Run(tt.name, func(t *testing.T) {
90 got, err := parseArgs(tt.args)
91 if tt.wantErr {
92 var usage *usageError
93 if !errors.As(err, &usage) {
94 t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
95 }
96 return
97 }
98 if err != nil {
99 t.Fatalf("parseArgs(%q): %v", tt.args, err)
100 }
101 if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
102 t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
103 }
104 })
105 }
106}
107
108func TestExitCodes(t *testing.T) {
109 tests := []struct {
110 name string
111 err error
112 want int
113 }{
114 {"success", nil, 0},
115 {"command", errors.New("command failed"), 1},
116 {"usage", &usageError{msg: "bad args"}, 2},
117 {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
118 }
119 for _, tt := range tests {
120 t.Run(tt.name, func(t *testing.T) {
121 if got := ExitCode(tt.err); got != tt.want {
122 t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
123 }
124 })
125 }
126}
127
128func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
129 called := false
130 out := new(strings.Builder)
131 err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
132 stdout: out,
133 getenv: func(string) string { return "" },
134 dial: func(context.Context, string) (wire.Transport, error) {
135 called = true
136 return nil, errors.New("must not dial")
137 },
138 })
139 if err != nil {
140 t.Fatalf("runCmdWithDeps(help): %v", err)
141 }
142 if called {
143 t.Fatal("help dialed daemon")
144 }
145 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"} {
146 if !strings.Contains(out.String(), want) {
147 t.Fatalf("help %q missing %q", out.String(), want)
148 }
149 }
150 for _, hidden := range []string{"session-picker", "resize-pane"} {
151 if strings.Contains(out.String(), hidden) {
152 t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
153 }
154 }
155}
156
157func TestMoveCommandHelpUsesExactUsages(t *testing.T) {
158 for _, tt := range []struct {
159 slug, usage, desc string
160 }{
161 {"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move focused pane to tab"},
162 {"move-tab", "move-tab <destination-session>", "Move active tab to session"},
163 } {
164 t.Run(tt.slug, func(t *testing.T) {
165 got := cmdHelp(cmdInvocation{slug: tt.slug})
166 want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc
167 if got != want {
168 t.Fatalf("cmdHelp() = %q, want %q", got, want)
169 }
170 })
171 }
172}
173
174func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
175 for _, tt := range []struct {
176 slug string
177 args []string
178 }{
179 {slug: "move-pane", args: []string{"work", "t_dest"}},
180 {slug: "move-tab", args: []string{"work"}},
181 } {
182 t.Run(tt.slug, func(t *testing.T) {
183 parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
184 require.NoError(t, err)
185 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
186 require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
187 stdout: io.Discard,
188 getenv: func(string) string { return "" },
189 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
190 }))
191 require.Len(t, transport.sent, 2)
192 message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
193 require.NoError(t, err)
194 request := message.(protocol.CommandRequest)
195 require.Equal(t, tt.slug, request.Slug)
196 require.Equal(t, tt.args, request.Args)
197 require.Equal(t, protocol.Version, request.Version)
198 })
199 }
200}
201
202func TestRemoteCatalogCommandUsesBroker(t *testing.T) {
203 stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded})
204 service := newSeamBrokerService(stream)
205 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
206 stdout: io.Discard,
207 getenv: func(string) string { return "" },
208 connect: func(context.Context) (ports.BrokerService, error) { return service, nil },
209 dial: func(context.Context, string) (wire.Transport, error) {
210 t.Fatal("remote catalog bypassed the broker")
211 return nil, errors.New("unexpected dial")
212 },
213 })
214 require.NoError(t, err)
215 require.Len(t, service.openedRequests(), 1)
216 require.Equal(t, ports.BrokerStreamControl, service.openedRequests()[0].Purpose)
217}
218
219func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
220 for _, invocation := range []cmdInvocation{
221 {slug: "move-pane"},
222 {slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
223 {slug: "move-tab"},
224 {slug: "move-tab", args: []string{"work", "extra"}},
225 } {
226 t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
227 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
228 Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments",
229 })}
230 err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
231 stdout: io.Discard,
232 getenv: func(string) string { return "" },
233 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
234 })
235 if ExitCode(err) != 2 {
236 t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
237 }
238 })
239 }
240}
241
242func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
243 for _, slug := range []string{
244 "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
245 "consume-or-expel-pane-left", "consume-or-expel-pane-right",
246 } {
247 t.Run(slug, func(t *testing.T) {
248 invocation, err := parseArgs([]string{"cmd", "--self", slug})
249 require.NoError(t, err)
250 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
251 require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
252 stdout: io.Discard,
253 getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
254 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
255 }))
256 require.Len(t, transport.sent, 2)
257 message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
258 require.NoError(t, err)
259 request := message.(protocol.CommandRequest)
260 require.Equal(t, slug, request.Slug)
261 require.True(t, request.Self)
262 require.Equal(t, "work", request.TargetSession)
263 require.Equal(t, "t_abc", request.TargetTab)
264 require.Equal(t, "p_def", request.TargetPane)
265 })
266 }
267}
268
269func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
270 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})}
271 out := new(strings.Builder)
272 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
273 stdout: out,
274 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
275 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
276 })
277 if err != nil {
278 t.Fatalf("runCmdWithDeps: %v", err)
279 }
280 if len(transport.sent) != 2 || transport.recvCalls != 2 || transport.closeCalls != 1 {
281 t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
282 }
283 message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
284 if err != nil {
285 t.Fatalf("decode request: %v", err)
286 }
287 req, ok := message.(protocol.CommandRequest)
288 if !ok || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
289 t.Fatalf("request = %+v", req)
290 }
291 if out.String() != "done\n" {
292 t.Fatalf("output = %q", out.String())
293 }
294}
295
296func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
297 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
298 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
299 stdout: io.Discard,
300 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
301 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
302 })
303 if err != nil {
304 t.Fatalf("runCmdWithDeps: %v", err)
305 }
306 message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
307 if err != nil {
308 t.Fatalf("decode request: %v", err)
309 }
310 req := message.(protocol.CommandRequest)
311 if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
312 t.Fatalf("VEV locator request = %+v", req)
313 }
314}
315
316func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
317 transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
318 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
319 stdout: io.Discard,
320 getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
321 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
322 })
323 if err != nil {
324 t.Fatalf("runCmdWithDeps: %v", err)
325 }
326 message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
327 if err != nil {
328 t.Fatalf("decode request: %v", err)
329 }
330 req := message.(protocol.CommandRequest)
331 if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
332 t.Fatalf("explicit targeting request = %+v", req)
333 }
334}
335
336func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
337 dialed := false
338 err := runCmdWithDeps(context.Background(), cmdInvocation{
339 slug: "toast",
340 args: make([]string, math.MaxUint16+1),
341 }, cmdDeps{
342 stdout: io.Discard,
343 getenv: func(string) string { return "" },
344 dial: func(context.Context, string) (wire.Transport, error) {
345 dialed = true
346 return nil, errors.New("must not dial")
347 },
348 })
349
350 if ExitCode(err) != 2 {
351 t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
352 }
353 if dialed {
354 t.Fatal("oversized command dialed daemon")
355 }
356}
357
358func TestRunCmdTimesOutPendingRequest(t *testing.T) {
359 timerCh := make(chan time.Time, 1)
360 timer := portsmocks.NewMockTimer(t)
361 timer.EXPECT().C().Return(timerCh).Once()
362 timer.EXPECT().Stop().Return(true).Once()
363 delayCh := make(chan time.Duration, 1)
364 clock := portsmocks.NewMockClock(t)
365 clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
366 delayCh <- delay
367 }).Return(timer).Once()
368 recvStarted := make(chan struct{})
369 recvGate := make(chan struct{})
370 transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
371 done := make(chan error, 1)
372 go func() {
373 done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
374 stdout: io.Discard,
375 getenv: func(string) string { return "" },
376 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
377 clock: clock,
378 })
379 }()
380 <-recvStarted
381 if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
382 t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
383 }
384 timerCh <- time.Time{}
385 err := <-done
386 close(recvGate)
387 if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
388 t.Fatalf("timeout error=%v code=%d", err, ExitCode(err))
389 }
390 message, decodeErr := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload)
391 if decodeErr != nil {
392 t.Fatalf("decode=%v, want non-zero request ID", decodeErr)
393 }
394 request := message.(protocol.CommandRequest)
395 if request.RequestID == 0 {
396 t.Fatalf("request=%+v, want non-zero request ID", request)
397 }
398}
399
400func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) {
401 const want = "unexpected command reply request ID 0"
402 transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
403 Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want,
404 })}
405 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
406 stdout: io.Discard,
407 getenv: func(string) string { return "" },
408 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
409 })
410 // A correctly typed but uncorrelated reply is an indeterminate outcome, not a
411 // definite daemon failure: the request was sent, so it is never replayed.
412 if err == nil || !errors.Is(err, errCommandOutcomeUnknown) {
413 t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err)
414 }
415 if !strings.Contains(err.Error(), want) {
416 t.Fatalf("error = %v, want message containing %q", err, want)
417 }
418 if errors.Is(err, daemon.ErrCommandRequestTimeout) {
419 t.Fatalf("error = %v, want daemon error instead of timeout", err)
420 }
421}
422
423func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
424 dials := 0
425 dialErr := errors.New("unreachable")
426 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
427 stdout: io.Discard,
428 getenv: func(string) string { return "" },
429 dial: func(context.Context, string) (wire.Transport, error) {
430 dials++
431 return nil, dialErr
432 },
433 })
434 if ExitCode(err) != 3 || dials != 1 {
435 t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
436 }
437 if !errors.Is(err, dialErr) {
438 t.Fatalf("error=%v does not wrap dial error", err)
439 }
440}
441
442func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
443 tests := []struct {
444 name string
445 result protocol.CommandResult
446 wantCode int
447 }{
448 {
449 name: "invalid command arguments are usage errors",
450 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
451 wantCode: 2,
452 },
453 {
454 name: "missing runtime target is a command failure",
455 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
456 wantCode: 1,
457 },
458 {
459 name: "resize not in split remains an exit one failure",
460 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"},
461 wantCode: 1,
462 },
463 {
464 name: "resize minimum remains an exit one failure",
465 result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"},
466 wantCode: 1,
467 },
468 }
469 for _, tt := range tests {
470 t.Run(tt.name, func(t *testing.T) {
471 transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
472 err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
473 stdout: io.Discard,
474 getenv: func(string) string { return "" },
475 dial: func(context.Context, string) (wire.Transport, error) { return transport, nil },
476 })
477 if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
478 t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
479 }
480 })
481 }
482}
483
484type cmdTestTransport struct {
485 sent []wire.Envelope
486 recv wire.Envelope
487 recvErr error
488 recvCalls int
489 closeCalls int
490 recvStarted chan struct{}
491 replyStarted chan struct{}
492 recvGate <-chan struct{}
493 preserveReplyID bool
494}
495
496func (t *cmdTestTransport) Send(frame wire.Envelope) error {
497 t.sent = append(t.sent, frame)
498 return nil
499}
500
501// Recv serves the server preamble response on the first call, then the
502// queued reply: typed client connections run the preamble lazily.
503func (t *cmdTestTransport) Recv() (wire.Envelope, error) {
504 t.recvCalls++
505 if t.recvStarted != nil {
506 select {
507 case <-t.recvStarted:
508 default:
509 close(t.recvStarted)
510 }
511 }
512 if t.recvCalls == 1 {
513 return mustPreambleResponse(), nil
514 }
515 if t.replyStarted != nil {
516 select {
517 case <-t.replyStarted:
518 default:
519 close(t.replyStarted)
520 }
521 }
522 if t.recvGate != nil {
523 <-t.recvGate
524 }
525 if t.recvErr == nil && !t.preserveReplyID {
526 message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload)
527 if err == nil {
528 if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 {
529 requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload)
530 if decodeErr == nil {
531 result.RequestID = requestMessage.(protocol.CommandRequest).RequestID
532 t.recv = mustServerEnvelope(result)
533 }
534 }
535 }
536 }
537 return t.recv, t.recvErr
538}
539
540func (t *cmdTestTransport) Close() error {
541 t.closeCalls++
542 return nil
543}