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}