jsdialog_handler_test.go RawHistoryFolder

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}

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 8

Tags 42

↑↓ moveenter openesc close