v0.8.1 1f54f61

terminal_test.go RawHistoryFolder

1package daemonmux
2
3import (
4	"errors"
5	"fmt"
6	"sync"
7	"sync/atomic"
8	"testing"
9
10	"github.com/bnema/vev/internal/domain"
11)
12
13// errTerminalSentinel is a stable cause used across tests so error identity
14// can be checked with errors.Is.
15var errTerminalSentinel = errors.New("terminal sentinel")
16
17func terminalDone(s *terminalState) bool {
18	select {
19	case <-s.Done():
20		return true
21	default:
22		return false
23	}
24}
25
26func TestTerminalStatePreTerminalGetters(t *testing.T) {
27	s := newTerminalState()
28
29	if terminalDone(s) {
30		t.Fatal("Done closed before any publication")
31	}
32	if err := s.Err(); err != nil {
33		t.Fatalf("Err() = %v before publication, want nil", err)
34	}
35	if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
36		t.Fatalf("FailureKind() = %v before publication, want none", kind)
37	}
38}
39
40func TestTerminalStateLocalClose(t *testing.T) {
41	s := newTerminalState()
42	s.Close()
43
44	if !terminalDone(s) {
45		t.Fatal("Done not closed after local Close")
46	}
47	if err := s.Err(); err != nil {
48		t.Fatalf("Err() = %v after local Close, want nil", err)
49	}
50	if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
51		t.Fatalf("FailureKind() = %v after local Close, want none", kind)
52	}
53
54	// A second local Close stays orderly, and a later failure must not
55	// overwrite the winning outcome.
56	s.Close()
57	s.Fail(domain.RemoteFailureTimeout, errors.New("late failure"))
58	if err := s.Err(); err != nil {
59		t.Fatalf("Err() = %v after late Fail, want nil", err)
60	}
61	if kind := s.FailureKind(); kind != domain.RemoteFailureNone {
62		t.Fatalf("FailureKind() = %v after late Fail, want none", kind)
63	}
64}
65
66type wrappedError struct{ err error }
67
68func (w wrappedError) Error() string { return w.err.Error() }
69func (w wrappedError) Unwrap() error { return w.err }
70
71func TestTerminalStateFailurePreservesErrorIdentity(t *testing.T) {
72	s := newTerminalState()
73	cause := wrappedError{err: fmt.Errorf("dial daemon: %w", errTerminalSentinel)}
74	s.Fail(domain.RemoteFailureTimeout, cause)
75
76	if !terminalDone(s) {
77		t.Fatal("Done not closed after Fail")
78	}
79	if got := s.Err(); got != cause {
80		t.Fatalf("Err() = %v, want the exact published error value", got)
81	}
82	if !errors.Is(s.Err(), errTerminalSentinel) {
83		t.Fatal("errors.Is lost the wrapped sentinel")
84	}
85	var wrapped wrappedError
86	if !errors.As(s.Err(), &wrapped) {
87		t.Fatal("errors.As lost the wrapped error identity")
88	}
89	if kind := s.FailureKind(); kind != domain.RemoteFailureTimeout {
90		t.Fatalf("FailureKind() = %v, want timeout", kind)
91	}
92
93	// A later orderly Close must not clear the published failure.
94	s.Close()
95	if s.Err() != cause || s.FailureKind() != domain.RemoteFailureTimeout {
96		t.Fatalf("Close overwrote failure: Err()=%v FailureKind()=%v", s.Err(), s.FailureKind())
97	}
98}
99
100// TestTerminalStateNilCauseUsesSentinel proves a failure publisher that
101// supplies no cause still publishes a non-nil Err() carrying the package
102// sentinel, paired with a nonzero valid kind.
103func TestTerminalStateNilCauseUsesSentinel(t *testing.T) {
104	s := newTerminalState()
105	s.Fail(domain.RemoteFailureTimeout, nil)
106
107	if !terminalDone(s) {
108		t.Fatal("Done not closed after Fail with a nil cause")
109	}
110	if s.Err() == nil {
111		t.Fatal("Err() is nil after Fail with a nil cause")
112	}
113	if !errors.Is(s.Err(), ErrTerminalFailure) {
114		t.Fatalf("Err() = %v, want ErrTerminalFailure", s.Err())
115	}
116	if kind := s.FailureKind(); kind != domain.RemoteFailureTimeout {
117		t.Fatalf("FailureKind() = %v, want timeout", kind)
118	}
119	// A failure always pairs a non-nil error with a nonzero valid kind, so a
120	// concurrent reader never observes the inconsistent (nil, nonzero) pair.
121	if (s.Err() == nil) != (s.FailureKind() == domain.RemoteFailureNone) {
122		t.Fatal("failure published an inconsistent (err, kind) pair")
123	}
124
125	// A nil cause with an out-of-range kind still normalizes the kind and
126	// substitutes the sentinel.
127	serialized := newTerminalState()
128	serialized.Fail(domain.RemoteFailureKind(99), nil)
129	if !errors.Is(serialized.Err(), ErrTerminalFailure) {
130		t.Fatalf("Err() = %v, want ErrTerminalFailure", serialized.Err())
131	}
132	if kind := serialized.FailureKind(); kind != domain.RemoteFailureTransport {
133		t.Fatalf("FailureKind() = %v, want transport", kind)
134	}
135}
136
137func TestTerminalStateFailureKindNormalization(t *testing.T) {
138	tests := []struct {
139		name string
140		give domain.RemoteFailureKind
141		want domain.RemoteFailureKind
142	}{
143		{name: "none normalizes to transport", give: domain.RemoteFailureNone, want: domain.RemoteFailureTransport},
144		{name: "out of range normalizes to transport", give: domain.RemoteFailureKind(99), want: domain.RemoteFailureTransport},
145		{name: "transport stays", give: domain.RemoteFailureTransport, want: domain.RemoteFailureTransport},
146		{name: "timeout stays", give: domain.RemoteFailureTimeout, want: domain.RemoteFailureTimeout},
147		{name: "trust stays", give: domain.RemoteFailureTrust, want: domain.RemoteFailureTrust},
148		{name: "authentication stays", give: domain.RemoteFailureAuthentication, want: domain.RemoteFailureAuthentication},
149		{name: "incompatible stays", give: domain.RemoteFailureIncompatible, want: domain.RemoteFailureIncompatible},
150		{name: "invalid response stays", give: domain.RemoteFailureInvalidResponse, want: domain.RemoteFailureInvalidResponse},
151	}
152	for _, tt := range tests {
153		t.Run(tt.name, func(t *testing.T) {
154			s := newTerminalState()
155			s.Fail(tt.give, errors.New("boom"))
156			if got := s.FailureKind(); got != tt.want {
157				t.Fatalf("FailureKind() = %v, want %v", got, tt.want)
158			}
159			if s.Err() == nil {
160				t.Fatal("Err() is nil after Fail")
161			}
162		})
163	}
164}
165
166func TestTerminalStateFirstWinsConcurrentPublishers(t *testing.T) {
167	const publishers = 100
168	validKinds := []domain.RemoteFailureKind{
169		domain.RemoteFailureTransport,
170		domain.RemoteFailureTimeout,
171		domain.RemoteFailureTrust,
172		domain.RemoteFailureAuthentication,
173		domain.RemoteFailureIncompatible,
174		domain.RemoteFailureInvalidResponse,
175	}
176
177	s := newTerminalState()
178	publishedErrs := make([]error, publishers)
179	publishedKinds := make([]domain.RemoteFailureKind, publishers)
180
181	var wg sync.WaitGroup
182	start := make(chan struct{})
183	for i := 0; i < publishers; i++ {
184		publishedErrs[i] = fmt.Errorf("publisher %d: %w", i, errTerminalSentinel)
185		publishedKinds[i] = validKinds[i%len(validKinds)]
186		wg.Add(1)
187		go func(i int) {
188			defer wg.Done()
189			<-start
190			s.Fail(publishedKinds[i], publishedErrs[i])
191		}(i)
192	}
193	close(start)
194	wg.Wait()
195
196	if !terminalDone(s) {
197		t.Fatal("Done not closed after concurrent publishers")
198	}
199	winnerErr := s.Err()
200	winnerKind := s.FailureKind()
201	if winnerErr == nil {
202		t.Fatal("Err() is nil after failure publishers")
203	}
204	if !errors.Is(winnerErr, errTerminalSentinel) {
205		t.Fatal("winner error lost the sentinel identity")
206	}
207
208	matches := 0
209	for i := range publishedErrs {
210		if winnerErr != publishedErrs[i] {
211			continue
212		}
213		matches++
214		if winnerKind != publishedKinds[i] {
215			t.Fatalf("winner error from publisher %d paired with kind %v, want %v", i, winnerKind, publishedKinds[i])
216		}
217	}
218	if matches != 1 {
219		t.Fatalf("exactly one publisher must win, got %d", matches)
220	}
221
222	// Later publications, local or failing, never change the winner.
223	s.Close()
224	s.Fail(domain.RemoteFailureTrust, errors.New("late failure"))
225	if s.Err() != winnerErr || s.FailureKind() != winnerKind {
226		t.Fatalf("terminal values changed: (%v, %v)", s.Err(), s.FailureKind())
227	}
228}
229
230func TestTerminalStateConcurrentGettersAndPublishers(t *testing.T) {
231	const goroutines = 100
232
233	s := newTerminalState()
234	var wg sync.WaitGroup
235	var inconsistent atomic.Int32
236	start := make(chan struct{})
237	for i := 0; i < goroutines; i++ {
238		wg.Add(1)
239		go func(i int) {
240			defer wg.Done()
241			<-start
242			switch i % 4 {
243			case 0:
244				s.Fail(domain.RemoteFailureTransport, fmt.Errorf("failure %d: %w", i, errTerminalSentinel))
245			case 1:
246				s.Close()
247			default:
248				select {
249				case <-s.Done():
250					err := s.Err()
251					kind := s.FailureKind()
252					// Failure pairs a non-nil error with a nonzero valid
253					// kind; an orderly local Close pairs nil with none.
254					if (err == nil) != (kind == domain.RemoteFailureNone) {
255						inconsistent.Add(1)
256					}
257				default:
258				}
259			}
260		}(i)
261	}
262	close(start)
263	wg.Wait()
264
265	if !terminalDone(s) {
266		t.Fatal("Done not closed after concurrent getters and publishers")
267	}
268	if inconsistent.Load() != 0 {
269		t.Fatalf("observed %d inconsistent terminal reads", inconsistent.Load())
270	}
271
272	// Exactly-once close: repeated publication never panics and never changes
273	// the terminal outcome.
274	gotErr := s.Err()
275	gotKind := s.FailureKind()
276	for i := 0; i < 10; i++ {
277		s.Close()
278		s.Fail(domain.RemoteFailureTimeout, errors.New("late failure"))
279		if s.Err() != gotErr || s.FailureKind() != gotKind {
280			t.Fatalf("terminal values changed: (%v, %v) -> (%v, %v)", gotErr, gotKind, s.Err(), s.FailureKind())
281		}
282	}
283}

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