1package capture
2
3import (
4 "context"
5 "errors"
6 "image"
7 "os"
8 "testing"
9 "time"
10
11 portsmocks "github.com/bnema/neferwl/internal/mocks/ports"
12 "github.com/bnema/neferwl/internal/ports"
13 "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"
15)
16
17func hiddenScene() ports.Scene {
18 return ports.Scene{
19 Scale: 2, OutputWidth: 100, OutputHeight: 50, Windows: []ports.SceneWindow{{ID: 1}},
20 Capture: &ports.SceneCapture{Workspace: 9},
21 CaptureScene: &ports.Scene{
22 OutputWidth: 100, OutputHeight: 50, Scale: 2, Seq: 9,
23 Windows: []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}},
24 Layers: []ports.SceneLayer{{ID: 10}, {ID: 11}},
25 },
26 }
27}
28
29func hiddenRequest(t *testing.T, id uint64, region image.Rectangle) ports.CaptureRequest {
30 q := pipelineRequest(t, id)
31 q.Workspace, q.OffScreen = 9, true
32 q.Region, q.Width, q.Height, q.Stride = region, region.Dx(), region.Dy(), region.Dx()*4
33 require.NoError(t, q.Dst.File.Truncate(int64(q.Stride*q.Height)))
34 return q
35}
36
37func childMock(t *testing.T, w, h int) (*portsmocks.MockRenderer, *portsmocks.MockCaptureFrame) {
38 r := portsmocks.NewMockRenderer(t)
39 frame := portsmocks.NewMockCaptureFrame(t)
40 frame.EXPECT().Done().Return(nil).Maybe()
41 frame.EXPECT().Read(mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe()
42 r.EXPECT().EndCapture(frame).Return().Maybe()
43 r.EXPECT().Close().Return().Once()
44 r.EXPECT().BeginCapture().Return(frame, nil).Once()
45 return r, frame
46}
47
48func TestSubmitHiddenDrawsChildAndRebasesRegion(t *testing.T) {
49 replies := make(chan ports.CaptureDone, 4)
50 p := NewPipeline(context.Background(), replies)
51 child, _ := childMock(t, 200, 100)
52 var scene ports.Scene
53 child.EXPECT().Render(mock.Anything, mock.Anything).RunAndReturn(func(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) (*os.File, error) {
54 scene = s
55 return nil, nil
56 }).Once()
57 var made [2]int
58 p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made = [2]int{w, h}; return child, nil })
59 s := hiddenScene()
60 p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(480, 360, 680, 460))})
61 require.Equal(t, [2]int{200, 100}, made, "child image is the frame in physical pixels")
62 require.Nil(t, scene.Capture)
63 require.Zero(t, scene.Seq)
64 require.Equal(t, []ports.SceneWindow{{ID: 5}, {ID: 10, Popup: true}}, scene.Windows)
65 require.Equal(t, []ports.SceneLayer{{ID: 10}, {ID: 11}}, scene.Layers)
66 require.NoError(t, awaitCapture(t, replies).Err)
67 p.Close(nil)
68}
69
70func TestSubmitHiddenFailsClosed(t *testing.T) {
71 for _, tc := range []struct {
72 name string
73 edit func(*ports.Scene)
74 want error
75 }{
76 {"output off", func(s *ports.Scene) { s.Off = true }, nil},
77 {"no hidden workspace in the scene", func(s *ports.Scene) { s.Capture.Workspace = 0 }, ErrOffscreenUnavailable},
78 {"frame too large", func(s *ports.Scene) { s.CaptureScene.OutputWidth = 20000 }, ErrOffscreenGeometry},
79 {"frame empty", func(s *ports.Scene) { s.CaptureScene.OutputWidth = 0 }, ErrOffscreenGeometry},
80 } {
81 t.Run(tc.name, func(t *testing.T) {
82 replies := make(chan ports.CaptureDone, 2)
83 p := NewPipeline(context.Background(), replies)
84 defer p.Close(nil)
85 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { t.Fatal("factory called"); return nil, nil })
86 s := hiddenScene()
87 tc.edit(&s)
88 p.SubmitHidden(s, nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
89 got := awaitCapture(t, replies)
90 require.Error(t, got.Err)
91 if tc.want != nil {
92 require.ErrorIs(t, got.Err, tc.want)
93 }
94 })
95 }
96}
97
98func TestSubmitHiddenFactoryFailureAndNoFactory(t *testing.T) {
99 replies := make(chan ports.CaptureDone, 4)
100 p := NewPipeline(context.Background(), replies)
101 defer p.Close(nil)
102 p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
103 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable)
104 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return nil, errors.New("no gpu") })
105 p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 2, image.Rect(0, 0, 200, 100))})
106 require.ErrorIs(t, awaitCapture(t, replies).Err, ErrOffscreenUnavailable)
107}
108
109func TestSubmitHiddenRegionOutsideFrameFailsOthersServed(t *testing.T) {
110 replies := make(chan ports.CaptureDone, 4)
111 p := NewPipeline(context.Background(), replies)
112 child, _ := childMock(t, 200, 100)
113 child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
114 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
115 p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{
116 hiddenRequest(t, 1, image.Rect(0, 0, 300, 100)), // not the child's size
117 hiddenRequest(t, 2, image.Rect(0, 0, 200, 100)),
118 })
119 byID := map[uint64]error{}
120 for range 2 {
121 d := awaitCapture(t, replies)
122 byID[d.ID] = d.Err
123 }
124 require.ErrorIs(t, byID[1], ErrOffscreenGeometry)
125 require.NoError(t, byID[2])
126 p.Close(nil)
127}
128
129// A hidden-workspace capture submitted while the indicator gate is open is held
130// by the child pipeline: no reply until the owner's verdict, then it is
131// released (nil) or failed (error) like a displayed capture.
132func TestSubmitHiddenHeldByGateUntilVerdict(t *testing.T) {
133 for _, tc := range []struct {
134 name string
135 verdict error
136 }{
137 {"released", nil},
138 {"failed", GateVerdict(errors.New("frame dropped"))},
139 } {
140 t.Run(tc.name, func(t *testing.T) {
141 replies := make(chan ports.CaptureDone, 4)
142 p := NewPipeline(context.Background(), replies)
143 child, _ := childMock(t, 200, 100)
144 child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
145 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
146 p.BeginGate()
147 p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
148 select {
149 case d := <-replies:
150 t.Fatalf("answered before the gate verdict: %v", d.Err)
151 case <-time.After(100 * time.Millisecond):
152 }
153 p.EndGate(tc.verdict)
154 got := awaitCapture(t, replies)
155 if tc.verdict == nil {
156 require.NoError(t, got.Err)
157 } else {
158 require.ErrorIs(t, got.Err, ErrIndicatorMissing)
159 }
160 p.Close(nil)
161 })
162 }
163}
164
165func TestSubmitHiddenChildClosesWhenSessionEnds(t *testing.T) {
166 replies := make(chan ports.CaptureDone, 4)
167 p := NewPipeline(context.Background(), replies)
168 child, _ := childMock(t, 200, 100)
169 child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
170 p.EnableOffscreen(func(int, int) (ports.Renderer, error) { return child, nil })
171 p.SubmitHidden(hiddenScene(), nil, []ports.CaptureRequest{hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))})
172 require.NoError(t, awaitCapture(t, replies).Err)
173 // Wait for the worker to hand the lease back, then the session ends.
174 b := <-p.HiddenCompleted()
175 p.RecycleHidden(b)
176 p.Retire(ports.Scene{})
177 require.Nil(t, p.off.r, "child closed once the scene has no CaptureScene")
178 p.Close(nil)
179}
180
181func TestHiddenNilPipelineSafe(t *testing.T) {
182 var p *Pipeline
183 require.False(t, p.HiddenReading())
184 p.Retire(ports.Scene{})
185}
186
187// A window is drawn by the child like a hidden workspace; another window or
188// a workspace needs a new child.
189func TestSubmitHiddenWindow(t *testing.T) {
190 replies := make(chan ports.CaptureDone, 4)
191 p := NewPipeline(context.Background(), replies)
192 child, _ := childMock(t, 200, 100)
193 child.EXPECT().Render(mock.Anything, mock.Anything).Return(nil, nil).Once()
194 made := 0
195 p.EnableOffscreen(func(w, h int) (ports.Renderer, error) { made++; return child, nil })
196 s := hiddenScene()
197 s.Capture = &ports.SceneCapture{Window: 5}
198 q := hiddenRequest(t, 1, image.Rect(0, 0, 200, 100))
199 q.Workspace, q.Window = 0, 5
200 p.SubmitHidden(s, nil, []ports.CaptureRequest{q})
201 require.NoError(t, awaitCapture(t, replies).Err)
202 require.Equal(t, 1, made)
203 require.Equal(t, offTarget{window: 5}, p.off.target)
204 p.Close(nil)
205}