main ece7682

cmd_outcome_test.go RawHistoryFolder

1package app
2
3// CommandResult caller contract (explicit command-result slice). runCmd sends
4// exactly one CommandRequest with a unique nonzero RequestID and consumes
5// exactly one correlated CommandResult: a lost, wrong-typed, uncorrelated, or
6// malformed reply, or a deadline/cancellation after the send, is a typed
7// outcome-unknown that is never replayed and whose send count stays one. A
8// caller canceled before the send is a definite not-sent outcome, and a
9// definite daemon failure keeps its bounded error and exit code. Every case is
10// bounded and uses fake clocks or gates, never sleeps.
11
12import (
13	"context"
14	"io"
15	"strings"
16	"testing"
17	"time"
18
19	"github.com/stretchr/testify/mock"
20	"github.com/stretchr/testify/require"
21	"google.golang.org/protobuf/proto"
22
23	"github.com/bnema/vev/internal/adapters/sessionwire"
24	"github.com/bnema/vev/internal/ports"
25	portsmocks "github.com/bnema/vev/internal/ports/mocks"
26	"github.com/bnema/vev/internal/protocol"
27	"github.com/bnema/vev/internal/protocol/wire"
28	"github.com/bnema/vev/internal/usecase/daemon"
29)
30
31// countCommandRequests reports how many CommandRequests the transport placed on
32// the wire: exactly one is the no-replay invariant.
33func countCommandRequests(t *testing.T, transport *cmdTestTransport) int {
34	t.Helper()
35	count := 0
36	for _, frame := range transport.sent {
37		message, err := sessionwire.DecodeClientEnvelope(frame.Payload)
38		if err != nil {
39			continue
40		}
41		if _, ok := message.(protocol.CommandRequest); ok {
42			count++
43		}
44	}
45	return count
46}
47
48// runCmdReply drives one runCmdWithDeps invocation against a scripted transport.
49func runCmdReply(ctx context.Context, transport *cmdTestTransport, clock ports.Clock) error {
50	return runCmdWithDeps(ctx, cmdInvocation{slug: "split-right"}, cmdDeps{
51		stdout: io.Discard,
52		getenv: func(string) string { return "" },
53		dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
54		clock:  clock,
55	})
56}
57
58// rawServerEnvelope wraps already-serialized server envelope bytes without
59// re-encoding them. Fixtures that model hostile or corrupt frames use it so the
60// now-correct strict semantic encoder can never reject the fixture first.
61func rawServerEnvelope(raw []byte) wire.Envelope { return wire.Envelope{Payload: raw} }
62
63// malformedCommandOutcomeEnvelope marshals a server CommandResult whose
64// outcome/code combination the semantic contract forbids (a succeeded outcome
65// with a nonzero code). proto.Marshal deliberately bypasses
66// sessionwire.EncodeServerMessage, which refuses the semantic value outright.
67func malformedCommandOutcomeEnvelope(t *testing.T) wire.Envelope {
68	t.Helper()
69	raw, err := proto.Marshal(&wire.ServerEnvelope{Payload: &wire.ServerEnvelope_CommandResult{CommandResult: &wire.CommandResult{
70		RequestId: 1,
71		Outcome:   wire.CommandOutcome_COMMAND_OUTCOME_SUCCEEDED,
72		Code:      uint32(protocol.ErrInternal),
73	}}})
74	require.NoError(t, err)
75	// The semantic encoder must refuse the same value, which proves this fixture
76	// really is a hand-built frame rather than a re-encoded semantic message.
77	_, encodeErr := sessionwire.EncodeServerMessage(protocol.CommandResult{
78		RequestID: 1, Outcome: protocol.CommandSucceeded, Code: protocol.ErrInternal,
79	})
80	require.Error(t, encodeErr, "a forbidden outcome must not be encodable")
81	return rawServerEnvelope(raw)
82}
83
84// TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown proves a reply
85// that is lost, wrong-typed, uncorrelated, or malformed leaves a typed
86// outcome-unknown: the request was sent, so it is never resent, and the
87// one-shot connection is retired exactly once.
88func TestRunCmdLostUncorrelatedOrWrongTypedReplyIsOutcomeUnknown(t *testing.T) {
89	tests := []struct {
90		name        string
91		transport   *cmdTestTransport
92		wantContain string
93	}{
94		{
95			name:        "reply lost with EOF",
96			transport:   &cmdTestTransport{recvErr: io.EOF},
97			wantContain: "EOF",
98		},
99		{
100			name:        "reply lost with a closed pipe",
101			transport:   &cmdTestTransport{recvErr: io.ErrClosedPipe},
102			wantContain: "closed pipe",
103		},
104		{
105			name:        "wrong reply type",
106			transport:   &cmdTestTransport{recv: mustServerEnvelope(protocol.Pong{})},
107			wantContain: "unexpected command reply protocol.Pong",
108		},
109		{
110			name: "wrong reply request ID",
111			transport: &cmdTestTransport{preserveReplyID: true, recv: mustServerEnvelope(protocol.CommandResult{
112				RequestID: 99, Outcome: protocol.CommandSucceeded,
113			})},
114			wantContain: "unexpected command reply request ID 99",
115		},
116		{
117			name:        "malformed outcome bytes are refused",
118			transport:   &cmdTestTransport{recv: malformedCommandOutcomeEnvelope(t)},
119			wantContain: "wire value out of semantic range",
120		},
121		{
122			name: "truncated envelope bytes are refused",
123			transport: &cmdTestTransport{recv: rawServerEnvelope(
124				mustServerEnvelope(protocol.CommandResult{RequestID: 1, Outcome: protocol.CommandSucceeded}).Payload[:3])},
125			wantContain: "wire: envelope is truncated",
126		},
127		{
128			name: "definite daemon unknown outcome",
129			transport: &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{
130				Outcome: protocol.CommandOutcomeUnknown, Text: "daemon is shutting down",
131			})},
132			wantContain: "daemon is shutting down",
133		},
134	}
135	for _, tt := range tests {
136		t.Run(tt.name, func(t *testing.T) {
137			err := runCmdReply(context.Background(), tt.transport, nil)
138
139			require.ErrorIs(t, err, errCommandOutcomeUnknown, "a lost or uncorrelated reply is outcome-unknown")
140			require.NotErrorIs(t, err, errCommandNotSent, "the request was sent, so the outcome is not a definite not-sent")
141			require.ErrorContains(t, err, tt.wantContain)
142			require.Equal(t, 3, ExitCode(err))
143			require.Equal(t, 1, countCommandRequests(t, tt.transport), "an indeterminate command must never be resent")
144			require.Equal(t, 1, tt.transport.closeCalls, "the one-shot connection is retired exactly once")
145		})
146	}
147}
148
149// TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend proves a caller
150// canceled while awaiting the reply reports outcome-unknown, sends exactly one
151// request, and retires the connection.
152func TestRunCmdCancelAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
153	replyStarted := make(chan struct{})
154	recvGate := make(chan struct{})
155	transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
156
157	ctx, cancel := context.WithCancel(context.Background())
158	defer cancel()
159	done := make(chan error, 1)
160	go func() { done <- runCmdReply(ctx, transport, nil) }()
161
162	// Wait until the reply read is actually parked, then cancel. The gate makes
163	// entry deterministic without sleeping.
164	<-replyStarted
165	cancel()
166	err := <-done
167	close(recvGate)
168
169	require.ErrorIs(t, err, errCommandOutcomeUnknown)
170	require.Equal(t, 3, ExitCode(err))
171	require.Equal(t, 1, countCommandRequests(t, transport), "a canceled command must never be resent")
172	require.Equal(t, 1, transport.closeCalls, "cancellation after the send must retire the connection")
173}
174
175// TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend proves a request
176// whose result deadline expires while awaiting the reply reports
177// outcome-unknown, sends exactly one request, and retires the connection. The
178// fake clock keeps the deadline deterministic.
179func TestRunCmdTimeoutAfterSendIsOutcomeUnknownWithoutResend(t *testing.T) {
180	timerCh := make(chan time.Time, 1)
181	timer := portsmocks.NewMockTimer(t)
182	timer.EXPECT().C().Return(timerCh).Once()
183	timer.EXPECT().Stop().Return(true).Once()
184	delayCh := make(chan time.Duration, 1)
185	clock := portsmocks.NewMockClock(t)
186	clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
187		delayCh <- delay
188	}).Return(timer).Once()
189
190	replyStarted := make(chan struct{})
191	recvGate := make(chan struct{})
192	transport := &cmdTestTransport{replyStarted: replyStarted, recvGate: recvGate}
193	done := make(chan error, 1)
194	go func() { done <- runCmdReply(context.Background(), transport, clock) }()
195
196	<-replyStarted
197	require.Equal(t, daemon.CommandRequestTimeout, <-delayCh)
198	timerCh <- time.Time{}
199	err := <-done
200	close(recvGate)
201
202	require.ErrorIs(t, err, errCommandOutcomeUnknown)
203	require.ErrorContains(t, err, daemon.ErrCommandRequestTimeout.Error())
204	require.Equal(t, 3, ExitCode(err))
205	require.Equal(t, 1, countCommandRequests(t, transport), "a timed-out command must never be resent")
206	require.Equal(t, 1, transport.closeCalls, "a result timeout must retire the connection")
207}
208
209// TestRunCmdCancelBeforeSendIsDefiniteNotSent proves a caller already canceled
210// before the request is sent reports a definite not-sent outcome, puts no
211// CommandRequest on the wire, and is not misclassified as unknown.
212func TestRunCmdCancelBeforeSendIsDefiniteNotSent(t *testing.T) {
213	transport := &cmdTestTransport{recv: mustServerEnvelope(protocol.CommandResult{Outcome: protocol.CommandSucceeded})}
214
215	ctx, cancel := context.WithCancel(context.Background())
216	cancel()
217	err := runCmdReply(ctx, transport, nil)
218
219	require.ErrorIs(t, err, errCommandNotSent)
220	require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a not-sent command has a definite outcome")
221	require.Equal(t, 0, countCommandRequests(t, transport), "a canceled-before-send command must not reach the wire")
222}
223
224// TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError proves a
225// definite daemon outcome is reported with its exact text and exit code, and
226// still sends exactly one request.
227func TestRunCmdSuccessAndFailurePreserveCorrelationAndBoundedError(t *testing.T) {
228	tests := []struct {
229		name     string
230		result   protocol.CommandResult
231		wantCode int
232	}{
233		{
234			name:     "success",
235			result:   protocol.CommandResult{Outcome: protocol.CommandSucceeded, Output: "done"},
236			wantCode: 0,
237		},
238		{
239			name:     "invalid arguments are a usage error",
240			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrInvalidCommandArgs, Text: "usage: split-right"},
241			wantCode: 2,
242		},
243		{
244			name:     "runtime failure is an exit one failure",
245			result:   protocol.CommandResult{Outcome: protocol.CommandFailed, Code: protocol.ErrNoSuchTarget, Text: "no live sessions"},
246			wantCode: 1,
247		},
248	}
249	for _, tt := range tests {
250		t.Run(tt.name, func(t *testing.T) {
251			out := new(strings.Builder)
252			transport := &cmdTestTransport{recv: mustServerEnvelope(tt.result)}
253			err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
254				stdout: out,
255				getenv: func(string) string { return "" },
256				dial:   func(context.Context, string) (wire.Transport, error) { return transport, nil },
257			})
258
259			require.Equal(t, tt.wantCode, ExitCode(err))
260			require.Equal(t, 1, countCommandRequests(t, transport), "a definite outcome sends exactly one request")
261			require.NotErrorIs(t, err, errCommandOutcomeUnknown, "a definite outcome is never reported as unknown")
262			if tt.result.Outcome == protocol.CommandSucceeded {
263				require.NoError(t, err)
264				require.Equal(t, tt.result.Output+"\n", out.String())
265			} else {
266				require.ErrorContains(t, err, tt.result.Text)
267				require.NotErrorIs(t, err, errCommandNotSent)
268			}
269		})
270	}
271}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close