1package headless
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "image"
8 "image/color"
9 "image/png"
10 "maps"
11 "math"
12 "os"
13 "path/filepath"
14 "strconv"
15 "strings"
16 "sync"
17 "syscall"
18 "testing"
19 "time"
20
21 "github.com/bnema/neferwl/internal/adapters/logging"
22 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
23 "github.com/bnema/neferwl/internal/ports"
24 "github.com/bnema/zerowrap"
25 "github.com/stretchr/testify/mock"
26)
27
28// frames records what the mocked renderer is asked to draw.
29type frames struct {
30 mu sync.Mutex
31 scenes []ports.Scene
32 contents []map[ports.WindowID]ports.SurfaceContent
33}
34
35func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceContent) {
36 f.mu.Lock()
37 defer f.mu.Unlock()
38 return append([]ports.Scene(nil), f.scenes...), append([]map[ports.WindowID]ports.SurfaceContent(nil), f.contents...)
39}
40
41// recordingRenderer returns a generated mock whose Render records each frame
42// and returns err. Close must be called exactly once.
43func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
44 r := portsmocks.NewMockRenderer(t)
45 f := &frames{}
46 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
47 f.mu.Lock()
48 defer f.mu.Unlock()
49 f.scenes = append(f.scenes, s)
50 f.contents = append(f.contents, maps.Clone(c))
51 return nil, err
52 }).Maybe()
53 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
54 r.EXPECT().Close().Return().Once()
55 return r, f
56}
57
58// waitFrames polls until the renderer drew at least n frames.
59func waitFrames(t *testing.T, f *frames, n int) {
60 t.Helper()
61 deadline := time.After(3 * time.Second)
62 for {
63 if s, _ := f.snapshot(); len(s) >= n {
64 return
65 }
66 select {
67 case <-deadline:
68 t.Fatal("no frame")
69 case <-time.After(10 * time.Millisecond):
70 }
71 }
72}
73
74func TestRun(t *testing.T) {
75 dir := t.TempDir()
76 scenes := make(chan ports.Scene, 8)
77 contents := newContentFeed(t, 8)
78 r, f := recordingRenderer(t, nil)
79 contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
80 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
81 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
82 scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
83 contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
84 ctx, cancel := context.WithCancel(context.Background())
85 done := make(chan error, 1)
86 go func() {
87 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)
88 }()
89 waitFrames(t, f, 1)
90 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
91 t.Errorf("coalescing: %+v %+v", s, c)
92 }
93 // An empty content of a window the scene still draws (it unmapped
94 // and fades out) keeps the last one; a scene without it drops it.
95 contents.send(ports.SurfaceContent{ID: 1})
96 waitFrames(t, f, 2)
97 if _, c := f.snapshot(); len(c) < 2 {
98 t.Fatal("no second frame")
99 } else if kept, ok := c[1][1]; !ok || kept.SHM == nil || kept.SHM.Pool != 1 {
100 t.Errorf("content of the shown window dropped on its empty content: %+v", c[1])
101 }
102 scenes <- ports.Scene{Seq: 3, OutputWidth: 2, OutputHeight: 2, Windows: shown[1:]}
103 waitFrames(t, f, 3)
104 if _, c := f.snapshot(); len(c) < 3 {
105 t.Fatal("no third frame")
106 } else if _, ok := c[2][1]; ok {
107 t.Error("content not deleted")
108 }
109 cancel()
110 if err := <-done; err != nil {
111 t.Fatal(err)
112 }
113 for _, name := range []string{"frame-000001.png", "frame-000002.png", "frame-000003.png", "latest.png"} {
114 file, err := os.Open(filepath.Join(dir, name))
115 if err != nil {
116 t.Fatal(err)
117 }
118 _, err = png.Decode(file)
119 _ = file.Close()
120 if err != nil {
121 t.Fatal(err)
122 }
123 }
124 index, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
125 if err != nil {
126 t.Fatal(err)
127 }
128 lines := strings.Split(strings.TrimSpace(string(index)), "\n")
129 if len(lines) != 8 || lines[0] != "ffconcat version 1.0" {
130 t.Fatalf("unexpected index: %s", index)
131 }
132 for i, want := range []string{"file 'frame-000001.png'", "file 'frame-000002.png'", "file 'frame-000003.png'"} {
133 if lines[1+2*i] != want {
134 t.Fatalf("frame %d: %s", i+1, lines[1+2*i])
135 }
136 if i < 3 {
137 line := lines[2+2*i]
138 value, err := strconv.ParseFloat(strings.TrimPrefix(line, "duration "), 64)
139 if !strings.HasPrefix(line, "duration ") || err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
140 t.Fatalf("invalid duration: %q", line)
141 }
142 }
143 }
144 if lines[7] != lines[5] {
145 t.Fatalf("missing final repeated frame: %s", index)
146 }
147}
148
149// A scene whose Seq the output already holds is not drawn again.
150func TestHeadlessSameSeqRendersOnce(t *testing.T) {
151 scenes := make(chan ports.Scene)
152 contents := newContentFeed(t, 0)
153 r, f := recordingRenderer(t, nil)
154 ctx, cancel := context.WithCancel(context.Background())
155 done := make(chan error, 1)
156 go func() {
157 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)
158 }()
159 scene := ports.Scene{Seq: 5, OutputWidth: 2, OutputHeight: 2, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}}}
160 scenes <- scene
161 waitFrames(t, f, 1)
162 scenes <- scene
163 // Unshown content sends Run round its loop: the held scene is handled.
164 contents.send(ports.SurfaceContent{ID: 99, Seq: 1, SHM: &ports.SHMBuffer{}})
165 contents.send(ports.SurfaceContent{ID: 99, Seq: 2, SHM: &ports.SHMBuffer{}})
166 if s, _ := f.snapshot(); len(s) != 1 {
167 t.Fatalf("%d frames for a scene with the held Seq", len(s))
168 }
169 scene.Seq = 6
170 scenes <- scene
171 waitFrames(t, f, 2)
172 cancel()
173 if err := <-done; err != nil {
174 t.Fatal(err)
175 }
176 if s, _ := f.snapshot(); len(s) != 2 {
177 t.Fatalf("%d frames, want 2", len(s))
178 }
179}
180
181// Content of a window the output does not show draws no frame.
182func TestRunSkipsContentNotShown(t *testing.T) {
183 scenes := make(chan ports.Scene, 1)
184 contents := newContentFeed(t, 2)
185 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}}}
186 r, f := recordingRenderer(t, nil)
187 ctx, cancel := context.WithCancel(context.Background())
188 done := make(chan error, 1)
189 go func() {
190 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)
191 }()
192 waitFrames(t, f, 1)
193 contents.send(ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}})
194 contents.send(ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}})
195 time.Sleep(50 * time.Millisecond)
196 if s, _ := f.snapshot(); len(s) != 1 {
197 t.Fatalf("%d frames for content not shown", len(s))
198 }
199 contents.send(ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}})
200 waitFrames(t, f, 2)
201 if _, c := f.snapshot(); c[1][2].SHM == nil {
202 t.Error("content not shown was dropped")
203 }
204 cancel()
205 if err := <-done; err != nil {
206 t.Fatal(err)
207 }
208}
209
210func TestErrors(t *testing.T) {
211 expected := errors.New("failure")
212 scenes := make(chan ports.Scene, 1)
213 scenes <- ports.Scene{}
214 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) {
215 t.Fatal(err)
216 }
217 r, _ := recordingRenderer(t, expected)
218 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) {
219 t.Fatal(err)
220 }
221}
222
223// Each trim tick lets the idle renderer free what it no longer draws,
224// without a frame; a failed trim stops the output.
225func TestRunTrimsRendererOnTick(t *testing.T) {
226 ticks := make(chan time.Time)
227 clk := portsmocks.NewMockClock(t)
228 tk := portsmocks.NewMockTicker(t)
229 clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
230 tk.EXPECT().C().Return(ticks)
231 tk.EXPECT().Stop().Return().Once()
232 now := time.Unix(1000, 0)
233 clk.EXPECT().Now().Return(now)
234 r, f := recordingRenderer(t, nil)
235 trimmed := make(chan time.Time, 2)
236 failure := errors.New("trim failed")
237 calls := 0 // Run's goroutine only
238 r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
239 trimmed <- at
240 if calls++; calls == 2 {
241 return failure
242 }
243 return nil
244 }).Twice()
245 done := make(chan error, 1)
246 var statsLog bytes.Buffer // written by Run's goroutine, read after it ends
247 log := zerowrap.New(zerowrap.Config{Level: "info", Format: "json", Output: &statsLog})
248 go func() {
249 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)
250 }()
251 ticks <- now
252 if got := <-trimmed; !got.Equal(now) {
253 t.Fatalf("trimmed at %v", got)
254 }
255 ticks <- now
256 if err := <-done; !errors.Is(err, failure) {
257 t.Fatalf("run: %v", err)
258 }
259 if s, _ := f.snapshot(); len(s) != 0 {
260 t.Fatalf("%d frames for a trim", len(s))
261 }
262 // A trim tick that succeeded logs the periodic stats once.
263 if got := strings.Count(statsLog.String(), `"message":"stats"`); got != 1 {
264 t.Fatalf("%d stats entries: %s", got, statsLog.String())
265 }
266 for _, want := range []string{`"output":"HEADLESS-1"`, `"frames":0`} {
267 if !strings.Contains(statsLog.String(), want) {
268 t.Fatalf("stats entry lacks %s: %s", want, statsLog.String())
269 }
270 }
271}
272
273// latest.png is the frame file linked, not encoded again: the same pixels,
274// published atomically, and nothing left behind.
275func TestLinkPNGSecure(t *testing.T) {
276 dir := t.TempDir()
277 img := image.NewRGBA(image.Rect(0, 0, 3, 2))
278 for i := range img.Pix {
279 img.Pix[i] = byte(i * 7)
280 }
281 frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png")
282 if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
283 t.Fatal(err)
284 }
285 // An older latest.png is replaced.
286 if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil {
287 t.Fatal(err)
288 }
289 if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written {
290 t.Fatalf("link = %v, %v", written, err)
291 }
292 a, err := os.ReadFile(frame)
293 if err != nil {
294 t.Fatal(err)
295 }
296 b, err := os.ReadFile(latest)
297 if err != nil {
298 t.Fatal(err)
299 }
300 if !bytes.Equal(a, b) {
301 t.Fatal("latest.png differs from the frame")
302 }
303 fi, err := os.Stat(frame)
304 if err != nil {
305 t.Fatal(err)
306 }
307 li, err := os.Stat(latest)
308 if err != nil {
309 t.Fatal(err)
310 }
311 if !os.SameFile(fi, li) {
312 t.Fatal("latest.png is a copy, not a hard link to the frame")
313 }
314 got, err := png.Decode(bytes.NewReader(b))
315 if err != nil {
316 t.Fatal(err)
317 }
318 for y := range 2 {
319 for x := range 3 {
320 if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) {
321 t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
322 }
323 }
324 }
325 entries, err := os.ReadDir(dir)
326 if err != nil {
327 t.Fatal(err)
328 }
329 if len(entries) != 2 {
330 t.Fatalf("leftover files: %v", entries)
331 }
332}
333
334// A frame that crossed a security change is not published as latest.png, a
335// failed rename leaves no temporary file, and a source that cannot be linked
336// is encoded instead.
337func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
338 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
339 good := ports.SecurityState{Generation: 1}
340 // frameIn writes the frame to link and returns its directory and path.
341 frameIn := func(t *testing.T) (string, string) {
342 t.Helper()
343 dir := t.TempDir()
344 frame := filepath.Join(dir, "frame-000001.png")
345 if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346 t.Fatal(err)
347 }
348 return dir, frame
349 }
350 // onlyFrame fails unless the frame is all the directory holds: no
351 // latest.png and no latest.png.tmp.
352 onlyFrame := func(t *testing.T, dir string) {
353 t.Helper()
354 if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355 t.Fatalf("latest.png or a temp file delivered: %v", entries)
356 }
357 }
358 t.Run("protected state", func(t *testing.T) {
359 dir, frame := frameIn(t)
360 // A protected state is refused without reading the live snapshot.
361 gate := portsmocks.NewMockSessionSecurity(t)
362 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363 t.Fatalf("link = %v, %v", written, err)
364 }
365 onlyFrame(t, dir)
366 })
367 t.Run("protected snapshot", func(t *testing.T) {
368 dir, frame := frameIn(t)
369 gate := portsmocks.NewMockSessionSecurity(t)
370 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
371 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
372 t.Fatalf("link = %v, %v", written, err)
373 }
374 onlyFrame(t, dir)
375 })
376 t.Run("changed after the link", func(t *testing.T) {
377 dir, frame := frameIn(t)
378 gate := portsmocks.NewMockSessionSecurity(t)
379 gate.EXPECT().Snapshot().Return(good).Once()
380 gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
381 if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
382 t.Fatalf("link = %v, %v", written, err)
383 }
384 onlyFrame(t, dir)
385 })
386 t.Run("rename failure", func(t *testing.T) {
387 dir, frame := frameIn(t)
388 // A non-empty directory cannot be replaced by a file.
389 latest := filepath.Join(dir, "latest.png")
390 if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391 t.Fatal(err)
392 }
393 if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394 t.Fatalf("link = %v, %v; want an error", written, err)
395 }
396 if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397 t.Fatalf("temporary link left behind: %v", err)
398 }
399 if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400 t.Fatalf("existing directory touched: %v", err)
401 }
402 })
403 t.Run("no source file", func(t *testing.T) {
404 dir := t.TempDir()
405 latest := filepath.Join(dir, "latest.png")
406 if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
407 t.Fatalf("link = %v, %v", written, err)
408 }
409 fi, err := os.Stat(latest)
410 if err != nil {
411 t.Fatal(err)
412 }
413 if !fi.Mode().IsRegular() {
414 t.Fatalf("latest.png is %v", fi.Mode())
415 }
416 f, err := os.Open(latest)
417 if err != nil {
418 t.Fatal(err)
419 }
420 defer f.Close()
421 if _, err := png.Decode(f); err != nil {
422 t.Fatal(err)
423 }
424 // Encoded on its own: nothing else in the directory shares it.
425 if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426 t.Fatalf("unexpected files: %v", entries)
427 }
428 if n, ok := linkCount(fi); ok && n != 1 {
429 t.Fatalf("latest.png has %d links, want 1", n)
430 }
431 })
432}
433
434// linkCount returns the hard link count of fi where the platform reports it.
435func linkCount(fi os.FileInfo) (uint64, bool) {
436 st, ok := fi.Sys().(*syscall.Stat_t)
437 if !ok {
438 return 0, false
439 }
440 return uint64(st.Nlink), true
441}
442
443func TestWritePNGAtomic(t *testing.T) {
444 dir := t.TempDir()
445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
446 for _, name := range []string{"frame-000001.png", "latest.png"} {
447 path := filepath.Join(dir, name)
448 if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
449 t.Fatal(err)
450 }
451 f, err := os.Open(path)
452 if err != nil {
453 t.Fatal(err)
454 }
455 if _, err := png.Decode(f); err != nil {
456 t.Fatal(err)
457 }
458 _ = f.Close()
459 }
460 entries, err := os.ReadDir(dir)
461 if err != nil {
462 t.Fatal(err)
463 }
464 if len(entries) != 2 {
465 t.Fatalf("unexpected files: %v", entries)
466 }
467}
468
469func TestRunPassesLayerContent(t *testing.T) {
470 scenes := make(chan ports.Scene, 1)
471 contents := newContentFeed(t, 1)
472 layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
473 scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
474 contents.send(ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}})
475 r, f := recordingRenderer(t, nil)
476 ctx, cancel := context.WithCancel(context.Background())
477 done := make(chan error, 1)
478 go func() {
479 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)
480 }()
481 deadline := time.After(3 * time.Second)
482 for {
483 s, c := f.snapshot()
484 if len(c) > 0 && len(s[0].Layers) == 1 && c[0][42].SHM != nil {
485 break
486 }
487 select {
488 case <-deadline:
489 cancel()
490 <-done
491 t.Fatal("layer content not passed to renderer")
492 case <-time.After(10 * time.Millisecond):
493 }
494 }
495 cancel()
496 if err := <-done; err != nil {
497 t.Fatal(err)
498 }
499}
500
501func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
502 scenes := make(chan ports.Scene, 1)
503 requests := make(chan ports.CaptureRequest, 1)
504 replies := make(chan ports.CaptureDone, 1)
505 r, frames := recordingRenderer(t, nil)
506
507 frame := portsmocks.NewMockCaptureFrame(t)
508 frame.EXPECT().Done().Return(nil)
509 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()
510 r.EXPECT().BeginCapture().Return(frame, nil).Once()
511 r.EXPECT().EndCapture(frame).Return().Once()
512 ctx, cancel := context.WithCancel(context.Background())
513 done := make(chan error, 1)
514 go func() {
515 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)
516 }()
517 scenes <- ports.Scene{Background: "#000000"}
518 waitFrames(t, frames, 1)
519 f, err := os.CreateTemp(t.TempDir(), "shot")
520 if err != nil {
521 t.Fatal(err)
522 }
523 if err := f.Truncate(16); err != nil {
524 t.Fatal(err)
525 }
526 requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
527 select {
528 case result := <-replies:
529 if result.Err != nil {
530 t.Fatal(result.Err)
531 }
532 case <-time.After(3 * time.Second):
533 t.Fatal("no capture")
534 }
535 frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
536 if _, err := f.Stat(); err == nil {
537 t.Fatal("descriptor not closed")
538 }
539 if s, _ := frames.snapshot(); len(s) < 2 {
540 t.Fatal("no fresh frame")
541 }
542 cancel()
543 if err := <-done; err != nil {
544 t.Fatal(err)
545 }
546}
547
548// RawHDR writes the renderer's raw PQ codes as a 16-bit latest-pq.png next
549// to the 8-bit screenshots.
550func TestHeadlessRawHDRScreenshot(t *testing.T) {
551 dir := t.TempDir()
552 want := image.NewRGBA64(image.Rect(0, 0, 2, 2))
553 for i := range 4 {
554 want.SetRGBA64(i%2, i/2, color.RGBA64{R: uint16(i*0x1111 + 0x0123), G: 0x8000 + uint16(i), B: 0xffc0, A: 0xffff})
555 }
556 f, err := os.CreateTemp(t.TempDir(), "target")
557 if err != nil {
558 t.Fatal(err)
559 }
560 r := portsmocks.NewMockRenderer(t)
561 r.EXPECT().SetHDR(float64(203)).Return().Once()
562 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
563 rendered := make(chan struct{}, 4)
564 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(ports.Scene, map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
565 rendered <- struct{}{}
566 return nil, nil
567 }).Maybe()
568 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
569 r.EXPECT().HDRPixels().Return(want).Maybe()
570 r.EXPECT().Close().Return().Once()
571 scenes := make(chan ports.Scene, 1)
572 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2}
573 ctx, cancel := context.WithCancel(context.Background())
574 done := make(chan error, 1)
575 go func() {
576 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)
577 }()
578 select {
579 case <-rendered:
580 case <-time.After(3 * time.Second):
581 t.Fatal("no frame")
582 }
583 // The files are written after Render returns.
584 path := filepath.Join(dir, "latest-pq.png")
585 deadline := time.Now().Add(3 * time.Second)
586 for {
587 if _, err := os.Stat(path); err == nil {
588 break
589 }
590 if time.Now().After(deadline) {
591 t.Fatal("no latest-pq.png")
592 }
593 time.Sleep(10 * time.Millisecond)
594 }
595 cancel()
596 if err := <-done; err != nil {
597 t.Fatal(err)
598 }
599 file, err := os.Open(path)
600 if err != nil {
601 t.Fatal(err)
602 }
603 defer file.Close()
604 got, err := png.Decode(file)
605 if err != nil {
606 t.Fatal(err)
607 }
608 if _, ok := got.(*image.NRGBA64); !ok && got.ColorModel() != color.RGBA64Model {
609 t.Fatalf("not a 16-bit image: %T", got)
610 }
611 for y := range 2 {
612 for x := range 2 {
613 if g, w := color.RGBA64Model.Convert(got.At(x, y)), want.RGBA64At(x, y); g != color.Color(w) {
614 t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
615 }
616 }
617 }
618 if _, err := os.Stat(filepath.Join(dir, "latest.png")); err != nil {
619 t.Errorf("8-bit screenshot: %v", err)
620 }
621}
622
623// Only the output which successfully prepared HDR reports confirmed HDR.
624// The fallback must explicitly withdraw it, and close exported descriptors.
625func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
626 for _, success := range []bool{true, false} {
627 t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
628 r := portsmocks.NewMockRenderer(t)
629 r.EXPECT().SetHDR(float64(203)).Return().Once()
630 if success {
631 f, err := os.CreateTemp(t.TempDir(), "target")
632 if err != nil {
633 t.Fatal(err)
634 }
635 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
636 t.Cleanup(func() {
637 if _, err := f.Stat(); err == nil {
638 t.Error("exported fd not closed")
639 }
640 })
641 } else {
642 r.EXPECT().ExportTargets(1, []uint64(nil), false).Return(nil, errors.New("no compatible target")).Once()
643 r.EXPECT().SetHDR(float64(0)).Return().Once()
644 r.EXPECT().ExportTargets(0, []uint64(nil), false).Return(nil, nil).Once()
645 }
646 r.EXPECT().Close().Return().Once()
647 formats := make(chan ports.OutputFormats, 1)
648 ctx, cancel := context.WithCancel(context.Background())
649 done := make(chan error, 1)
650 go func() {
651 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)
652 }()
653 select {
654 case f := <-formats:
655 if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
656 t.Fatalf("report %+v, success=%t", f, success)
657 }
658 if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
659 t.Fatalf("HDR metadata %+v", f.HDR)
660 }
661 case <-time.After(2 * time.Second):
662 t.Fatal("no format report")
663 }
664 cancel()
665 if err := <-done; err != nil {
666 t.Fatal(err)
667 }
668 })
669 }
670}
671
672// One batch with two windows applies both before the frame they show in, and
673// goes back to wayland reset and exactly once.
674func TestRunAppliesWholeBatch(t *testing.T) {
675 scenes := make(chan ports.Scene, 1)
676 contents := newContentFeed(t, 1)
677 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
678 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
679 r, f := recordingRenderer(t, nil)
680 ctx, cancel := context.WithCancel(context.Background())
681 done := make(chan error, 1)
682 go func() {
683 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)
684 }()
685 waitFrames(t, f, 1)
686 contents.send(ports.SurfaceContent{ID: 1, Seq: 1, SHM: &ports.SHMBuffer{Pool: 1}}, ports.SurfaceContent{ID: 2, Seq: 1, SHM: &ports.SHMBuffer{Pool: 2}})
687 waitFrames(t, f, 2)
688 if _, c := f.snapshot(); len(c[1]) != 2 || c[1][1].SHM == nil || c[1][2].SHM == nil {
689 t.Errorf("batch not applied whole: %+v", c[1])
690 }
691 select {
692 case b := <-contents.returned:
693 if len(b.Items) != 0 {
694 t.Errorf("batch returned with %d items", len(b.Items))
695 }
696 case <-time.After(2 * time.Second):
697 t.Fatal("batch not returned")
698 }
699 cancel()
700 if err := <-done; err != nil {
701 t.Fatal(err)
702 }
703 if len(contents.returned) != 0 {
704 t.Error("batch returned more than once")
705 }
706}
707
708func TestRunFinalizesLastFrameDuration(t *testing.T) {
709 dir := t.TempDir()
710 scenes := make(chan ports.Scene, 1)
711 scenes <- ports.Scene{Seq: 1}
712 r, _ := recordingRenderer(t, nil)
713 ctx, cancel := context.WithCancel(context.Background())
714 defer cancel()
715 done := make(chan error, 1)
716 go func() {
717 done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil, nil)
718 }()
719 deadline := time.Now().Add(3 * time.Second)
720 for {
721 data, _ := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
722 if strings.Contains(string(data), "file 'frame-000001.png'") {
723 break
724 }
725 if time.Now().After(deadline) {
726 cancel()
727 <-done
728 t.Fatal("frame index not written")
729 }
730 time.Sleep(time.Millisecond)
731 }
732 time.Sleep(50 * time.Millisecond)
733 cancel()
734 if err := <-done; err != nil {
735 t.Fatal(err)
736 }
737 data, err := os.ReadFile(filepath.Join(dir, "frames.ffconcat"))
738 if err != nil {
739 t.Fatal(err)
740 }
741 lines := strings.Split(strings.TrimSpace(string(data)), "\n")
742 if len(lines) != 4 || lines[0] != "ffconcat version 1.0" || lines[1] != "file 'frame-000001.png'" || lines[3] != lines[1] {
743 t.Fatalf("unexpected finalized index: %s", data)
744 }
745 duration, err := strconv.ParseFloat(strings.TrimPrefix(lines[2], "duration "), 64)
746 if err != nil || duration < 0.05 || math.IsNaN(duration) || math.IsInf(duration, 0) {
747 t.Fatalf("final duration: %q (%v)", lines[2], err)
748 }
749}