v0.1.0 d9218d6

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	portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
16	"github.com/bnema/neferwl/internal/ports"
17	"github.com/stretchr/testify/mock"
18)
19
20// frames records what the mocked renderer is asked to draw.
21type frames struct {
22	mu       sync.Mutex
23	scenes   []ports.Scene
24	contents []map[ports.WindowID]ports.SurfaceContent
25}
26
27func (f *frames) snapshot() ([]ports.Scene, []map[ports.WindowID]ports.SurfaceContent) {
28	f.mu.Lock()
29	defer f.mu.Unlock()
30	return append([]ports.Scene(nil), f.scenes...), append([]map[ports.WindowID]ports.SurfaceContent(nil), f.contents...)
31}
32
33// recordingRenderer returns a generated mock whose Render records each frame
34// and returns err. Close must be called exactly once.
35func recordingRenderer(t *testing.T, err error) (*portsmocks.MockRenderer, *frames) {
36	r := portsmocks.NewMockRenderer(t)
37	f := &frames{}
38	r.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
39		f.mu.Lock()
40		defer f.mu.Unlock()
41		f.scenes = append(f.scenes, s)
42		f.contents = append(f.contents, maps.Clone(c))
43		return nil, err
44	}).Maybe()
45	r.EXPECT().Pixels().Return(image.NewRGBA(image.Rect(0, 0, 2, 2))).Maybe()
46	r.EXPECT().Close().Return().Once()
47	return r, f
48}
49
50// waitFrames polls until the renderer drew at least n frames.
51func waitFrames(t *testing.T, f *frames, n int) {
52	t.Helper()
53	deadline := time.After(3 * time.Second)
54	for {
55		if s, _ := f.snapshot(); len(s) >= n {
56			return
57		}
58		select {
59		case <-deadline:
60			t.Fatal("no frame")
61		case <-time.After(10 * time.Millisecond):
62		}
63	}
64}
65
66func TestRun(t *testing.T) {
67	dir := t.TempDir()
68	scenes := make(chan ports.Scene, 8)
69	contents := make(chan ports.SurfaceContent, 8)
70	r, f := recordingRenderer(t, nil)
71	contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
72	shown := []ports.SceneWindow{{ID: 1}, {ID: 2}}
73	scenes <- ports.Scene{Seq: 1, Windows: shown}
74	scenes <- ports.Scene{Seq: 2, Windows: shown}
75	contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
76	ctx, cancel := context.WithCancel(context.Background())
77	done := make(chan error, 1)
78	go func() {
79		done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
80	}()
81	waitFrames(t, f, 1)
82	if s, c := f.snapshot(); len(s) != 1 || s[0].Seq != 2 || len(c[0]) != 2 {
83		t.Errorf("coalescing: %+v %+v", s, c)
84	}
85	contents <- ports.SurfaceContent{ID: 1}
86	waitFrames(t, f, 2)
87	if _, c := f.snapshot(); len(c) < 2 {
88		t.Fatal("no second frame")
89	} else if _, ok := c[1][1]; ok {
90		t.Error("content not deleted")
91	}
92	cancel()
93	if err := <-done; err != nil {
94		t.Fatal(err)
95	}
96	for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} {
97		file, err := os.Open(filepath.Join(dir, name))
98		if err != nil {
99			t.Fatal(err)
100		}
101		_, err = png.Decode(file)
102		_ = file.Close()
103		if err != nil {
104			t.Fatal(err)
105		}
106	}
107}
108
109// Content of a window the output does not show draws no frame.
110func TestRunSkipsContentNotShown(t *testing.T) {
111	scenes := make(chan ports.Scene, 1)
112	contents := make(chan ports.SurfaceContent, 2)
113	scenes <- ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1}, {ID: 3, Hidden: true}}}
114	r, f := recordingRenderer(t, nil)
115	ctx, cancel := context.WithCancel(context.Background())
116	done := make(chan error, 1)
117	go func() {
118		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
119	}()
120	waitFrames(t, f, 1)
121	contents <- ports.SurfaceContent{ID: 2, SHM: &ports.SHMBuffer{Pool: 2}}
122	contents <- ports.SurfaceContent{ID: 3, SHM: &ports.SHMBuffer{Pool: 3}}
123	time.Sleep(50 * time.Millisecond)
124	if s, _ := f.snapshot(); len(s) != 1 {
125		t.Fatalf("%d frames for content not shown", len(s))
126	}
127	contents <- ports.SurfaceContent{ID: 1, SHM: &ports.SHMBuffer{Pool: 1}}
128	waitFrames(t, f, 2)
129	if _, c := f.snapshot(); c[1][2].SHM == nil {
130		t.Error("content not shown was dropped")
131	}
132	cancel()
133	if err := <-done; err != nil {
134		t.Fatal(err)
135	}
136}
137
138func TestErrors(t *testing.T) {
139	expected := errors.New("failure")
140	scenes := make(chan ports.Scene, 1)
141	scenes <- ports.Scene{}
142	if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return nil, expected }}, scenes, nil, nil); !errors.Is(err, expected) {
143		t.Fatal(err)
144	}
145	r, _ := recordingRenderer(t, expected)
146	if err := Run(context.Background(), Options{NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, nil, nil); !errors.Is(err, expected) {
147		t.Fatal(err)
148	}
149}
150
151func TestWritePNGAtomic(t *testing.T) {
152	dir := t.TempDir()
153	img := image.NewRGBA(image.Rect(0, 0, 1, 1))
154	for _, name := range []string{"frame-000001.png", "latest.png"} {
155		path := filepath.Join(dir, name)
156		if err := writePNG(path, img); err != nil {
157			t.Fatal(err)
158		}
159		f, err := os.Open(path)
160		if err != nil {
161			t.Fatal(err)
162		}
163		if _, err := png.Decode(f); err != nil {
164			t.Fatal(err)
165		}
166		_ = f.Close()
167	}
168	entries, err := os.ReadDir(dir)
169	if err != nil {
170		t.Fatal(err)
171	}
172	if len(entries) != 2 {
173		t.Fatalf("unexpected files: %v", entries)
174	}
175}
176
177func TestRunPassesLayerContent(t *testing.T) {
178	scenes := make(chan ports.Scene, 1)
179	contents := make(chan ports.SurfaceContent, 1)
180	layer := ports.SceneLayer{ID: 42, Layer: ports.LayerTop, Rect: ports.Rect{W: 2, H: 2}}
181	scenes <- ports.Scene{Layers: []ports.SceneLayer{layer}}
182	contents <- ports.SurfaceContent{ID: 42, Width: 1, Height: 1, SHM: &ports.SHMBuffer{Pool: 42, Stride: 4}}
183	r, f := recordingRenderer(t, nil)
184	ctx, cancel := context.WithCancel(context.Background())
185	done := make(chan error, 1)
186	go func() {
187		done <- Run(ctx, Options{Width: 2, Height: 2, NewRenderer: func(int, int) (ports.Renderer, error) { return r, nil }}, scenes, contents, nil)
188	}()
189	deadline := time.After(3 * time.Second)
190	for {
191		s, c := f.snapshot()
192		if len(c) > 0 && len(s[0].Layers) == 1 && c[0][42].SHM != nil {
193			break
194		}
195		select {
196		case <-deadline:
197			cancel()
198			<-done
199			t.Fatal("layer content not passed to renderer")
200		case <-time.After(10 * time.Millisecond):
201		}
202	}
203	cancel()
204	if err := <-done; err != nil {
205		t.Fatal(err)
206	}
207}

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