v0.4.0 b6be3a8

output_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close