v0.2.1 7e79979

extensions_test.go RawHistoryFolder

1package wayland
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"net"
8	"sync/atomic"
9	"testing"
10	"time"
11
12	"github.com/bnema/wlturbo"
13	"github.com/bnema/wlturbo/protocol/core"
14	"github.com/stretchr/testify/require"
15
16	"github.com/bnema/nefercap/internal/adapters/wayland/testserver"
17	"github.com/bnema/nefercap/internal/logging"
18	"github.com/bnema/nefercap/internal/ports"
19)
20
21func extConfig() testserver.Config {
22	return testserver.Config{
23		Outputs:       []testserver.OutputSpec{{Name: "BIG", Width: 64, Height: 48, Scale: 1}},
24		NeferwlSource: true, NeferwlExclusion: true,
25	}
26}
27
28func TestCapabilitiesWithExtension(t *testing.T) {
29	s, _ := newSource(t, extConfig())
30	require.Equal(t, ports.Capabilities{Exclusion: true}, s.Capabilities())
31}
32
33func TestRegionUsesExtensionSource(t *testing.T) {
34	s, srv := newSource(t, extConfig())
35	out := firstOutput(t, s)
36	f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
37	require.NoError(t, err)
38	require.NoError(t, f.Validate())
39	require.Equal(t, [3]int{20, 8, 80}, [3]int{f.Width, f.Height, f.Stride}, "the compositor sends only the region: no crop")
40	st := srv.Stats()
41	require.Equal(t, "region", st.LastSource)
42	require.Equal(t, [4]int32{10, 5, 20, 8}, st.LastRegion)
43	want := testserver.Pixel(10, 5, 1)
44	require.Equal(t, want[:], f.Row(0)[:4])
45	// the same region reuses the session; another one replaces it
46	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 10, Y: 5, Width: 20, Height: 8}})
47	require.NoError(t, err)
48	require.Equal(t, 1, srv.Stats().Sessions)
49	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{X: 0, Y: 0, Width: 4, Height: 4}})
50	require.NoError(t, err)
51	st = srv.Stats()
52	require.Equal(t, 2, st.Sessions)
53	require.Equal(t, 1, st.DestroyedSessions)
54}
55
56func TestBeginExclusionNeedsExtension(t *testing.T) {
57	s, _ := newSource(t, testserver.Config{})
58	_, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
59	require.ErrorIs(t, err, ErrExclusionUnsupported)
60}
61
62func TestBeginExclusionAndCaptureKeepSession(t *testing.T) {
63	s, srv := newSource(t, extConfig())
64	target := ports.Target{OutputID: firstOutput(t, s).ID}
65	token, err := s.BeginExclusion(context.Background(), target)
66	require.NoError(t, err)
67	require.Equal(t, testserver.Token, token)
68	for range 3 {
69		_, err = s.Capture(context.Background(), target)
70		require.NoError(t, err)
71	}
72	st := srv.Stats()
73	require.Equal(t, 1, st.Sessions, "capture reuses the session that owns the exclusion")
74	require.Equal(t, 1, st.Exclusions)
75	_, err = s.BeginExclusion(context.Background(), target)
76	require.Error(t, err, "one exclusion per session")
77}
78
79func TestExclusionRefused(t *testing.T) {
80	cfg := extConfig()
81	cfg.ExclusionFail = 1 // busy
82	s, _ := newSource(t, cfg)
83	_, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
84	require.ErrorIs(t, err, ErrExclusionUnavailable)
85}
86
87// Any refusal that is neither busy nor session_stopped is a plain
88// ErrExclusionUnavailable carrying the reason.
89func TestExclusionOtherReasonIsUnavailable(t *testing.T) {
90	cfg := extConfig()
91	cfg.ExclusionFail = 4 // reason 3
92	s, _ := newSource(t, cfg)
93	_, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: firstOutput(t, s).ID})
94	require.ErrorIs(t, err, ErrExclusionUnavailable)
95	require.NotErrorIs(t, err, ErrExclusionBusy)
96	require.NotErrorIs(t, err, ports.ErrCaptureStopped)
97	require.ErrorContains(t, err, "reason 3")
98}
99
100func TestExclusionSessionStoppedStaysStopped(t *testing.T) {
101	s, srv := newSource(t, extConfig())
102	target := ports.Target{OutputID: firstOutput(t, s).ID}
103	_, err := s.BeginExclusion(context.Background(), target)
104	require.NoError(t, err)
105	srv.StopSessions()
106	for range 2 {
107		_, err = s.Capture(context.Background(), target)
108		require.ErrorIs(t, err, ports.ErrCaptureStopped, "a recording never continues on a session without its exclusion")
109	}
110	require.Equal(t, 1, srv.Stats().Sessions)
111}
112
113func TestExclusionEndsWithTargetChange(t *testing.T) {
114	s, srv := newSource(t, extConfig())
115	out := firstOutput(t, s)
116	_, err := s.BeginExclusion(context.Background(), ports.Target{OutputID: out.ID})
117	require.NoError(t, err)
118	_, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, Region: ports.Region{Width: 4, Height: 4}})
119	require.NoError(t, err)
120	require.Equal(t, 1, srv.Stats().DestroyedExclusions)
121}
122
123// hudConnection is a second connection, like the HUD's, with a surface.
124func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {
125	t.Helper()
126	conn, err := net.Dial("unix", srv.Path)
127	require.NoError(t, err)
128	display, err := wlturbo.ConnectFromConn(conn)
129	require.NoError(t, err)
130	t.Cleanup(func() { _ = display.Close() })
131	require.NoError(t, display.Roundtrip())
132	comp := core.NewCompositor(display.Context())
133	_, err = display.Registry().BindNegotiated("wl_compositor", 4, comp)
134	require.NoError(t, err)
135	surface, err := comp.CreateSurface()
136	require.NoError(t, err)
137	return display, surface
138}
139
140func TestAuthorizeLayerWaitsForAttached(t *testing.T) {
141	_, srv := newSource(t, extConfig())
142	display, surface := hudConnection(t, srv)
143	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil))
144	require.Equal(t, []string{testserver.Token}, srv.Stats().Attached)
145}
146
147func TestAuthorizeLayerAttachFailed(t *testing.T) {
148	for reason, want := range map[uint32]AttachFailure{
149		1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers,
150		4: AttachAlreadyAttached, 5: AttachExclusionEnded, 6: AttachLayerDestroyed,
151	} {
152		cfg := extConfig()
153		cfg.AttachFail = reason
154		srv := testserver.Start(t, cfg)
155		display, surface := hudConnection(t, srv)
156		err := AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil)
157		require.ErrorIs(t, err, ErrAttachFailed)
158		var failed *AttachFailedError
159		require.ErrorAs(t, err, &failed)
160		require.Equal(t, want, failed.Reason)
161		require.Equal(t, want == AttachUnknownToken || want == AttachExclusionEnded, errors.Is(err, ErrExclusionUnavailable))
162		require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized))
163		require.NotContains(t, err.Error(), testserver.Token)
164	}
165}
166
167func TestAuthorizeLayerDetachedCallback(t *testing.T) {
168	_, srv := newSource(t, extConfig())
169	display, surface := hudConnection(t, srv)
170	var got atomic.Pointer[LayerDetachedError]
171	calls := make(chan struct{}, 4)
172	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) {
173		var d *LayerDetachedError
174		if errors.As(err, &d) {
175			got.Store(d)
176		}
177		calls <- struct{}{}
178	}))
179	require.Nil(t, got.Load())
180	srv.Detach(0, uint32(DetachExclusionEnded))
181	for len(calls) == 0 {
182		require.NoError(t, display.Roundtrip())
183	}
184	d := got.Load()
185	require.NotNil(t, d)
186	require.ErrorIs(t, d, ErrExclusionUnavailable)
187	require.NotContains(t, d.Error(), testserver.Token)
188}
189
190func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) {
191	_, srv := newSource(t, extConfig())
192	display, surface := hudConnection(t, srv)
193	got := make(chan error, 1)
194	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))
195	srv.SendRaw(0, 0, 0) // a second attached
196	for !display.Closed() {
197		if err := display.Dispatch(); err != nil {
198			break
199		}
200	}
201	require.ErrorIs(t, <-got, ErrExclusionUnavailable)
202	require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")
203}
204
205func TestAuthorizeLayerContextAndTimeout(t *testing.T) {
206	cfg := extConfig()
207	cfg.AttachSilent = true
208	srv := testserver.Start(t, cfg)
209	display, surface := hudConnection(t, srv)
210	ctx, cancel := context.WithCancel(context.Background())
211	done := make(chan error, 1)
212	go func() { done <- AuthorizeLayer(ctx, display, surface, testserver.Token, nil) }()
213	require.Eventually(t, func() bool { return len(srv.Stats().Attached) == 1 }, 5*time.Second, time.Millisecond)
214	cancel()
215	require.ErrorIs(t, <-done, context.Canceled)
216	require.True(t, display.Closed())
217
218	display2, surface2 := hudConnection(t, srv)
219	err := authorizeLayer(context.Background(), display2, surface2, testserver.Token, nil, 50*time.Millisecond)
220	require.ErrorIs(t, err, context.DeadlineExceeded)
221	require.True(t, display2.Closed())
222}
223
224func TestAuthorizeLayerRejectsBadInput(t *testing.T) {
225	require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testserver.Token, nil))
226	_, srv := newSource(t, extConfig())
227	display, surface := hudConnection(t, srv)
228	for _, tok := range []string{"", "has space", testserver.Token[:63], testserver.Token + "0", testserver.Token[:63] + "G", testserver.Token[:63] + "A"} {
229		require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok)
230	}
231	require.Empty(t, srv.Stats().Attached)
232}
233
234func TestAuthorizeLayerWithoutGlobal(t *testing.T) {
235	srv := testserver.Start(t, testserver.Config{})
236	display, surface := hudConnection(t, srv)
237	require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil), ErrExclusionUnsupported)
238}
239
240// A refused exclusion leaves none behind: the session is an ordinary one and
241// is not kept alive by the sticky-stopped rule.
242func TestRefusedExclusionDoesNotPinStoppedSession(t *testing.T) {
243	cfg := extConfig()
244	cfg.ExclusionFail = 1
245	s, srv := newSource(t, cfg)
246	target := ports.Target{OutputID: firstOutput(t, s).ID}
247	_, err := s.BeginExclusion(context.Background(), target)
248	require.ErrorIs(t, err, ErrExclusionUnavailable)
249	require.Equal(t, 1, srv.Stats().DestroyedExclusions, "the failed exclusion object is destroyed")
250	srv.StopSessions()
251	_, err = s.Capture(context.Background(), target)
252	require.ErrorIs(t, err, ports.ErrCaptureStopped)
253	_, err = s.Capture(context.Background(), target)
254	require.NoError(t, err, "a stopped session without an exclusion is replaced")
255}
256
257// The token is a secret: nothing of the exclusion flow logs it.
258func TestTokenNeverLogged(t *testing.T) {
259	var logs bytes.Buffer
260	ctx := logging.With(context.Background(), &logs, true)
261	srv := testserver.Start(t, extConfig())
262	s, err := New(ctx, srv.Path)
263	require.NoError(t, err)
264	defer s.Close()
265	target := ports.Target{OutputID: firstOutput(t, s).ID}
266	token, err := s.BeginExclusion(ctx, target)
267	require.NoError(t, err)
268	_, err = s.Capture(ctx, target)
269	require.NoError(t, err)
270	display, surface := hudConnection(t, srv)
271	require.NoError(t, AuthorizeLayer(ctx, display, surface, token, nil))
272	srv.StopSessions()
273	_, _ = s.Capture(ctx, target)
274	require.NotEmpty(t, logs.String(), "the source logs something")
275	require.NotContains(t, logs.String(), token)
276}

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 2

Tags 9

↑↓ moveenter openesc close