package app import ( "context" "errors" "io" "math" "reflect" "strconv" "strings" "testing" "time" "github.com/bnema/vev/internal/adapters/sessionwire" "github.com/bnema/vev/internal/ports" portsmocks "github.com/bnema/vev/internal/ports/mocks" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/protocol/wire" "github.com/bnema/vev/internal/usecase/daemon" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) // mustServerEnvelope serializes a semantic server message into the serialized // protobuf envelope exchanged by wire.Transport. func mustServerEnvelope(message protocol.ServerMessage) wire.Envelope { raw, err := sessionwire.EncodeServerMessage(message) if err != nil { panic(err) } return wire.Envelope{Payload: raw} } // mustClientEnvelope serializes a semantic client message into the serialized // protobuf envelope exchanged by wire.Transport. func mustClientEnvelope(message protocol.ClientMessage) wire.Envelope { raw, err := sessionwire.EncodeClientMessage(message) if err != nil { panic(err) } return wire.Envelope{Payload: raw} } // mustPreambleResponse builds the canned server preamble acceptance the // typed client connection expects before application traffic. func mustPreambleResponse() wire.Envelope { raw, err := sessionwire.EncodePreambleResponseForTest() if err != nil { panic(err) } return wire.Envelope{Payload: raw} } // decodeClientEnvelope unwraps a sent envelope into its semantic client // message, failing the test on a malformed payload. func decodeClientEnvelope(t *testing.T, envelope wire.Envelope) protocol.ClientMessage { t.Helper() message, err := sessionwire.DecodeClientEnvelope(envelope.Payload) require.NoError(t, err) return message } func TestParseCmdArgs(t *testing.T) { tests := []struct { name string args []string want cmdInvocation wantErr bool }{ {"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false}, {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false}, {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false}, {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, {"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true}, {"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false}, {"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true}, {"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true}, {"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true}, {"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := parseArgs(tt.args) if tt.wantErr { var usage *usageError if !errors.As(err, &usage) { t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err) } return } if err != nil { t.Fatalf("parseArgs(%q): %v", tt.args, err) } if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) { t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want) } }) } } func TestExitCodes(t *testing.T) { tests := []struct { name string err error want int }{ {"success", nil, 0}, {"command", errors.New("command failed"), 1}, {"usage", &usageError{msg: "bad args"}, 2}, {"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := ExitCode(tt.err); got != tt.want { t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want) } }) } } func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { called := false out := new(strings.Builder) err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{ stdout: out, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { called = true return nil, errors.New("must not dial") }, }) if err != nil { t.Fatalf("runCmdWithDeps(help): %v", err) } if called { t.Fatal("help dialed daemon") } 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"} { if !strings.Contains(out.String(), want) { t.Fatalf("help %q missing %q", out.String(), want) } } for _, hidden := range []string{"session-picker", "resize-pane"} { if strings.Contains(out.String(), hidden) { t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String()) } } } func TestMoveCommandHelpUsesExactUsages(t *testing.T) { for _, tt := range []struct { slug, usage, desc string }{ {"move-pane", "move-pane ", "Move focused pane to tab"}, {"move-tab", "move-tab ", "Move active tab to session"}, } { t.Run(tt.slug, func(t *testing.T) { got := cmdHelp(cmdInvocation{slug: tt.slug}) want := "usage: vev cmd [-s ] [--self] " + tt.usage + "\n\n" + tt.desc if got != want { t.Fatalf("cmdHelp() = %q, want %q", got, want) } }) } } func TestMoveCmdPreservesPositionalArguments(t *testing.T) { for _, tt := range []struct { slug string args []string }{ {slug: "move-pane", args: []string{"work", "t_dest"}}, {slug: "move-tab", args: []string{"work"}}, } { t.Run(tt.slug, func(t *testing.T) { parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...)) require.NoError(t, err) transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, })) require.Len(t, transport.sent, 2) message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) require.NoError(t, err) request := message.(protocol.CommandRequest) require.Equal(t, tt.slug, request.Slug) require.Equal(t, tt.args, request.Args) require.Equal(t, protocol.Version, request.Version) }) } } func TestRemoteCatalogCommandUsesBroker(t *testing.T) { stream := newSeamBrokerStream(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}) service := newSeamBrokerService(stream) err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, connect: func(context.Context) (ports.BrokerService, error) { return service, nil }, dial: func(context.Context, string) (wire.Transport, error) { t.Fatal("remote catalog bypassed the broker") return nil, errors.New("unexpected dial") }, }) require.NoError(t, err) require.Len(t, service.openedRequests(), 1) require.Equal(t, ports.BrokerStreamControl, service.openedRequests()[0].Purpose) } func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { for _, invocation := range []cmdInvocation{ {slug: "move-pane"}, {slug: "move-pane", args: []string{"work", "t_dest", "extra"}}, {slug: "move-tab"}, {slug: "move-tab", args: []string{"work", "extra"}}, } { t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) { transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{ Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "invalid command arguments", })} err := runCmdWithDeps(context.Background(), invocation, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) if ExitCode(err) != 2 { t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err)) } }) } } func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { for _, slug := range []string{ "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "consume-or-expel-pane-left", "consume-or-expel-pane-right", } { t.Run(slug, func(t *testing.T) { invocation, err := parseArgs([]string{"cmd", "--self", slug}) require.NoError(t, err) transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, })) require.Len(t, transport.sent, 2) message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) require.NoError(t, err) request := message.(protocol.CommandRequest) require.Equal(t, slug, request.Slug) require.True(t, request.Self) require.Equal(t, "work", request.TargetSession) require.Equal(t, "t_abc", request.TargetTab) require.Equal(t, "p_def", request.TargetPane) }) } } func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) { transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"})} out := new(strings.Builder) err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{ stdout: out, getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) if err != nil { t.Fatalf("runCmdWithDeps: %v", err) } if len(transport.sent) != 2 || transport.recvCalls != 2 || transport.closeCalls != 1 { t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls) } message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) if err != nil { t.Fatalf("decode request: %v", err) } req, ok := message.(protocol.CommandRequest) if !ok || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { t.Fatalf("request = %+v", req) } if out.String() != "done\n" { t.Fatalf("output = %q", out.String()) } } func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) { transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) if err != nil { t.Fatalf("runCmdWithDeps: %v", err) } message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) if err != nil { t.Fatalf("decode request: %v", err) } req := message.(protocol.CommandRequest) if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" { t.Fatalf("VEV locator request = %+v", req) } } func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) { transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})} err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) if err != nil { t.Fatalf("runCmdWithDeps: %v", err) } message, err := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) if err != nil { t.Fatalf("decode request: %v", err) } req := message.(protocol.CommandRequest) if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" { t.Fatalf("explicit targeting request = %+v", req) } } func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) { dialed := false err := runCmdWithDeps(context.Background(), cmdInvocation{ slug: "toast", args: make([]string, math.MaxUint16+1), }, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { dialed = true return nil, errors.New("must not dial") }, }) if ExitCode(err) != 2 { t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err)) } if dialed { t.Fatal("oversized command dialed daemon") } } func TestRunCmdTimesOutPendingRequest(t *testing.T) { timerCh := make(chan time.Time, 1) timer := portsmocks.NewMockTimer(t) timer.EXPECT().C().Return(timerCh).Once() timer.EXPECT().Stop().Return(true).Once() delayCh := make(chan time.Duration, 1) clock := portsmocks.NewMockClock(t) clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) { delayCh <- delay }).Return(timer).Once() recvStarted := make(chan struct{}) recvGate := make(chan struct{}) transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate} done := make(chan error, 1) go func() { done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, clock: clock, }) }() <-recvStarted if delay := <-delayCh; delay != daemon.CommandRequestTimeout { t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout) } timerCh <- time.Time{} err := <-done close(recvGate) if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) { t.Fatalf("timeout error=%v code=%d", err, ExitCode(err)) } message, decodeErr := sessionwire.DecodeClientEnvelope(transport.sent[1].Payload) if decodeErr != nil { t.Fatalf("decode=%v, want non-zero request ID", decodeErr) } request := message.(protocol.CommandRequest) if request.RequestID == 0 { t.Fatalf("request=%+v, want non-zero request ID", request) } } func TestRunCmdZeroRequestIDReplyIsRejected(t *testing.T) { const want = "unexpected command reply request ID 0" transport := &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{ Outcome: protocol.CommandFailed, Code: protocol.ErrInternal, Text: want, })} err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) // A correctly typed but uncorrelated reply is an indeterminate outcome, not a // definite daemon failure: the request was sent, so it is never replayed. if err == nil || !errors.Is(err, errCommandOutcomeUnknown) { t.Fatalf("error = %v, want an outcome-unknown rejection of reply request ID 0", err) } if !strings.Contains(err.Error(), want) { t.Fatalf("error = %v, want message containing %q", err, want) } if errors.Is(err, daemon.ErrCommandRequestTimeout) { t.Fatalf("error = %v, want daemon error instead of timeout", err) } } func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) { dials := 0 dialErr := errors.New("unreachable") err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { dials++ return nil, dialErr }, }) if ExitCode(err) != 3 || dials != 1 { t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials) } if !errors.Is(err, dialErr) { t.Fatalf("error=%v does not wrap dial error", err) } } func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) { tests := []struct { name string result protocol.CommandResult wantCode int }{ { name: "invalid command arguments are usage errors", result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: toast [-l level] "}, wantCode: 2, }, { name: "missing runtime target is a command failure", result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"}, wantCode: 1, }, { name: "resize not in split remains an exit one failure", result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane is not in a split"}, wantCode: 1, }, { name: "resize minimum remains an exit one failure", result: protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "pane cannot be resized further"}, wantCode: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)} err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{ stdout: io.Discard, getenv: func(string) string { return "" }, dial: func(context.Context, string) (wire.Transport, error) { return transport, nil }, }) if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text { t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode) } }) } } type cmdTestTransport struct { sent []wire.Envelope recv wire.Envelope recvErr error recvCalls int closeCalls int recvStarted chan struct{} replyStarted chan struct{} recvGate <-chan struct{} preserveReplyID bool } func (t *cmdTestTransport) Send(frame wire.Envelope) error { t.sent = append(t.sent, frame) return nil } // Recv serves the server preamble response on the first call, then the // queued reply: typed client connections run the preamble lazily. func (t *cmdTestTransport) Recv() (wire.Envelope, error) { t.recvCalls++ if t.recvStarted != nil { select { case <-t.recvStarted: default: close(t.recvStarted) } } if t.recvCalls == 1 { return mustPreambleResponse(), nil } if t.replyStarted != nil { select { case <-t.replyStarted: default: close(t.replyStarted) } } if t.recvGate != nil { <-t.recvGate } if t.recvErr == nil && !t.preserveReplyID { message, err := sessionwire.DecodeServerEnvelope(t.recv.Payload) if err == nil { if result, ok := message.(protocol.CommandResult); ok && result.RequestID == 0 { requestMessage, decodeErr := sessionwire.DecodeClientEnvelope(t.sent[1].Payload) if decodeErr == nil { result.RequestID = requestMessage.(protocol.CommandRequest).RequestID t.recv = mustServerEnvelope(result) } } } } return t.recv, t.recvErr } func (t *cmdTestTransport) Close() error { t.closeCalls++ return nil }