1package cef
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 purecef "github.com/bnema/purego-cef/cef"
9 "github.com/stretchr/testify/require"
10)
11
12type viewportSyncOrderHost struct {
13 purecef.BrowserHost
14 calls []string
15 zoomLevel float64
16 getZoomLevelCalls int
17}
18
19type pendingBrowserCreateObservedSizeBridgeStub struct {
20 sizeWidth int32
21 sizeHeight int32
22 refreshWidth int32
23 refreshHeight int32
24 refreshCalls int
25 refreshProvided bool
26}
27
28func (s *pendingBrowserCreateObservedSizeBridgeStub) Size() (int32, int32) {
29 return s.sizeWidth, s.sizeHeight
30}
31
32func (s *pendingBrowserCreateObservedSizeBridgeStub) RefreshObservedSizeOnGTKThread() (int32, int32) {
33 s.refreshCalls++
34 if s.refreshProvided {
35 return s.refreshWidth, s.refreshHeight
36 }
37 return s.sizeWidth, s.sizeHeight
38}
39
40func (h *viewportSyncOrderHost) WasHidden(state int32) {
41 h.calls = append(h.calls, "WasHidden")
42 if state != 0 {
43 h.calls = append(h.calls, "WasHidden(nonzero)")
44 }
45}
46
47func (h *viewportSyncOrderHost) NotifyScreenInfoChanged() {
48 h.calls = append(h.calls, "NotifyScreenInfoChanged")
49}
50
51func (h *viewportSyncOrderHost) WasResized() {
52 h.calls = append(h.calls, "WasResized")
53}
54
55func (h *viewportSyncOrderHost) Invalidate(_ purecef.PaintElementType) {
56 h.calls = append(h.calls, "Invalidate")
57}
58
59func (h *viewportSyncOrderHost) SetZoomLevel(level float64) {
60 h.calls = append(h.calls, "SetZoomLevel")
61 h.zoomLevel = level
62}
63
64func (h *viewportSyncOrderHost) GetZoomLevel() float64 {
65 h.getZoomLevelCalls++
66 return h.zoomLevel
67}
68
69func TestNotifyBrowserViewportSync_VisibleCallsFullSequence(t *testing.T) {
70 host := &viewportSyncOrderHost{}
71
72 notifyBrowserViewportSync(host, true)
73
74 require.Equal(t, []string{"WasHidden", "NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
75}
76
77func TestNotifyBrowserViewportSync_HiddenSkipsWasHidden(t *testing.T) {
78 host := &viewportSyncOrderHost{}
79
80 notifyBrowserViewportSync(host, false)
81
82 require.Equal(t, []string{"NotifyScreenInfoChanged", "WasResized", "Invalidate"}, host.calls)
83}
84
85func TestNotifyViewportSyncOnCEFUIThread_PostsViewportWorkOnly(t *testing.T) {
86 oldNewTask := cefNewTask
87 oldPostDelayedTask := cefPostDelayedTask
88 defer func() {
89 cefNewTask = oldNewTask
90 cefPostDelayedTask = oldPostDelayedTask
91 }()
92
93 cefNewTask = func(task purecef.Task) purecef.Task { return task }
94
95 var scheduled purecef.Task
96 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
97 require.Equal(t, purecef.ThreadIDTidUi, threadID)
98 require.NotNil(t, task)
99 if delayMs == 0 && scheduled == nil {
100 scheduled = task
101 }
102 return 1
103 }
104
105 host := &viewportSyncOrderHost{}
106 wv := &WebView{ctx: context.Background(), host: host}
107 wv.notifyViewportSyncOnCEFUIThread(host, true)
108
109 require.Empty(t, host.calls)
110 require.NotNil(t, scheduled)
111
112 scheduled.Execute()
113 require.Equal(t, []string{
114 "WasHidden",
115 "NotifyScreenInfoChanged",
116 "WasResized",
117 "Invalidate",
118 }, host.calls)
119}
120
121func TestNotifyViewportSyncOnCEFUIThread_RemainsViewportOnlyAcrossRepeatedResizes(t *testing.T) {
122 oldNewTask := cefNewTask
123 oldPostDelayedTask := cefPostDelayedTask
124 defer func() {
125 cefNewTask = oldNewTask
126 cefPostDelayedTask = oldPostDelayedTask
127 }()
128
129 cefNewTask = func(task purecef.Task) purecef.Task { return task }
130
131 var scheduled purecef.Task
132 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
133 if delayMs == 0 && scheduled == nil {
134 scheduled = task
135 }
136 return 1
137 }
138
139 host := &viewportSyncOrderHost{}
140 wv := &WebView{ctx: context.Background(), host: host}
141
142 wv.notifyViewportSyncOnCEFUIThread(host, true)
143 require.NotNil(t, scheduled)
144 scheduled.Execute()
145
146 host.calls = nil
147 scheduled = nil
148
149 wv.notifyViewportSyncOnCEFUIThread(host, true)
150 require.NotNil(t, scheduled)
151 scheduled.Execute()
152
153 require.Equal(t, []string{
154 "NotifyScreenInfoChanged",
155 "WasResized",
156 "Invalidate",
157 }, host.calls)
158}
159
160func TestSyncZoomForBackingScaleOnCEFUIThread_PostsZoomWork(t *testing.T) {
161 oldNewTask := cefNewTask
162 oldPostDelayedTask := cefPostDelayedTask
163 defer func() {
164 cefNewTask = oldNewTask
165 cefPostDelayedTask = oldPostDelayedTask
166 }()
167
168 cefNewTask = func(task purecef.Task) purecef.Task { return task }
169
170 var scheduled purecef.Task
171 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
172 require.Equal(t, purecef.ThreadIDTidUi, threadID)
173 require.NotNil(t, task)
174 if delayMs == 0 && scheduled == nil {
175 scheduled = task
176 }
177 return 1
178 }
179
180 host := &viewportSyncOrderHost{}
181 wv := &WebView{ctx: context.Background(), host: host}
182 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
183
184 require.Empty(t, host.calls)
185 require.NotNil(t, scheduled)
186
187 scheduled.Execute()
188 require.Equal(t, []string{
189 "SetZoomLevel",
190 "NotifyScreenInfoChanged",
191 }, host.calls)
192}
193
194func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsRedundantReapplyWhenBackingScaleUnchanged(t *testing.T) {
195 oldNewTask := cefNewTask
196 oldPostDelayedTask := cefPostDelayedTask
197 defer func() {
198 cefNewTask = oldNewTask
199 cefPostDelayedTask = oldPostDelayedTask
200 }()
201
202 cefNewTask = func(task purecef.Task) purecef.Task { return task }
203
204 var scheduled purecef.Task
205 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
206 if delayMs == 0 && scheduled == nil {
207 scheduled = task
208 }
209 return 1
210 }
211
212 host := &viewportSyncOrderHost{}
213 wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}}
214
215 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
216 require.NotNil(t, scheduled)
217 scheduled.Execute()
218
219 host.calls = nil
220 scheduled = nil
221
222 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
223 require.Nil(t, scheduled)
224 require.Empty(t, host.calls)
225}
226
227func TestSyncZoomForBackingScaleOnCEFUIThread_ReappliesWhenCompensationChangesWithoutTouchingUserZoom(t *testing.T) {
228 oldNewTask := cefNewTask
229 oldPostDelayedTask := cefPostDelayedTask
230 defer func() {
231 cefNewTask = oldNewTask
232 cefPostDelayedTask = oldPostDelayedTask
233 }()
234
235 cefNewTask = func(task purecef.Task) purecef.Task { return task }
236
237 var scheduled []purecef.Task
238 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
239 require.Equal(t, purecef.ThreadIDTidUi, threadID)
240 if delayMs == 0 {
241 scheduled = append(scheduled, task)
242 }
243 return 1
244 }
245
246 host := &viewportSyncOrderHost{}
247 wv := &WebView{ctx: context.Background(), host: host, zoomCompensation: zoomCompensationStub{compensation: 1.25}}
248 wv.zoomFactor.Store(1.3)
249
250 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
251 require.Len(t, scheduled, 1)
252 scheduled[0].Execute()
253 require.InDelta(t, cefZoomFromFactor(1.625), host.zoomLevel, 1e-9)
254
255 // Moving to a different output scale must reapply the same user zoom under the
256 // new compensation and must not mutate the persisted user zoom.
257 host.calls = nil
258 scheduled = nil
259 wv.zoomCompensation = zoomCompensationStub{compensation: 2.0}
260
261 wv.syncZoomForBackingScaleOnCEFUIThread(host, "gtk-size-observer")
262 require.Len(t, scheduled, 1)
263 scheduled[0].Execute()
264 require.InDelta(t, cefZoomFromFactor(2.6), host.zoomLevel, 1e-9)
265 require.InDelta(t, 1.3, wv.GetZoomLevel(), 1e-9, "user zoom must survive an output transition")
266 require.Equal(t, []string{"SetZoomLevel", "NotifyScreenInfoChanged"}, host.calls)
267}
268
269func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsStaleHostAndDestroyedView(t *testing.T) {
270 oldNewTask := cefNewTask
271 oldPostDelayedTask := cefPostDelayedTask
272 defer func() {
273 cefNewTask = oldNewTask
274 cefPostDelayedTask = oldPostDelayedTask
275 }()
276
277 cefNewTask = func(task purecef.Task) purecef.Task { return task }
278
279 var scheduled []purecef.Task
280 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
281 scheduled = append(scheduled, task)
282 return 1
283 }
284
285 capturedHost := &viewportSyncOrderHost{}
286 currentHost := &viewportSyncOrderHost{}
287 wv := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}}
288
289 wv.syncZoomForBackingScaleOnCEFUIThread(capturedHost, "host-replaced")
290 require.Len(t, scheduled, 1)
291 scheduled[0].Execute()
292 require.Empty(t, capturedHost.calls)
293 require.Empty(t, currentHost.calls)
294
295 destroyed := &WebView{ctx: context.Background(), host: currentHost, zoomCompensation: zoomCompensationStub{compensation: 1.25}}
296 destroyed.destroyed.Store(true)
297 scheduled = nil
298 destroyed.syncZoomForBackingScaleOnCEFUIThread(currentHost, "destroyed")
299 require.Len(t, scheduled, 1)
300 scheduled[0].Execute()
301 require.Empty(t, currentHost.calls)
302}
303
304func TestSyncZoomForBackingScaleOnCEFUIThread_SkipsMissingHost(t *testing.T) {
305 wv := &WebView{ctx: context.Background(), zoomCompensation: zoomCompensationStub{compensation: 1.25}}
306 wv.syncZoomForBackingScaleOnCEFUIThread(nil, "missing-host")
307 require.Zero(t, wv.appliedZoomCompensation())
308}
309
310func TestNotifyViewportSyncOnCEFUIThread_SkipsStaleHost(t *testing.T) {
311 oldNewTask := cefNewTask
312 oldPostDelayedTask := cefPostDelayedTask
313 defer func() {
314 cefNewTask = oldNewTask
315 cefPostDelayedTask = oldPostDelayedTask
316 }()
317
318 cefNewTask = func(task purecef.Task) purecef.Task { return task }
319
320 var scheduled purecef.Task
321 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
322 scheduled = task
323 return 1
324 }
325
326 capturedHost := &viewportSyncOrderHost{}
327 currentHost := &viewportSyncOrderHost{}
328 wv := &WebView{ctx: context.Background(), host: currentHost}
329 wv.notifyViewportSyncOnCEFUIThread(capturedHost, true)
330
331 require.NotNil(t, scheduled)
332 scheduled.Execute()
333 require.Empty(t, capturedHost.calls)
334 require.Empty(t, currentHost.calls)
335}
336
337func TestNotifyViewportSyncOnCEFUIThread_SkipsDestroyedWebView(t *testing.T) {
338 oldNewTask := cefNewTask
339 oldPostDelayedTask := cefPostDelayedTask
340 defer func() {
341 cefNewTask = oldNewTask
342 cefPostDelayedTask = oldPostDelayedTask
343 }()
344
345 cefNewTask = func(task purecef.Task) purecef.Task { return task }
346
347 var scheduled purecef.Task
348 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
349 scheduled = task
350 return 1
351 }
352
353 host := &viewportSyncOrderHost{}
354 wv := &WebView{ctx: context.Background(), host: host}
355 wv.destroyed.Store(true)
356 wv.notifyViewportSyncOnCEFUIThread(host, true)
357
358 require.NotNil(t, scheduled)
359 scheduled.Execute()
360 require.Empty(t, host.calls)
361}
362
363func TestObservedViewportSizeReady(t *testing.T) {
364 tests := []struct {
365 name string
366 observedWidth int32
367 observedHeight int32
368 allocatedWidth int32
369 allocatedHeight int32
370 want bool
371 }{
372 {
373 name: "exact match",
374 observedWidth: 793,
375 observedHeight: 1753,
376 allocatedWidth: 793,
377 allocatedHeight: 1753,
378 want: true,
379 },
380 {
381 name: "one pixel drift is accepted",
382 observedWidth: 792,
383 observedHeight: 1754,
384 allocatedWidth: 793,
385 allocatedHeight: 1753,
386 want: true,
387 },
388 {
389 name: "fallback observed size is rejected",
390 observedWidth: 1,
391 observedHeight: 1,
392 allocatedWidth: 793,
393 allocatedHeight: 1753,
394 want: false,
395 },
396 {
397 name: "missing allocation is rejected",
398 observedWidth: 793,
399 observedHeight: 1753,
400 allocatedWidth: 0,
401 allocatedHeight: 1753,
402 want: false,
403 },
404 {
405 name: "large mismatch is rejected",
406 observedWidth: 1200,
407 observedHeight: 1400,
408 allocatedWidth: 1587,
409 allocatedHeight: 1679,
410 want: false,
411 },
412 }
413
414 for _, tt := range tests {
415 t.Run(tt.name, func(t *testing.T) {
416 require.Equal(t, tt.want, observedViewportSizeReady(tt.observedWidth, tt.observedHeight, tt.allocatedWidth, tt.allocatedHeight))
417 })
418 }
419}
420
421func TestPendingBrowserCreateObservedSize_RefreshesStaleFallbackSize(t *testing.T) {
422 bridge := &pendingBrowserCreateObservedSizeBridgeStub{
423 sizeWidth: 1,
424 sizeHeight: 1,
425 refreshWidth: 1055,
426 refreshHeight: 1719,
427 refreshProvided: true,
428 }
429
430 observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
431
432 require.True(t, ready)
433 require.Equal(t, int32(1055), observedWidth)
434 require.Equal(t, int32(1719), observedHeight)
435 require.Equal(t, 1, bridge.refreshCalls)
436}
437
438func TestPendingBrowserCreateObservedSize_SkipsRefreshWhenObservedSizeAlreadyMatches(t *testing.T) {
439 bridge := &pendingBrowserCreateObservedSizeBridgeStub{sizeWidth: 1055, sizeHeight: 1719}
440
441 observedWidth, observedHeight, ready := pendingBrowserCreateObservedSize(bridge, 1055, 1719)
442
443 require.True(t, ready)
444 require.Equal(t, int32(1055), observedWidth)
445 require.Equal(t, int32(1719), observedHeight)
446 require.Equal(t, 0, bridge.refreshCalls)
447}
448
449func TestShouldDeferPendingBrowserCreateFromViewportSync_WaitsForNativePopupAttach(t *testing.T) {
450 wv := &WebView{pendingCreate: &pendingBrowserCreate{}, nativePopupCandidate: true}
451
452 require.True(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
453
454 wv.nativePopupFallbackStarted = true
455 require.False(t, wv.shouldDeferPendingBrowserCreateFromViewportSync())
456}
457
458func TestPreparePendingBrowserCreateObservedSizeRetry_CoalescesScheduling(t *testing.T) {
459 oldScheduleAfter := cefScheduleAfter
460 defer func() { cefScheduleAfter = oldScheduleAfter }()
461
462 scheduleCalls := 0
463 cefScheduleAfter = func(delay time.Duration, fn func()) {
464 require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
465 require.NotNil(t, fn)
466 scheduleCalls++
467 }
468
469 wv := &WebView{pendingCreate: &pendingBrowserCreate{}}
470
471 attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
472 require.Equal(t, 1, attempt)
473 require.Equal(t, pendingBrowserCreateObservedSizeRetryScheduled, action)
474 require.Equal(t, 1, scheduleCalls)
475
476 attempt, action = wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
477 require.Equal(t, 1, attempt)
478 require.Equal(t, pendingBrowserCreateObservedSizeRetryAlreadyScheduled, action)
479 require.Equal(t, 1, scheduleCalls)
480}
481
482func TestPreparePendingBrowserCreateObservedSizeRetry_MaxRetriesProceedsWithoutScheduling(t *testing.T) {
483 oldScheduleAfter := cefScheduleAfter
484 defer func() { cefScheduleAfter = oldScheduleAfter }()
485
486 scheduleCalls := 0
487 cefScheduleAfter = func(delay time.Duration, fn func()) {
488 require.Equal(t, pendingBrowserCreateObservedSizeRetryDelay, delay)
489 require.NotNil(t, fn)
490 scheduleCalls++
491 }
492
493 wv := &WebView{pendingCreate: &pendingBrowserCreate{observedSizeRetries: pendingBrowserCreateObservedSizeMaxRetries}}
494
495 attempt, action := wv.preparePendingBrowserCreateObservedSizeRetry(context.Background(), "test")
496 require.Equal(t, pendingBrowserCreateObservedSizeMaxRetries+1, attempt)
497 require.Equal(t, pendingBrowserCreateObservedSizeRetryProceedWithoutDelay, action)
498 require.Equal(t, 0, scheduleCalls)
499 require.False(t, wv.pendingCreate.observedSizeRetryScheduled)
500}
501
502func TestScheduleResizeRepaintPulse_CoalescesToLatestSequence(t *testing.T) {
503 oldNewTask := cefNewTask
504 oldPostDelayedTask := cefPostDelayedTask
505 defer func() {
506 cefNewTask = oldNewTask
507 cefPostDelayedTask = oldPostDelayedTask
508 }()
509
510 cefNewTask = func(task purecef.Task) purecef.Task { return task }
511
512 var scheduled []purecef.Task
513 var delays []int64
514 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
515 require.Equal(t, purecef.ThreadIDTidUi, threadID)
516 require.NotNil(t, task)
517 scheduled = append(scheduled, task)
518 delays = append(delays, delayMs)
519 return 1
520 }
521
522 host := &viewportSyncOrderHost{}
523 wv := &WebView{ctx: context.Background(), host: host}
524
525 wv.scheduleResizeRepaintPulse(context.Background(), "first")
526 wv.scheduleResizeRepaintPulse(context.Background(), "second")
527
528 require.Equal(t, []int64{16, 48, 16, 48}, delays)
529 require.Len(t, scheduled, 4)
530
531 scheduled[0].Execute()
532 scheduled[1].Execute()
533 require.Empty(t, host.calls)
534
535 scheduled[2].Execute()
536 scheduled[3].Execute()
537 require.Equal(t, []string{
538 "NotifyScreenInfoChanged",
539 "WasResized",
540 "Invalidate",
541 "NotifyScreenInfoChanged",
542 "WasResized",
543 "Invalidate",
544 }, host.calls)
545}
546
547func TestScheduleResizeRepaintPulse_SkipsStaleHost(t *testing.T) {
548 oldNewTask := cefNewTask
549 oldPostDelayedTask := cefPostDelayedTask
550 defer func() {
551 cefNewTask = oldNewTask
552 cefPostDelayedTask = oldPostDelayedTask
553 }()
554
555 cefNewTask = func(task purecef.Task) purecef.Task { return task }
556
557 var scheduled []purecef.Task
558 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 {
559 require.Equal(t, purecef.ThreadIDTidUi, threadID)
560 require.NotNil(t, task)
561 scheduled = append(scheduled, task)
562 return 1
563 }
564
565 capturedHost := &viewportSyncOrderHost{}
566 currentHost := &viewportSyncOrderHost{}
567 wv := &WebView{ctx: context.Background(), host: capturedHost}
568
569 wv.scheduleResizeRepaintPulse(context.Background(), "stale-host")
570 wv.host = currentHost
571
572 require.Len(t, scheduled, 2)
573 scheduled[0].Execute()
574 scheduled[1].Execute()
575
576 require.Empty(t, capturedHost.calls)
577 require.Empty(t, currentHost.calls)
578}
579
580func TestScheduleResizeRepaintPulse_SkipsWhenHostNilAtScheduleTime(t *testing.T) {
581 oldNewTask := cefNewTask
582 oldPostDelayedTask := cefPostDelayedTask
583 defer func() {
584 cefNewTask = oldNewTask
585 cefPostDelayedTask = oldPostDelayedTask
586 }()
587
588 cefNewTask = func(task purecef.Task) purecef.Task { return task }
589
590 var scheduled []purecef.Task
591 cefPostDelayedTask = func(_ purecef.ThreadID, task purecef.Task, _ int64) int32 {
592 scheduled = append(scheduled, task)
593 return 1
594 }
595
596 wv := &WebView{ctx: context.Background()}
597
598 wv.scheduleResizeRepaintPulse(context.Background(), "nil-host")
599
600 require.Empty(t, scheduled)
601}
602
603func TestScheduleResizeRepaintPulse_SkipsWhenHostClearedBeforeExecution(t *testing.T) {
604 oldNewTask := cefNewTask
605 oldPostDelayedTask := cefPostDelayedTask
606 defer func() {
607 cefNewTask = oldNewTask
608 cefPostDelayedTask = oldPostDelayedTask
609 }()
610
611 cefNewTask = func(task purecef.Task) purecef.Task { return task }
612
613 var scheduled []purecef.Task
614 cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, _ int64) int32 {
615 require.Equal(t, purecef.ThreadIDTidUi, threadID)
616 require.NotNil(t, task)
617 scheduled = append(scheduled, task)
618 return 1
619 }
620
621 host := &viewportSyncOrderHost{}
622 wv := &WebView{ctx: context.Background(), host: host}
623
624 wv.scheduleResizeRepaintPulse(context.Background(), "nil-host-before-execute")
625 wv.host = nil
626
627 require.Len(t, scheduled, 2)
628 scheduled[0].Execute()
629 scheduled[1].Execute()
630
631 require.Empty(t, host.calls)
632}