v0.3.0 ea9abc7

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	core "github.com/bnema/go-wayland-bindings/client/wayland"
13	"github.com/bnema/wlturbo"
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 and a
124// goroutine that reads its events, as neferclient's reader does.
125func hudConnection(t *testing.T, srv *testserver.Server) (*wlturbo.Display, *core.Surface) {
126	t.Helper()
127	conn, err := net.Dial("unix", srv.Path)
128	require.NoError(t, err)
129	display, err := wlturbo.ConnectFromConn(conn)
130	require.NoError(t, err)
131	t.Cleanup(func() { _ = display.Close() })
132	require.NoError(t, display.Roundtrip())
133	comp := core.NewCompositor(display.Context())
134	_, err = display.Registry().BindNegotiated("wl_compositor", 4, comp)
135	require.NoError(t, err)
136	surface, err := comp.CreateSurface()
137	require.NoError(t, err)
138	read := make(chan struct{})
139	go func() {
140		defer close(read)
141		for display.Dispatch() == nil {
142		}
143	}()
144	t.Cleanup(func() { _ = display.Close(); <-read })
145	return display, surface
146}
147
148func TestAuthorizeLayerWaitsForAttached(t *testing.T) {
149	_, srv := newSource(t, extConfig())
150	display, surface := hudConnection(t, srv)
151	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil))
152	require.Equal(t, []string{testserver.Token}, srv.Stats().Attached)
153}
154
155func TestAuthorizeLayerAttachFailed(t *testing.T) {
156	for reason, want := range map[uint32]AttachFailure{
157		1: AttachUnknownToken, 2: AttachUnauthorized, 3: AttachTooManyLayers,
158		4: AttachAlreadyAttached, 5: AttachExclusionEnded, 6: AttachLayerDestroyed,
159	} {
160		cfg := extConfig()
161		cfg.AttachFail = reason
162		srv := testserver.Start(t, cfg)
163		display, surface := hudConnection(t, srv)
164		err := AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil)
165		require.ErrorIs(t, err, ErrAttachFailed)
166		var failed *AttachFailedError
167		require.ErrorAs(t, err, &failed)
168		require.Equal(t, want, failed.Reason)
169		require.Equal(t, want == AttachUnknownToken || want == AttachExclusionEnded, errors.Is(err, ErrExclusionUnavailable))
170		require.Equal(t, want == AttachUnauthorized, errors.Is(err, ErrUnauthorized))
171		require.NotContains(t, err.Error(), testserver.Token)
172	}
173}
174
175func TestAuthorizeLayerDetachedCallback(t *testing.T) {
176	_, srv := newSource(t, extConfig())
177	display, surface := hudConnection(t, srv)
178	var got atomic.Pointer[LayerDetachedError]
179	calls := make(chan struct{}, 4)
180	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) {
181		var d *LayerDetachedError
182		if errors.As(err, &d) {
183			got.Store(d)
184		}
185		calls <- struct{}{}
186	}))
187	require.Nil(t, got.Load())
188	srv.Detach(0, uint32(DetachExclusionEnded))
189	select {
190	case <-calls:
191	case <-time.After(5 * time.Second):
192		t.Fatal("onDetached was not called")
193	}
194	d := got.Load()
195	require.NotNil(t, d)
196	require.ErrorIs(t, d, ErrExclusionUnavailable)
197	require.NotContains(t, d.Error(), testserver.Token)
198}
199
200func TestAuthorizeLayerProtocolViolationClosesDisplay(t *testing.T) {
201	_, srv := newSource(t, extConfig())
202	display, surface := hudConnection(t, srv)
203	got := make(chan error, 1)
204	require.NoError(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, func(err error) { got <- err }))
205	srv.SendRaw(0, 0, 0) // a second attached
206	select {
207	case err := <-got:
208		require.ErrorIs(t, err, ErrExclusionUnavailable)
209	case <-time.After(5 * time.Second):
210		t.Fatal("onDetached was not called")
211	}
212	require.True(t, display.Closed(), "the surface could be visible in captures: the connection ends")
213}
214
215func TestAuthorizeLayerContextAndTimeout(t *testing.T) {
216	cfg := extConfig()
217	cfg.AttachSilent = true
218	srv := testserver.Start(t, cfg)
219	display, surface := hudConnection(t, srv)
220	ctx, cancel := context.WithCancel(context.Background())
221	done := make(chan error, 1)
222	go func() { done <- AuthorizeLayer(ctx, display, surface, testserver.Token, nil) }()
223	require.Eventually(t, func() bool { return len(srv.Stats().Attached) == 1 }, 5*time.Second, time.Millisecond)
224	cancel()
225	require.ErrorIs(t, <-done, context.Canceled)
226	require.True(t, display.Closed())
227
228	display2, surface2 := hudConnection(t, srv)
229	err := authorizeLayer(context.Background(), display2, surface2, testserver.Token, nil, 50*time.Millisecond)
230	require.ErrorIs(t, err, context.DeadlineExceeded)
231	require.True(t, display2.Closed())
232}
233
234func TestAuthorizeLayerRejectsBadInput(t *testing.T) {
235	require.Error(t, AuthorizeLayer(context.Background(), nil, nil, testserver.Token, nil))
236	_, srv := newSource(t, extConfig())
237	display, surface := hudConnection(t, srv)
238	require.Error(t, AuthorizeLayer(context.Background(), display, (*core.Surface)(nil), testserver.Token, nil), "typed nil surface")
239	_, other := hudConnection(t, srv)
240	require.Error(t, AuthorizeLayer(context.Background(), display, other, testserver.Token, nil), "surface of another connection")
241	for _, tok := range []string{"", "has space", testserver.Token[:63], testserver.Token + "0", testserver.Token[:63] + "G", testserver.Token[:63] + "A"} {
242		require.Error(t, AuthorizeLayer(context.Background(), display, surface, tok, nil), "%q", tok)
243	}
244	require.Empty(t, srv.Stats().Attached)
245}
246
247func TestAuthorizeLayerWithoutGlobal(t *testing.T) {
248	srv := testserver.Start(t, testserver.Config{})
249	display, surface := hudConnection(t, srv)
250	require.ErrorIs(t, AuthorizeLayer(context.Background(), display, surface, testserver.Token, nil), ErrExclusionUnsupported)
251}
252
253// A refused exclusion leaves none behind: the session is an ordinary one and
254// is not kept alive by the sticky-stopped rule.
255func TestRefusedExclusionDoesNotPinStoppedSession(t *testing.T) {
256	cfg := extConfig()
257	cfg.ExclusionFail = 1
258	s, srv := newSource(t, cfg)
259	target := ports.Target{OutputID: firstOutput(t, s).ID}
260	_, err := s.BeginExclusion(context.Background(), target)
261	require.ErrorIs(t, err, ErrExclusionUnavailable)
262	require.Equal(t, 1, srv.Stats().DestroyedExclusions, "the failed exclusion object is destroyed")
263	srv.StopSessions()
264	_, err = s.Capture(context.Background(), target)
265	require.ErrorIs(t, err, ports.ErrCaptureStopped)
266	_, err = s.Capture(context.Background(), target)
267	require.NoError(t, err, "a stopped session without an exclusion is replaced")
268}
269
270// The token is a secret: nothing of the exclusion flow logs it.
271func TestTokenNeverLogged(t *testing.T) {
272	var logs bytes.Buffer
273	ctx := logging.With(context.Background(), &logs, true)
274	srv := testserver.Start(t, extConfig())
275	s, err := New(ctx, srv.Path)
276	require.NoError(t, err)
277	defer s.Close()
278	target := ports.Target{OutputID: firstOutput(t, s).ID}
279	token, err := s.BeginExclusion(ctx, target)
280	require.NoError(t, err)
281	_, err = s.Capture(ctx, target)
282	require.NoError(t, err)
283	display, surface := hudConnection(t, srv)
284	require.NoError(t, AuthorizeLayer(ctx, display, surface, token, nil))
285	srv.StopSessions()
286	_, _ = s.Capture(ctx, target)
287	require.NotEmpty(t, logs.String(), "the source logs something")
288	require.NotContains(t, logs.String(), token)
289}

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