1package headless
2
3import (
4 "context"
5 "errors"
6 "image"
7 "image/png"
8 "maps"
9 "os"
10 "path/filepath"
11 "sync"
12 "testing"
13 "time"
14
15 "github.com/bnema/neferwl/internal/logging"
16 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
17 "github.com/bnema/neferwl/internal/ports"
18 "github.com/stretchr/testify/mock"
19)
20
21// frames records what the mocked renderer is asked to draw.
22type frames struct {
23 mu sync.Mutex
24 scenes []ports.Scene
25 contents []map[ports.WindowID]ports.SurfaceContent
26}
27
28func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceContent) {
29 f.mu.Lock()
30 defer f.mu.Unlock()
31 return append([]ports.Scene(nil), f.scenes...), append([]map[ports.WindowID]ports.SurfaceContent(nil), f.contents...)
32}
33
34// recordingRenderer returns a generated mock whose Render records each frame
35// and returns err. Close must be called exactly once.
36func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
37 r := portsmocks.NewMockRenderer(t)
38 f := &frames{}
39 r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
40 f.mu.Lock()
41 defer f.mu.Unlock()
42 f.scenes = append(f.scenes, s)
43 f.contents = append(f.contents, maps.Clone(c))
44 return nil, err
45 }).Maybe()
46 r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
47 r.EXPECT().Close().Return().Once()
48 return r, f
49}
50
51// waitFrames polls until the renderer drew at least n frames.
52func waitFrames(t *testing.T, f *frames, n int) {
53 t.Helper()
54 deadline := time.After(3 * time.Second)
55 for {
56 if s, _ := f.snapshot(); len(s) >= n {
57 return
58 }
59 select {
60 case <-deadline:
61 t.Fatal("no frame")
62 case <-time.After(10 * time.Millisecond):
63 }
64 }
65}
66
67func TestRun(t *testing.T) {
68 dir := t.TempDir()
69 scenes := make(chan ports.Scene, 8)
70 contents := make(chan ports.SurfaceContent, 8)
71 r, f := recordingRenderer(t, nil)
72 contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
73 shown := []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 2, H: 2}}, {ID: 2, Rect: ports.Rect{W: 2, H: 2}}}
74 scenes <- ports.Scene{Seq: 1, OutputWidth: 2, OutputHeight: 2, Windows: shown}
75 scenes <- ports.Scene{Seq: 2, OutputWidth: 2, OutputHeight: 2, Windows: shown}
76 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
77 ctx, cancel := context.WithCancel(context.Background())
78 done := make(chan error, 1)
79 go func() {
80 done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
81 }()
82 waitFrames(t, f, 1)
83 if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
84 t.Errorf("coalescing: %+v %+v", s, c)
85 }
86 contents <- ports.SurfaceContent{ID: 1}
87 waitFrames(t, f, 2)
88 if _, c := f.snapshot(); len(c) < 2 {
89 t.Fatal("no second frame")
90 } else if _, ok := c[1][1]; ok {
91 t.Error("content not deleted")
92 }
93 cancel()
94 if err := <-done; err != nil {
95 t.Fatal(err)
96 }
97 for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} {
98 file, err := os.Open(filepath.Join(dir, name))
99 if err != nil {
100 t.Fatal(err)
101 }
102 _, err = png.Decode(file)
103 _ = file.Close()
104 if err != nil {
105 t.Fatal(err)
106 }
107 }
108}
109
110// Content of a window the output does not show draws no frame.
111func TestRunSkipsContentNotShown(t *testing.T) {
112 scenes := make(chan ports.Scene, 1)
113 contents := make(chan ports.SurfaceContent, 2)
114 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}}}
115 r, f := recordingRenderer(t, nil)
116 ctx, cancel := context.WithCancel(context.Background())
117 done := make(chan error, 1)
118 go func() {
119 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
120 }()
121 waitFrames(t, f, 1)
122 contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
123 contents <- ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}
124 time.Sleep(50 * time.Millisecond)
125 if s, _ := f.snapshot(); len(s) != 1 {
126 t.Fatalf("%d frames for content not shown", len(s))
127 }
128 contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
129 waitFrames(t, f, 2)
130 if _, c := f.snapshot(); c[1][2].SHM == nil {
131 t.Error("content not shown was dropped")
132 }
133 cancel()
134 if err := <-done; err != nil {
135 t.Fatal(err)
136 }
137}
138
139func TestErrors(t *testing.T) {
140 expected := errors.New("failure")
141 scenes := make(chan ports.Scene, 1)
142 scenes <- ports.Scene{}
143 if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
144 t.Fatal(err)
145 }
146 r, _ := recordingRenderer(t, expected)
147 if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, nil); !errors.Is(err, expected) {
148 t.Fatal(err)
149 }
150}
151
152// Each trim tick lets the idle renderer free what it no longer draws,
153// without a frame; a failed trim stops the output.
154func TestRunTrimsRendererOnTick(t *testing.T) {
155 ticks := make(chan time.Time)
156 clk := portsmocks.NewMockClock(t)
157 tk := portsmocks.NewMockTicker(t)
158 clk.EXPECT().NewTicker(trimEvery).Return(tk).Once()
159 tk.EXPECT().C().Return(ticks)
160 tk.EXPECT().Stop().Return().Once()
161 now := time.Unix(1000, 0)
162 clk.EXPECT().Now().Return(now)
163 r, f := recordingRenderer(t, nil)
164 trimmed := make(chan time.Time, 2)
165 failure := errors.New("trim failed")
166 calls := 0 // Run's goroutine only
167 r.EXPECT().Trim(now).RunAndReturn(func(at time.Time) error {
168 trimmed <- at
169 if calls++; calls == 2 {
170 return failure
171 }
172 return nil
173 }).Twice()
174 done := make(chan error, 1)
175 go func() {
176 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)
177 }()
178 ticks <- now
179 if got := <-trimmed; !got.Equal(now) {
180 t.Fatalf("trimmed at %v", got)
181 }
182 ticks <- now
183 if err := <-done; !errors.Is(err, failure) {
184 t.Fatalf("run: %v", err)
185 }
186 if s, _ := f.snapshot(); len(s) != 0 {
187 t.Fatalf("%d frames for a trim", len(s))
188 }
189}
190
191func TestWritePNGAtomic(t *testing.T) {
192 dir := t.TempDir()
193 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
194 for _, name := range []string{"frame-000001.png", "latest.png"} {
195 path := filepath.Join(dir, name)
196 if err := writePNGSecure(path, img, nil, ports.SecurityState{}); err != nil {
197 t.Fatal(err)
198 }
199 f, err := os.Open(path)
200 if err != nil {
201 t.Fatal(err)
202 }
203 if _, err := png.Decode(f); err != nil {
204 t.Fatal(err)
205 }
206 _ = f.Close()
207 }
208 entries, err := os.ReadDir(dir)
209 if err != nil {
210 t.Fatal(err)
211 }
212 if len(entries) != 2 {
213 t.Fatalf("unexpected files: %v", entries)
214 }
215}
216
217func TestRunPassesLayerContent(t *testing.T) {
218 scenes := make(chan ports.Scene, 1)
219 contents := make(chan ports.SurfaceContent, 1)
220 layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
221 scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
222 contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}}
223 r, f := recordingRenderer(t, nil)
224 ctx, cancel := context.WithCancel(context.Background())
225 done := make(chan error, 1)
226 go func() {
227 done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil, nil)
228 }()
229 deadline := time.After(3 * time.Second)
230 for {
231 s, c := f.snapshot()
232 if len(c) > 0 && len(s[0].Layers) == 1 && c[0][42].SHM != nil {
233 break
234 }
235 select {
236 case <-deadline:
237 cancel()
238 <-done
239 t.Fatal("layer content not passed to renderer")
240 case <-time.After(10 * time.Millisecond):
241 }
242 }
243 cancel()
244 if err := <-done; err != nil {
245 t.Fatal(err)
246 }
247}
248
249func TestCaptureForcesFreshHeadlessFrame(t *testing.T) {
250 scenes := make(chan ports.Scene, 1)
251 requests := make(chan ports.CaptureRequest, 1)
252 replies := make(chan ports.CaptureDone, 1)
253 r, frames := recordingRenderer(t, nil)
254
255 frame := portsmocks.NewMockCaptureFrame(t)
256 frame.EXPECT().Done().Return(nil)
257 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()
258 r.EXPECT().BeginCapture().Return(frame, nil).Once()
259 r.EXPECT().EndCapture(frame).Return().Once()
260 ctx, cancel := context.WithCancel(context.Background())
261 done := make(chan error, 1)
262 go func() {
263 done <- Run(ctx, Options{Width: 2, Height: 2, Captured: replies, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil, requests)
264 }()
265 scenes <- ports.Scene{Background: "#000000"}
266 waitFrames(t, frames, 1)
267 f, err := os.CreateTemp(t.TempDir(), "shot")
268 if err != nil {
269 t.Fatal(err)
270 }
271 if err := f.Truncate(16); err != nil {
272 t.Fatal(err)
273 }
274 requests <- ports.CaptureRequest{ID: 1, Region: image.Rect(0, 0, 2, 2), Width: 2, Height: 2, Stride: 8, Dst: ports.SHMBuffer{File: f}}
275 select {
276 case result := <-replies:
277 if result.Err != nil {
278 t.Fatal(result.Err)
279 }
280 case <-time.After(3 * time.Second):
281 t.Fatal("no capture")
282 }
283 frame.Calls = nil // Testify must not inspect unmapped slice during expectation cleanup.
284 if _, err := f.Stat(); err == nil {
285 t.Fatal("descriptor not closed")
286 }
287 if s, _ := frames.snapshot(); len(s) < 2 {
288 t.Fatal("no fresh frame")
289 }
290 cancel()
291 if err := <-done; err != nil {
292 t.Fatal(err)
293 }
294}
295
296// Only the output which successfully prepared HDR reports confirmed HDR.
297// The fallback must explicitly withdraw it, and close exported descriptors.
298func TestHeadlessHDRFormatsConfirmed(t *testing.T) {
299 for _, success := range []bool{true, false} {
300 t.Run(map[bool]string{true: "confirmed", false: "fallback"}[success], func(t *testing.T) {
301 r := portsmocks.NewMockRenderer(t)
302 r.EXPECT().SetHDR(float64(203)).Return().Once()
303 if success {
304 f, err := os.CreateTemp(t.TempDir(), "target")
305 if err != nil {
306 t.Fatal(err)
307 }
308 r.EXPECT().ExportTargets(1, []uint64(nil)).Return([]ports.DMABuf{{Planes: []ports.DMABufPlane{{File: f}}}}, nil).Once()
309 t.Cleanup(func() {
310 if _, err := f.Stat(); err == nil {
311 t.Error("exported fd not closed")
312 }
313 })
314 } else {
315 r.EXPECT().ExportTargets(1, []uint64(nil)).Return(nil, errors.New("no compatible target")).Once()
316 r.EXPECT().SetHDR(float64(0)).Return().Once()
317 r.EXPECT().ExportTargets(0, []uint64(nil)).Return(nil, nil).Once()
318 }
319 r.EXPECT().Close().Return().Once()
320 formats := make(chan ports.OutputFormats, 1)
321 ctx, cancel := context.WithCancel(context.Background())
322 done := make(chan error, 1)
323 go func() {
324 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)
325 }()
326 select {
327 case f := <-formats:
328 if f.Output != "HEADLESS-1" || (f.HDR != nil) != success {
329 t.Fatalf("report %+v, success=%t", f, success)
330 }
331 if success && (f.HDR.MaxLuminance != 1000 || f.HDR.MaxFrameAverage != 400 || f.HDR.MinLuminance != .005) {
332 t.Fatalf("HDR metadata %+v", f.HDR)
333 }
334 case <-time.After(2 * time.Second):
335 t.Fatal("no format report")
336 }
337 cancel()
338 if err := <-done; err != nil {
339 t.Fatal(err)
340 }
341 })
342 }
343}