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}