main 5d6fa26

output_test.go RawHistoryFolder

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}

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 23

Tags 10

↑↓ moveenter openesc close