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