output_test.go RawHistoryFolder

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}

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