1package cef
2
3import (
4 "context"
5 "testing"
6
7 purecef "github.com/bnema/purego-cef/cef"
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/dumber/internal/application/port"
11)
12
13type stubJSDialogCallback struct {
14 calls []jsDialogAnswer
15 // onCont mimics CEF's DialogClosed ordering: OnDialogClosed and
16 // OnResetDialogState are delivered from inside Cont.
17 onCont func()
18}
19
20type jsDialogAnswer struct {
21 ok bool
22 input string
23}
24
25func (s *stubJSDialogCallback) Cont(success int32, userInput string) {
26 s.calls = append(s.calls, jsDialogAnswer{ok: success != 0, input: userInput})
27 if s.onCont != nil {
28 s.onCont()
29 }
30}
31
32// echoCEFClose makes cb deliver CEF's DialogClosed + reset echo from Cont.
33func echoCEFClose(h *handlerSet, cb *stubJSDialogCallback) {
34 cb.onCont = func() {
35 h.OnDialogClosed(nil)
36 h.OnResetDialogState(nil)
37 }
38}
39
40// useDirectJSDialogContinue answers stub callbacks inline (no CEF UI thread).
41func useDirectJSDialogContinue(t *testing.T) {
42 t.Helper()
43 prev := continueCEFJSDialog
44 continueCEFJSDialog = func(cb purecef.JsdialogCallback, ok bool, input string) {
45 success := int32(0)
46 if ok {
47 success = 1
48 }
49 cb.Cont(success, input)
50 }
51 t.Cleanup(func() { continueCEFJSDialog = prev })
52}
53
54type jsDialogUIRecorder struct {
55 reqs []port.JSDialogRequest
56 respond []func(bool, string)
57 resets int
58 handled bool
59}
60
61func newJSDialogWebView(ui *jsDialogUIRecorder) *WebView {
62 return &WebView{
63 ctx: context.Background(),
64 callbacks: &port.WebViewCallbacks{
65 OnJSDialog: func(req port.JSDialogRequest, respond func(bool, string)) bool {
66 ui.reqs = append(ui.reqs, req)
67 ui.respond = append(ui.respond, respond)
68 return ui.handled
69 },
70 OnJSDialogReset: func() { ui.resets++ },
71 },
72 }
73}
74
75func TestGetJsdialogHandlerEnabled(t *testing.T) {
76 h := &handlerSet{}
77 require.Same(t, h, h.GetJsdialogHandler())
78}
79
80func TestJSDialogTypeMapping(t *testing.T) {
81 cases := map[purecef.JsdialogType]port.JSDialogType{
82 purecef.JsdialogTypeJsdialogtypeAlert: port.JSDialogAlert,
83 purecef.JsdialogTypeJsdialogtypeConfirm: port.JSDialogConfirm,
84 purecef.JsdialogTypeJsdialogtypePrompt: port.JSDialogPrompt,
85 }
86 for in, want := range cases {
87 got, ok := jsDialogTypeFromCEF(in)
88 require.True(t, ok)
89 require.Equal(t, want, got)
90 }
91 _, ok := jsDialogTypeFromCEF(purecef.JsdialogTypeJsdialogtypeNumValues)
92 require.False(t, ok)
93}
94
95func TestOnJsdialogForwardsRequestAndRespondsOnce(t *testing.T) {
96 useDirectJSDialogContinue(t)
97 ui := &jsDialogUIRecorder{handled: true}
98 h := &handlerSet{wv: newJSDialogWebView(ui)}
99 cb := &stubJSDialogCallback{}
100 var suppress int32
101
102 handled := h.OnJsdialog(nil, "https://example.com/p", purecef.JsdialogTypeJsdialogtypePrompt, "Name?", "bob", cb, &suppress)
103
104 require.Equal(t, int32(1), handled)
105 require.Zero(t, suppress)
106 require.Equal(t, []port.JSDialogRequest{{
107 Type: port.JSDialogPrompt, Origin: "https://example.com/p", Message: "Name?", DefaultPrompt: "bob",
108 }}, ui.reqs)
109 require.Empty(t, cb.calls)
110
111 ui.respond[0](true, "alice")
112 ui.respond[0](false, "late") // ignored: exactly once
113 require.Equal(t, []jsDialogAnswer{{ok: true, input: "alice"}}, cb.calls)
114}
115
116func TestOnJsdialogDropsInputForNonPrompt(t *testing.T) {
117 useDirectJSDialogContinue(t)
118 ui := &jsDialogUIRecorder{handled: true}
119 h := &handlerSet{wv: newJSDialogWebView(ui)}
120 cb := &stubJSDialogCallback{}
121
122 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "ok?", "", cb, new(int32))
123 ui.respond[0](true, "ignored")
124
125 require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
126}
127
128func TestOnJsdialogWithoutUICallbackUsesDefault(t *testing.T) {
129 cb := &stubJSDialogCallback{}
130 var suppress int32
131
132 for _, h := range []*handlerSet{
133 nil,
134 {},
135 {wv: &WebView{ctx: context.Background()}},
136 {wv: &WebView{ctx: context.Background(), callbacks: &port.WebViewCallbacks{}}},
137 } {
138 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
139 require.False(t, h.OnBeforeUnloadDialog(nil, "m", 0, cb))
140 }
141 require.Zero(t, suppress)
142 require.Empty(t, cb.calls)
143}
144
145func TestOnJsdialogRejectsNilCallbackAndUnknownType(t *testing.T) {
146 ui := &jsDialogUIRecorder{handled: true}
147 h := &handlerSet{wv: newJSDialogWebView(ui)}
148
149 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", nil, new(int32)))
150 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeNumValues, "m", "", &stubJSDialogCallback{}, new(int32)))
151 require.Empty(t, ui.reqs)
152}
153
154func TestOnJsdialogUINotHandledCancels(t *testing.T) {
155 useDirectJSDialogContinue(t)
156 ui := &jsDialogUIRecorder{handled: false}
157 h := &handlerSet{wv: newJSDialogWebView(ui)}
158 cb := &stubJSDialogCallback{}
159
160 require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "m", "", cb, new(int32)))
161 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
162}
163
164func TestOnJsdialogSuppressesWhileDialogOpen(t *testing.T) {
165 useDirectJSDialogContinue(t)
166 ui := &jsDialogUIRecorder{handled: true}
167 h := &handlerSet{wv: newJSDialogWebView(ui)}
168 first := &stubJSDialogCallback{}
169 second := &stubJSDialogCallback{}
170 var suppress int32
171
172 require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32)))
173 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, &suppress))
174 require.Equal(t, int32(1), suppress)
175 require.Len(t, ui.reqs, 1)
176 require.Empty(t, second.calls)
177
178 // Once answered, new dialogs are allowed again.
179 ui.respond[0](true, "")
180 require.Equal(t, int32(1), h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "3", "", second, new(int32)))
181 require.Len(t, ui.reqs, 2)
182}
183
184func TestOnBeforeUnloadDialog(t *testing.T) {
185 useDirectJSDialogContinue(t)
186 ui := &jsDialogUIRecorder{handled: true}
187 wv := newJSDialogWebView(ui)
188 wv.uri = "https://example.com/edit"
189 h := &handlerSet{wv: wv}
190 cb := &stubJSDialogCallback{}
191
192 require.True(t, h.OnBeforeUnloadDialog(nil, "unsaved", 1, cb))
193 // CEF's fixed message text is not forwarded.
194 require.Equal(t, []port.JSDialogRequest{{
195 Type: port.JSDialogBeforeUnload, Origin: "https://example.com/edit", IsReload: true,
196 }}, ui.reqs)
197
198 // A second beforeunload while open is answered "stay" immediately.
199 other := &stubJSDialogCallback{}
200 require.True(t, h.OnBeforeUnloadDialog(nil, "again", 0, other))
201 require.Equal(t, []jsDialogAnswer{{ok: false}}, other.calls)
202 require.Len(t, ui.reqs, 1)
203
204 ui.respond[0](true, "")
205 require.Equal(t, []jsDialogAnswer{{ok: true}}, cb.calls)
206}
207
208func TestOnResetDialogStateResolvesPendingAndClosesUI(t *testing.T) {
209 useDirectJSDialogContinue(t)
210 ui := &jsDialogUIRecorder{handled: true}
211 h := &handlerSet{wv: newJSDialogWebView(ui)}
212 cb := &stubJSDialogCallback{}
213 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypePrompt, "m", "d", cb, new(int32))
214
215 h.OnResetDialogState(nil)
216
217 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
218 require.Equal(t, 1, ui.resets)
219
220 // A late UI answer after reset must not resolve twice.
221 ui.respond[0](true, "late")
222 require.Len(t, cb.calls, 1)
223
224 // Reset with nothing pending is a no-op and does not touch the UI.
225 h.OnResetDialogState(nil)
226 require.Equal(t, 1, ui.resets)
227 h.OnDialogClosed(nil)
228}
229
230func TestDestroyCancelsPendingJSDialog(t *testing.T) {
231 useDirectJSDialogContinue(t)
232 ui := &jsDialogUIRecorder{handled: true}
233 wv := newJSDialogWebView(ui)
234 h := &handlerSet{wv: wv}
235 cb := &stubJSDialogCallback{}
236 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
237
238 wv.Destroy()
239
240 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
241 require.Equal(t, 1, ui.resets)
242}
243
244func TestJSDialogCancelledBeforeUIRunsIsNotShown(t *testing.T) {
245 useDirectJSDialogContinue(t)
246 ui := &jsDialogUIRecorder{handled: true}
247 wv := newJSDialogWebView(ui)
248 cb := &stubJSDialogCallback{}
249 call, begin := wv.jsDialogs.begin(cb)
250 require.Equal(t, jsDialogBeginOK, begin)
251 wv.jsDialogs.resolve(call, false, "")
252
253 wv.presentJSDialog(call, port.JSDialogRequest{}, wv.callbacks)
254
255 require.Empty(t, ui.reqs)
256 require.Len(t, cb.calls, 1)
257}
258
259// useQueuedGTK makes runOnGTK queue callbacks; the returned func runs them in
260// order, like the GTK main loop would.
261func useQueuedGTK(t *testing.T, wv *WebView) (drain func()) {
262 t.Helper()
263 var queue []func()
264 prev := postToGTK
265 postToGTK = func(fn func()) { queue = append(queue, fn) }
266 t.Cleanup(func() { postToGTK = prev })
267 wv.engine = &Engine{}
268 return func() {
269 for len(queue) > 0 {
270 fn := queue[0]
271 queue = queue[1:]
272 fn()
273 }
274 }
275}
276
277func TestStaleUIResetSkippedWhenNewerDialogPending(t *testing.T) {
278 useDirectJSDialogContinue(t)
279 ui := &jsDialogUIRecorder{handled: true}
280 wv := newJSDialogWebView(ui)
281 drainGTK := useQueuedGTK(t, wv)
282 h := &handlerSet{wv: wv}
283 old := &stubJSDialogCallback{}
284 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "old", "", old, new(int32))
285 drainGTK()
286 require.Len(t, ui.reqs, 1)
287
288 // Cancel resolves the old dialog; its UI reset is queued for GTK.
289 wv.cancelJSDialogs()
290 require.Equal(t, []jsDialogAnswer{{ok: false}}, old.calls)
291 require.Zero(t, ui.resets, "reset must wait for the GTK queue")
292
293 // A newer dialog arrives before the queued reset runs.
294 newer := &stubJSDialogCallback{}
295 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "new", "", newer, new(int32))
296
297 // GTK runs the stale reset, then presents the newer dialog.
298 drainGTK()
299 require.Len(t, ui.reqs, 2)
300 require.Zero(t, ui.resets, "stale reset must not hide the newer dialog")
301 require.Empty(t, newer.calls, "newer dialog must stay pending")
302
303 // Canceling the newer dialog resets the UI once GTK runs.
304 wv.cancelJSDialogs()
305 drainGTK()
306 require.Equal(t, []jsDialogAnswer{{ok: false}}, newer.calls)
307 require.Equal(t, 1, ui.resets)
308}
309
310func TestResetEchoAfterSuppressDoesNotCancelOpenDialog(t *testing.T) {
311 useDirectJSDialogContinue(t)
312 ui := &jsDialogUIRecorder{handled: true}
313 h := &handlerSet{wv: newJSDialogWebView(ui)}
314 first := &stubJSDialogCallback{}
315 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
316
317 // A spammer is suppressed; CEF then sends OnResetDialogState.
318 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
319 h.OnResetDialogState(nil)
320
321 require.Empty(t, first.calls, "dialog #1 must stay pending")
322 require.Zero(t, ui.resets)
323
324 // A real reset afterwards still cancels it.
325 h.OnResetDialogState(nil)
326 require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
327 require.Equal(t, 1, ui.resets)
328}
329
330func TestResetEchoAfterRejectedBeforeUnloadDoesNotCancelOpenDialog(t *testing.T) {
331 useDirectJSDialogContinue(t)
332 ui := &jsDialogUIRecorder{handled: true}
333 h := &handlerSet{wv: newJSDialogWebView(ui)}
334 first := &stubJSDialogCallback{}
335 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeConfirm, "1", "", first, new(int32))
336
337 other := &stubJSDialogCallback{}
338 echoCEFClose(h, other)
339 require.True(t, h.OnBeforeUnloadDialog(nil, "", 0, other))
340
341 require.Empty(t, first.calls)
342 require.Zero(t, ui.resets)
343}
344
345func TestCEFCloseEchoDoesNotCancelNewerDialog(t *testing.T) {
346 useDirectJSDialogContinue(t)
347 ui := &jsDialogUIRecorder{handled: true}
348 h := &handlerSet{wv: newJSDialogWebView(ui)}
349 first := &stubJSDialogCallback{}
350 echoCEFClose(h, first)
351 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
352
353 ui.respond[0](true, "") // Cont -> OnDialogClosed + OnResetDialogState (echo)
354 require.Equal(t, []jsDialogAnswer{{ok: true}}, first.calls)
355 require.Zero(t, ui.resets, "echo must not hide the UI")
356
357 // The echo consumed the flag, so a genuine reset on a newer dialog cancels it.
358 second := &stubJSDialogCallback{}
359 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
360 h.OnResetDialogState(nil)
361 require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls)
362}
363
364func TestDeferredResetEchoIsConsumed(t *testing.T) {
365 useDirectJSDialogContinue(t)
366 ui := &jsDialogUIRecorder{handled: true}
367 h := &handlerSet{wv: newJSDialogWebView(ui)}
368 first := &stubJSDialogCallback{} // no synchronous echo
369 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
370 ui.respond[0](true, "")
371
372 // The echo arrives later, while no dialog is open: consumed, UI untouched.
373 h.OnDialogClosed(nil)
374 h.OnResetDialogState(nil)
375 require.Zero(t, ui.resets)
376 require.Len(t, first.calls, 1)
377 require.False(t, h.wv.jsDialogs.consumeIgnoreReset(), "flag must have been consumed")
378}
379
380func TestMainFrameLoadStartCancelsPendingDialog(t *testing.T) {
381 useDirectJSDialogContinue(t)
382 ui := &jsDialogUIRecorder{handled: true}
383 wv := newJSDialogWebView(ui)
384 h := &handlerSet{wv: wv}
385 first := &stubJSDialogCallback{}
386 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
387 // A suppressed spammer makes CEF drop its handler_, so no reset will follow.
388 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", &stubJSDialogCallback{}, new(int32))
389
390 h.OnLoadStart(nil, stubFrame{main: false, url: "https://sub.example.com"}, 0)
391 require.Empty(t, first.calls, "subframe loads must not cancel")
392
393 h.OnLoadStart(nil, stubFrame{main: true, url: "https://example.com/next"}, 0)
394 require.Equal(t, []jsDialogAnswer{{ok: false}}, first.calls)
395 require.Equal(t, 1, ui.resets)
396}
397
398func TestDestroyedStateRejectsNewDialog(t *testing.T) {
399 useDirectJSDialogContinue(t)
400 ui := &jsDialogUIRecorder{handled: true}
401 wv := newJSDialogWebView(ui)
402 h := &handlerSet{wv: wv}
403 wv.cancelJSDialogsClosing(true) // Destroy raced ahead of the destroyed check
404 cb := &stubJSDialogCallback{}
405 var suppress int32
406
407 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
408 require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
409 require.Zero(t, suppress)
410 require.Empty(t, ui.reqs)
411}
412
413func TestNoStaleIgnoreAcrossDialogs(t *testing.T) {
414 useDirectJSDialogContinue(t)
415 ui := &jsDialogUIRecorder{handled: true}
416 h := &handlerSet{wv: newJSDialogWebView(ui)}
417 // Answered without a CEF echo (e.g. reset-then-late-answer path).
418 first := &stubJSDialogCallback{}
419 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "1", "", first, new(int32))
420 ui.respond[0](true, "")
421
422 second := &stubJSDialogCallback{}
423 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "2", "", second, new(int32))
424 h.OnResetDialogState(nil)
425
426 require.Equal(t, []jsDialogAnswer{{ok: false}}, second.calls, "real reset must cancel the new dialog")
427}
428
429func TestDestroyedWebViewDoesNotShowDialogs(t *testing.T) {
430 ui := &jsDialogUIRecorder{handled: true}
431 wv := newJSDialogWebView(ui)
432 wv.destroyed.Store(true)
433 h := &handlerSet{wv: wv}
434 cb := &stubJSDialogCallback{}
435 var suppress int32
436
437 require.Zero(t, h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, &suppress))
438 require.False(t, h.OnBeforeUnloadDialog(nil, "", 0, cb))
439 require.Zero(t, suppress)
440 require.Empty(t, ui.reqs)
441 require.Empty(t, cb.calls)
442}
443
444func TestRenderProcessTerminatedCancelsPendingJSDialog(t *testing.T) {
445 useDirectJSDialogContinue(t)
446 ui := &jsDialogUIRecorder{handled: true}
447 wv := newJSDialogWebView(ui)
448 h := &handlerSet{wv: wv}
449 cb := &stubJSDialogCallback{}
450 h.OnJsdialog(nil, "", purecef.JsdialogTypeJsdialogtypeAlert, "m", "", cb, new(int32))
451
452 h.OnRenderProcessTerminated(nil, purecef.TerminationStatusTsProcessCrashed, 0, "")
453
454 require.Equal(t, []jsDialogAnswer{{ok: false}}, cb.calls)
455 require.Equal(t, 1, ui.resets)
456}