detached dc2703a

test(ui): cover JS dialog origin truncation, text caps and pane cleanup

commit
dc2703aaf3f1e8233a35f01a1ecbdb00a34c3d64
author
brice · · 2026-10-03 07:02 UTC
committer
brice · · 2026-10-03 07:50 UTC
parent
1982d1a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +131 -1
M internal/ui/component/jsdialog_popup_test.go +20 -1
@@ -15,10 +15,29 @@ func TestJSDialogOriginLabel(t *testing.T) {
1515 assert.Equal(t, "This page", jsDialogOriginLabel(""))
1616 assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
1717 assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18- long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".com/")
18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".example.com/")
1919 assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
2020 }
2121
22+func TestJSDialogOriginLabelKeepsRegistrableDomain(t *testing.T) {
23+ // A long attacker-controlled prefix must not push the real domain out.
24+ label := jsDialogOriginLabel("https://" + strings.Repeat("paypal.com.", 20) + "evil.test/")
25+ assert.True(t, strings.HasPrefix(label, "…"))
26+ assert.True(t, strings.HasSuffix(label, "evil.test"))
27+}
28+
29+func TestBuildJSDialogContentCapsPageText(t *testing.T) {
30+ huge := strings.Repeat("x", jsDialogMaxTextRunes*3)
31+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Message: huge})
32+ assert.LessOrEqual(t, len([]rune(c.Body)), jsDialogMaxTextRunes+1)
33+}
34+
35+func TestBuildJSDialogContentBeforeUnloadUsesFixedText(t *testing.T) {
36+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: "https://example.com", Message: "page text"})
37+ assert.Contains(t, c.Body, jsDialogBeforeUnloadText)
38+ assert.NotContains(t, c.Body, "page text")
39+}
40+
2241 func TestBuildJSDialogContent(t *testing.T) {
2342 origin := "https://example.com/x"
2443
@@ -15,10 +15,29 @@ func TestJSDialogOriginLabel(t *testing.T) {
15 assert.Equal(t, "This page", jsDialogOriginLabel(""))15 assert.Equal(t, "This page", jsDialogOriginLabel(""))
16 assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))16 assert.Equal(t, "This page", jsDialogOriginLabel("not a url"))
17 assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))17 assert.Equal(t, "file:", jsDialogOriginLabel("file:///tmp/x.html"))
18- long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".com/")18+ long := jsDialogOriginLabel("https://" + strings.Repeat("a", 500) + ".example.com/")
19 assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)19 assert.LessOrEqual(t, len([]rune(long)), jsDialogMaxHostChars+1)
20 }20 }
21 21
22+func TestJSDialogOriginLabelKeepsRegistrableDomain(t *testing.T) {
23+ // A long attacker-controlled prefix must not push the real domain out.
24+ label := jsDialogOriginLabel("https://" + strings.Repeat("paypal.com.", 20) + "evil.test/")
25+ assert.True(t, strings.HasPrefix(label, "…"))
26+ assert.True(t, strings.HasSuffix(label, "evil.test"))
27+}
28+
29+func TestBuildJSDialogContentCapsPageText(t *testing.T) {
30+ huge := strings.Repeat("x", jsDialogMaxTextRunes*3)
31+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogAlert, Message: huge})
32+ assert.LessOrEqual(t, len([]rune(c.Body)), jsDialogMaxTextRunes+1)
33+}
34+
35+func TestBuildJSDialogContentBeforeUnloadUsesFixedText(t *testing.T) {
36+ c := buildJSDialogContent(port.JSDialogRequest{Type: port.JSDialogBeforeUnload, Origin: "https://example.com", Message: "page text"})
37+ assert.Contains(t, c.Body, jsDialogBeforeUnloadText)
38+ assert.NotContains(t, c.Body, "page text")
39+}
40+
22 func TestBuildJSDialogContent(t *testing.T) {41 func TestBuildJSDialogContent(t *testing.T) {
23 origin := "https://example.com/x"42 origin := "https://example.com/x"
24 43
A internal/ui/component/pane_view_jsdialog_test.go +111 -0
@@ -0,0 +1,111 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+// fakeJSDialogView records calls made by PaneView.
17+type fakeJSDialogView struct {
18+ respond func(bool, string)
19+ focus bool
20+ shown int
21+ focusRequests int
22+ destroyed bool
23+ hiddenCleared bool
24+ dismissedWith []bool
25+}
26+
27+func (f *fakeJSDialogView) Widget() *gtk.Widget { return nil }
28+func (f *fakeJSDialogView) Show(_ port.JSDialogRequest, respond func(bool, string), focus bool, _ float64) {
29+ f.shown++
30+ f.respond, f.focus = respond, focus
31+}
32+func (f *fakeJSDialogView) Hide() {}
33+func (f *fakeJSDialogView) RequestFocus() { f.focusRequests++ }
34+func (f *fakeJSDialogView) SetOnHidden(fn func(bool)) {
35+ if fn == nil {
36+ f.hiddenCleared = true
37+ }
38+}
39+func (f *fakeJSDialogView) Dismiss(ok bool) {
40+ f.dismissedWith = append(f.dismissedWith, ok)
41+ if f.respond != nil {
42+ r := f.respond
43+ f.respond = nil
44+ r(ok, "")
45+ }
46+}
47+func (f *fakeJSDialogView) Destroy() { f.destroyed = true }
48+
49+func newJSDialogPaneView(t *testing.T, fake *fakeJSDialogView) *PaneView {
50+ t.Helper()
51+ overlay := mocks.NewMockOverlayWidget(t)
52+ factory := mocks.NewMockWidgetFactory(t)
53+ wrapped := mocks.NewMockWidget(t)
54+ factory.EXPECT().WrapWidget(mock.Anything).Return(wrapped).Maybe()
55+ overlay.EXPECT().AddOverlay(wrapped).Maybe()
56+ overlay.EXPECT().SetClipOverlay(wrapped, true).Maybe()
57+ overlay.EXPECT().SetMeasureOverlay(wrapped, false).Maybe()
58+ overlay.EXPECT().RemoveOverlay(wrapped).Maybe()
59+ pv := &PaneView{
60+ overlay: overlay,
61+ factory: factory,
62+ newJSDialog: func(layout.OverlayWidget) jsDialogView {
63+ return fake
64+ },
65+ }
66+ return pv
67+}
68+
69+func TestPaneViewCleanupAnswersOpenJSDialog(t *testing.T) {
70+ fake := &fakeJSDialogView{}
71+ pv := newJSDialogPaneView(t, fake)
72+
73+ var answers []bool
74+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(ok bool, _ string) { answers = append(answers, ok) }, 1))
75+
76+ pv.Cleanup()
77+
78+ assert.Equal(t, []bool{false}, answers, "page must be answered, not left blocked")
79+ assert.True(t, fake.hiddenCleared, "hidden callback detached before dismissing")
80+ assert.True(t, fake.destroyed)
81+ assert.Nil(t, pv.jsDialog)
82+
83+ // Cleanup again is a no-op.
84+ pv.Cleanup()
85+ assert.Equal(t, []bool{false}, answers)
86+}
87+
88+func TestPaneViewShowJSDialogFocusFollowsActive(t *testing.T) {
89+ fake := &fakeJSDialogView{}
90+ pv := newJSDialogPaneView(t, fake)
91+
92+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
93+ assert.False(t, fake.focus, "inactive pane's dialog must not steal focus")
94+
95+ pv.isActive = true
96+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
97+ assert.True(t, fake.focus)
98+}
99+
100+func TestPaneViewSetActiveFocusesPendingJSDialog(t *testing.T) {
101+ fake := &fakeJSDialogView{}
102+ pv := newJSDialogPaneView(t, fake)
103+ border := mocks.NewMockBoxWidget(t)
104+ border.EXPECT().AddCssClass(activePaneClass).Once()
105+ pv.borderBox = border
106+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
107+
108+ pv.SetActive(true)
109+
110+ assert.Equal(t, 1, fake.focusRequests)
111+}
@@ -0,0 +1,111 @@
1+package component
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/puregotk/v4/gtk"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+ "github.com/bnema/dumber/internal/ui/layout/mocks"
14+)
15+
16+// fakeJSDialogView records calls made by PaneView.
17+type fakeJSDialogView struct {
18+ respond func(bool, string)
19+ focus bool
20+ shown int
21+ focusRequests int
22+ destroyed bool
23+ hiddenCleared bool
24+ dismissedWith []bool
25+}
26+
27+func (f *fakeJSDialogView) Widget() *gtk.Widget { return nil }
28+func (f *fakeJSDialogView) Show(_ port.JSDialogRequest, respond func(bool, string), focus bool, _ float64) {
29+ f.shown++
30+ f.respond, f.focus = respond, focus
31+}
32+func (f *fakeJSDialogView) Hide() {}
33+func (f *fakeJSDialogView) RequestFocus() { f.focusRequests++ }
34+func (f *fakeJSDialogView) SetOnHidden(fn func(bool)) {
35+ if fn == nil {
36+ f.hiddenCleared = true
37+ }
38+}
39+func (f *fakeJSDialogView) Dismiss(ok bool) {
40+ f.dismissedWith = append(f.dismissedWith, ok)
41+ if f.respond != nil {
42+ r := f.respond
43+ f.respond = nil
44+ r(ok, "")
45+ }
46+}
47+func (f *fakeJSDialogView) Destroy() { f.destroyed = true }
48+
49+func newJSDialogPaneView(t *testing.T, fake *fakeJSDialogView) *PaneView {
50+ t.Helper()
51+ overlay := mocks.NewMockOverlayWidget(t)
52+ factory := mocks.NewMockWidgetFactory(t)
53+ wrapped := mocks.NewMockWidget(t)
54+ factory.EXPECT().WrapWidget(mock.Anything).Return(wrapped).Maybe()
55+ overlay.EXPECT().AddOverlay(wrapped).Maybe()
56+ overlay.EXPECT().SetClipOverlay(wrapped, true).Maybe()
57+ overlay.EXPECT().SetMeasureOverlay(wrapped, false).Maybe()
58+ overlay.EXPECT().RemoveOverlay(wrapped).Maybe()
59+ pv := &PaneView{
60+ overlay: overlay,
61+ factory: factory,
62+ newJSDialog: func(layout.OverlayWidget) jsDialogView {
63+ return fake
64+ },
65+ }
66+ return pv
67+}
68+
69+func TestPaneViewCleanupAnswersOpenJSDialog(t *testing.T) {
70+ fake := &fakeJSDialogView{}
71+ pv := newJSDialogPaneView(t, fake)
72+
73+ var answers []bool
74+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(ok bool, _ string) { answers = append(answers, ok) }, 1))
75+
76+ pv.Cleanup()
77+
78+ assert.Equal(t, []bool{false}, answers, "page must be answered, not left blocked")
79+ assert.True(t, fake.hiddenCleared, "hidden callback detached before dismissing")
80+ assert.True(t, fake.destroyed)
81+ assert.Nil(t, pv.jsDialog)
82+
83+ // Cleanup again is a no-op.
84+ pv.Cleanup()
85+ assert.Equal(t, []bool{false}, answers)
86+}
87+
88+func TestPaneViewShowJSDialogFocusFollowsActive(t *testing.T) {
89+ fake := &fakeJSDialogView{}
90+ pv := newJSDialogPaneView(t, fake)
91+
92+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
93+ assert.False(t, fake.focus, "inactive pane's dialog must not steal focus")
94+
95+ pv.isActive = true
96+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
97+ assert.True(t, fake.focus)
98+}
99+
100+func TestPaneViewSetActiveFocusesPendingJSDialog(t *testing.T) {
101+ fake := &fakeJSDialogView{}
102+ pv := newJSDialogPaneView(t, fake)
103+ border := mocks.NewMockBoxWidget(t)
104+ border.EXPECT().AddCssClass(activePaneClass).Once()
105+ pv.borderBox = border
106+ require.True(t, pv.ShowJSDialog(port.JSDialogRequest{}, func(bool, string) {}, 1))
107+
108+ pv.SetActive(true)
109+
110+ assert.Equal(t, 1, fake.focusRequests)
111+}

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