1package headless
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "image"
8 "image/color"
9 "image/png"
10 "maps"
11 "os"
12 "path/filepath"
13 "strings"
14 "sync"
15 "testing"
16 "time"
17
18 "github.com/bnema/neferwl/internal/adapters/logging"
19 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
20 "github.com/bnema/neferwl/internal/ports"
21 "github.com/bnema/zerowrap"
22 "github.com/stretchr/testify/mock"
23)
24
25// frames records what the mocked renderer is asked to draw.
26type frames struct {
27 mu sync.Mutex
28 scenes []ports.Scene
29 contents []map[ports.WindowID]ports.SurfaceContent
30}
31
32func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceContent) {
33 f.mu.Lock()
34 defer f.mu.Unlock()
35 return append([]ports.Scene(nil), f.scenes...), append([]map[ports.WindowID]ports.SurfaceContent(nil), f.contents...)
36}
37
38// recordingRenderer returns a generated mock whose Render records each frame
39// and returns err. Close must be called exactly once.
40func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
41 r := portsmocks.NewMockRenderer(t)
42 f := &frames{}
43 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
44 f.mu.Lock()
45 defer f.mu.Unlock()
46 f.scenes = append(f.scenes, s)
47 f.contents = append(f.contents, maps.Clone(c))
48 return nil, err
49 }).Maybe()
50 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
51 r.EXPECT().Close().Return().Once()
52 return r, f
53}
54
55// waitFrames polls until the renderer drew at least n frames.
56func waitFrames(t *testing.T, f *frames, n int) {
57 t.Helper()
58 deadline := time.After(3 * time.Second)
59 for {
60 if s, _ := f.snapshot(); len(s) >= n {
61 return
62 }
63 select {
64 case <-deadline:
65 t.Fatal("no frame")
66 case <-time.After(10 * time.Millisecond):
67 }
68 }
69}
70
71func TestRun(t *testing.T) {
72 dir := t.TempDir()
73 scenes := make(chan ports.Scene, 8)
74 contents := newContentFeed(t, 8)
75 r, f := recordingRenderer(t, nil)
76 contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
77 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
78 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
79 scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
80 contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
81 ctx, cancel := context.WithCancel(context.Background())
82 done := make(chan error, 1)
83 go func() {
84 done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
85 }()
86 waitFrames(t, f, 1)
87 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
88 t.Errorf("coalescing: %+v %+v", s, c)
89 }
90 // An empty content of a window the scene still draws (it unmapped
91 // and fades out) keeps the last one; a scene without it drops it.
92 contents.send(ports.SurfaceContent{ID: 1})
93 waitFrames(t, f, 2)
94 if _, c := f.snapshot(); len(c) < 2 {
95 t.Fatal("no second frame")
96 } else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 {
97 t.Errorf("content of the shown window dropped on its empty content: %+v", c[1])
98 }
99 scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]}
100 waitFrames(t, f, 3)
101 if _, c := f.snapshot(); len(c) < 3 {
102 t.Fatal("no third frame")
103 } else if _, ok := c[2][1]; ok {
104 t.Error("content not deleted")
105 }
106 cancel()
107 if err := <-done; err != nil {
108 t.Fatal(err)
109 }
110 for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} {
111 file, err := os.Open(filepath.Join(dir, name))
112 if err != nil {
113 t.Fatal(err)
114 }
115 _, err = png.Decode(file)
116 _ = file.Close()
117 if err != nil {
118 t.Fatal(err)
119 }
120 }
121}
122
123// A scene whose Seq the output already holds is not drawn again.
124func TestHeadlessSameSeqRendersOnce(t *testing.T) {
125 scenes := make(chan ports.Scene)
126 contents := newContentFeed(t, 0)
127 r, f := recordingRenderer(t, nil)
128 ctx, cancel := context.WithCancel(context.Background())
129 done := make(chan error, 1)
130 go func() {
131 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
132 }()
133 scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
134 scenes <- scene
135 waitFrames(t, f, 1)
136 scenes <- scene
137 // Unshown content sends Run round its loop: the held scene is handled.
138 contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}})
139 contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}})
140 if s, _ := f.snapshot(); len(s) != 1 {
141 t.Fatalf("%d frames for a scene with the held Seq", len(s))
142 }
143 scene.Seq = 6
144 scenes <- scene
145 waitFrames(t, f, 2)
146 cancel()
147 if err := <-done; err != nil {
148 t.Fatal(err)
149 }
150 if s, _ := f.snapshot(); len(s) != 2 {
151 t.Fatalf("%d frames, want 2", len(s))
152 }
153}
154
155// Content of a window the output does not show draws no frame.
156func TestRunSkipsContentNotShown(t *testing.T) {
157 scenes := make(chan ports.Scene, 1)
158 contents := newContentFeed(t, 2)
159 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 3, Hidden: true}}}
160 r, f := recordingRenderer(t, nil)
161 ctx, cancel := context.WithCancel(context.Background())
162 done := make(chan error, 1)
163 go func() {
164 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
165 }()
166 waitFrames(t, f, 1)
167 contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
168 contents.send(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}})
169 time.Sleep(50 * time.Millisecond)
170 if s, _ := f.snapshot(); len(s) != 1 {
171 t.Fatalf("%d frames for content not shown", len(s))
172 }
173 contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
174 waitFrames(t, f, 2)
175 if _, c := f.snapshot(); c[1][2].SHM == nil {
176 t.Error("content not shown was dropped")
177 }
178 cancel()
179 if err := <-done; err != nil {
180 t.Fatal(err)
181 }
182}
183
184func TestErrors(t *testing.T) {
185 expected := errors.New("failure")
186 scenes := make(chan ports.Scene, 1)
187 scenes <- ports.Scene{}
188 if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
189 t.Fatal(err)
190 }
191 r, _ := recordingRenderer(t, expected)
192 if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil); !errors.Is(err, expected) {
193 t.Fatal(err)
194 }
195}
196
197// Each trim tick lets the idle renderer free what it no longer draws,
198// without a frame; a failed trim stops the output.
199func TestRunTrimsRendererOnTick(t *testing.T) {
200 ticks := make(chan time.Time)
201 clk := portsmocks.NewMockClock(t)
202 tk := portsmocks.NewMockTicker(t)
203 clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
204 tk.EXPECT().C().Return(ticks)
205 tk.EXPECT().Stop().Return().Once()
206 now := time.Unix(1000, 0)
207 clk.EXPECT().Now().Return(now)
208 r, f := recordingRenderer(t, nil)
209 trimmed := make(chan time.Time, 2)
210 failure := errors.New("trim failed")
211 calls := 0 // Run's goroutine only
212 r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
213 trimmed <- at
214 if calls++; calls == 2 {
215 return failure
216 }
217 return nil
218 }).Twice()
219 done := make(chan error, 1)
220 var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
221 log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
222 go func() {
223 done <- Run(context.Background(), Options{Width: 2, Height: 2, Clock: clk, Log: log, Name: "HEADLESS-1", NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
224 }()
225 ticks <- now
226 if got := <-trimmed; !got.Equal(now) {
227 t.Fatalf("trimmed at %v", got)
228 }
229 ticks <- now
230 if err := <-done; !errors.Is(err, failure) {
231 t.Fatalf("run: %v", err)
232 }
233 if s, _ := f.snapshot(); len(s) != 0 {
234 t.Fatalf("%d frames for a trim", len(s))
235 }
236 // A trim tick that succeeded logs the periodic stats once.
237 if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
238 t.Fatalf("%d stats entries: %s", got, statsLog.String())
239 }
240 for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`} {
241 if !strings.Contains(statsLog.String(), want) {
242 t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
243 }
244 }
245}
246
247func TestWritePNGAtomic(t *testing.T) {
248 dir := t.TempDir()
249 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
250 for _, name := range []string{"frame-000001.png", "latest.png"} {
251 path := filepath.Join(dir, name)
252 if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
253 t.Fatal(err)
254 }
255 f, err := os.Open(path)
256 if err != nil {
257 t.Fatal(err)
258 }
259 if _, err := png.Decode(f); err != nil {
260 t.Fatal(err)
261 }
262 _ = f.Close()
263 }
264 entries, err := os.ReadDir(dir)
265 if err != nil {
266 t.Fatal(err)
267 }
268 if len(entries) != 2 {
269 t.Fatalf("unexpected files: %v", entries)
270 }
271}
272
273func TestRunPassesLayerContent(t *testing.T) {
274 scenes := make(chan ports.Scene, 1)
275 contents := newContentFeed(t, 1)
276 layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
277 scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
278 contents.send(ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}})
279 r, f := recordingRenderer(t, nil)
280 ctx, cancel := context.WithCancel(context.Background())
281 done := make(chan error, 1)
282 go func() {
283 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
284 }()
285 deadline := time.After(3 * time.Second)
286 for {
287 s, c := f.snapshot()
288 if len(c) > 0 && len(s[0].Layers) == 1 && c[0][42].SHM != nil {
289 break
290 }
291 select {
292 case <-deadline:
293 cancel()
294 <-done
295 t.Fatal("layer content not passed to renderer")
296 case <-time.After(10 * time.Millisecond):
297 }
298 }
299 cancel()
300 if err := <-done; err != nil {
301 t.Fatal(err)
302 }
303}
304
305func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
306 scenes := make(chan ports.Scene, 1)
307 requests := make(chan ports.CaptureRequest, 1)
308 replies := make(chan ports.CaptureDone, 1)
309 r, frames := recordingRenderer(t, nil)
310
311 frame := portsmocks.NewMockCaptureFrame(t)
312 frame.EXPECT().Done().Return(nil)
313 frame.EXPECT().Read(image.Rect(0, 0, 2, 2), mock.MatchedBy(func(p []byte) bool { return len(p) >= 16 }), 8).RunAndReturn(func(_ image.Rectangle, dst []byte, _ int) error { copy(dst, []byte{1, 2, 3, 255}); return nil }).Once()
314 r.EXPECT().BeginCapture().Return(frame, nil).Once()
315 r.EXPECT().EndCapture(frame).Return().Once()
316 ctx, cancel := context.WithCancel(context.Background())
317 done := make(chan error, 1)
318 go func() {
319 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, requests)
320 }()
321 scenes <- ports.Scene{Background: "#000000"}
322 waitFrames(t, frames, 1)
323 f, err := os.CreateTemp(t.TempDir(), "shot")
324 if err != nil {
325 t.Fatal(err)
326 }
327 if err := f.Truncate(16); err != nil {
328 t.Fatal(err)
329 }
330 requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
331 select {
332 case result := <-replies:
333 if result.Err != nil {
334 t.Fatal(result.Err)
335 }
336 case <-time.After(3 * time.Second):
337 t.Fatal("no capture")
338 }
339 frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
340 if _, err := f.Stat(); err == nil {
341 t.Fatal("descriptor not closed")
342 }
343 if s, _ := frames.snapshot(); len(s) < 2 {
344 t.Fatal("no fresh frame")
345 }
346 cancel()
347 if err := <-done; err != nil {
348 t.Fatal(err)
349 }
350}
351
352// RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next
353// to the 8-bit screenshots.
354func TestHeadlessRawHDRScreenshot(t *testing.T) {
355 dir := t.TempDir()
356 want := image.NewRGBA64(image.Rect(0, 0, 2, 2))
357 for i := range 4 {
358 want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff})
359 }
360 f, err := os.CreateTemp(t.TempDir(), "target")
361 if err != nil {
362 t.Fatal(err)
363 }
364 r := portsmocks.NewMockRenderer(t)
365 r.EXPECT().SetHDR(float64(203)).Return().Once()
366 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
367 rendered := make(chan struct{}, 4)
368 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
369 rendered <- struct{}{}
370 return nil, nil
371 }).Maybe()
372 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
373 r.EXPECT().HDRPixels().Return(want).Maybe()
374 r.EXPECT().Close().Return().Once()
375 scenes := make(chan ports.Scene, 1)
376 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2}
377 ctx, cancel := context.WithCancel(context.Background())
378 done := make(chan error, 1)
379 go func() {
380 done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, RawHDR: true, ScreenshotDir: dir, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
381 }()
382 select {
383 case <-rendered:
384 case <-time.After(3 * time.Second):
385 t.Fatal("no frame")
386 }
387 // The files are written after Render returns.
388 path := filepath.Join(dir, "latest-pq.png")
389 deadline := time.Now().Add(3 * time.Second)
390 for {
391 if _, err := os.Stat(path); err == nil {
392 break
393 }
394 if time.Now().After(deadline) {
395 t.Fatal("no latest-pq.png")
396 }
397 time.Sleep(10 * time.Millisecond)
398 }
399 cancel()
400 if err := <-done; err != nil {
401 t.Fatal(err)
402 }
403 file, err := os.Open(path)
404 if err != nil {
405 t.Fatal(err)
406 }
407 defer file.Close()
408 got, err := png.Decode(file)
409 if err != nil {
410 t.Fatal(err)
411 }
412 if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model {
413 t.Fatalf("not a 16-bit image: %T", got)
414 }
415 for y := range 2 {
416 for x := range 2 {
417 if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) {
418 t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
419 }
420 }
421 }
422 if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil {
423 t.Errorf("8-bit screenshot: %v", err)
424 }
425}
426
427// Only the output which successfully prepared HDR reports confirmed HDR.
428// The fallback must explicitly withdraw it, and close exported descriptors.
429func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
430 for _, success := range []bool{true, false} {
431 t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
432 r := portsmocks.NewMockRenderer(t)
433 r.EXPECT().SetHDR(float64(203)).Return().Once()
434 if success {
435 f, err := os.CreateTemp(t.TempDir(), "target")
436 if err != nil {
437 t.Fatal(err)
438 }
439 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
440 t.Cleanup(func() {
441 if _, err := f.Stat(); err == nil {
442 t.Error("exported fd not closed")
443 }
444 })
445 } else {
446 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return(nil, errors.New("no compatible target")).Once()
447 r.EXPECT().SetHDR(float64(0)).Return().Once()
448 r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
449 }
450 r.EXPECT().Close().Return().Once()
451 formats := make(chan ports.OutputFormats, 1)
452 ctx, cancel := context.WithCancel(context.Background())
453 done := make(chan error, 1)
454 go func() {
455 done <- Run(ctx, Options{Width: 2, Height: 2, HDR: true, Name: "HEADLESS-1", Formats: formats, Log: logging.For(ctx, "render"), NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, nil, nil, nil, nil, nil)
456 }()
457 select {
458 case f := <-formats:
459 if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
460 t.Fatalf("report %+v, success=%t", f, success)
461 }
462 if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
463 t.Fatalf("HDR metadata %+v", f.HDR)
464 }
465 case <-time.After(2 * time.Second):
466 t.Fatal("no format report")
467 }
468 cancel()
469 if err := <-done; err != nil {
470 t.Fatal(err)
471 }
472 })
473 }
474}
475
476// One batch with two windows applies both before the frame they show in, and
477// goes back to wayland reset and exactly once.
478func TestRunAppliesWholeBatch(t *testing.T) {
479 scenes := make(chan ports.Scene, 1)
480 contents := newContentFeed(t, 1)
481 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
482 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
483 r, f := recordingRenderer(t, nil)
484 ctx, cancel := context.WithCancel(context.Background())
485 done := make(chan error, 1)
486 go func() {
487 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents.in(), contents.ret, nil, nil)
488 }()
489 waitFrames(t, f, 1)
490 contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}})
491 waitFrames(t, f, 2)
492 if _, c := f.snapshot(); len(c[1]) != 2 || c[1][1].SHM == nil || c[1][2].SHM == nil {
493 t.Errorf("batch not applied whole: %+v", c[1])
494 }
495 select {
496 case b := <-contents.returned:
497 if len(b.Items) != 0 {
498 t.Errorf("batch returned with %d items", len(b.Items))
499 }
500 case <-time.After(2 * time.Second):
501 t.Fatal("batch not returned")
502 }
503 cancel()
504 if err := <-done; err != nil {
505 t.Fatal(err)
506 }
507 if len(contents.returned) != 0 {
508 t.Error("batch returned more than once")
509 }
510}