download_handler_test.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"fmt"
6	"path/filepath"
7	"testing"
8
9	purecef "github.com/bnema/purego-cef/cef"
10	cefmocks "github.com/bnema/purego-cef/cef/mocks"
11	"github.com/stretchr/testify/require"
12
13	"github.com/bnema/dumber/internal/application/port"
14	"github.com/bnema/dumber/internal/application/usecase"
15)
16
17type stubDownloadItem struct {
18	id              uint32
19	url             string
20	suggested       string
21	mimeType        string
22	fullPath        string
23	complete        bool
24	canceled        bool
25	interrupted     bool
26	reason          purecef.DownloadInterruptReason
27	percentComplete int32
28	totalBytes      int64
29	receivedBytes   int64
30}
31
32func (s stubDownloadItem) IsValid() bool       { return true }
33func (s stubDownloadItem) IsInProgress() bool  { return !s.complete && !s.canceled && !s.interrupted }
34func (s stubDownloadItem) IsComplete() bool    { return s.complete }
35func (s stubDownloadItem) IsCanceled() bool    { return s.canceled }
36func (s stubDownloadItem) IsInterrupted() bool { return s.interrupted }
37func (s stubDownloadItem) GetInterruptReason() purecef.DownloadInterruptReason {
38	return s.reason
39}
40func (s stubDownloadItem) GetCurrentSpeed() int64        { return 0 }
41func (s stubDownloadItem) GetPercentComplete() int32     { return s.percentComplete }
42func (s stubDownloadItem) GetTotalBytes() int64          { return s.totalBytes }
43func (s stubDownloadItem) GetReceivedBytes() int64       { return s.receivedBytes }
44func (s stubDownloadItem) GetStartTime() uintptr         { return 0 }
45func (s stubDownloadItem) GetEndTime() uintptr           { return 0 }
46func (s stubDownloadItem) GetFullPath() string           { return s.fullPath }
47func (s stubDownloadItem) GetID() uint32                 { return s.id }
48func (s stubDownloadItem) GetURL() string                { return s.url }
49func (s stubDownloadItem) GetOriginalURL() string        { return s.url }
50func (s stubDownloadItem) GetSuggestedFileName() string  { return s.suggested }
51func (s stubDownloadItem) GetContentDisposition() string { return "" }
52func (s stubDownloadItem) GetMimeType() string           { return s.mimeType }
53func (s stubDownloadItem) IsPaused() bool                { return false }
54
55type stubBeforeDownloadCallback struct {
56	path       string
57	showDialog int32
58}
59
60func (s *stubBeforeDownloadCallback) Cont(downloadPath string, showDialog int32) {
61	s.path = downloadPath
62	s.showDialog = showDialog
63}
64
65type captureDownloadEvents struct {
66	events []port.DownloadEvent
67}
68
69func (c *captureDownloadEvents) OnDownloadEvent(_ context.Context, event port.DownloadEvent) {
70	c.events = append(c.events, event)
71}
72
73func TestCEFDownloadHandlerLifecycle(t *testing.T) {
74	ctx := context.Background()
75	preparer := usecase.NewPrepareDownloadUseCase(nil)
76	events := &captureDownloadEvents{}
77	downloadDir := t.TempDir()
78	handler := newDownloadHandler(downloadDir, events, preparer, nil)
79	callback := &stubBeforeDownloadCallback{}
80
81	item := stubDownloadItem{
82		id:        7,
83		url:       "https://example.com/image.iso",
84		suggested: "image.iso",
85		mimeType:  "application/x-iso9660-image",
86	}
87
88	ok := handler.onBeforeDownload(ctx, nil, item, item.suggested, callback)
89
90	require.True(t, ok)
91	require.Equal(t, filepath.Join(downloadDir, "image.iso"), callback.path)
92	require.Len(t, events.events, 1)
93	require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
94
95	item.complete = true
96	item.fullPath = callback.path
97	handler.onDownloadUpdated(ctx, nil, item, nil)
98	// Repeated completion updates should not emit duplicate finished events.
99	handler.onDownloadUpdated(ctx, nil, item, nil)
100
101	require.Len(t, events.events, 2)
102	require.Equal(t, port.DownloadEventFinished, events.events[1].Type)
103	require.Equal(t, "image.iso", events.events[1].Filename)
104
105	// Active map should be cleaned up after terminal event.
106	handler.mu.Lock()
107	_, inActive := handler.active[7]
108	_, inFinished := handler.finished[7]
109	handler.mu.Unlock()
110	require.False(t, inActive, "completed download should be removed from active map")
111	require.True(t, inFinished, "completed download should be in finished set")
112}
113
114func TestCanDownload_AllowsAllMethods(t *testing.T) {
115	preparer := usecase.NewPrepareDownloadUseCase(nil)
116	handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
117
118	require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "GET"))
119	require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "POST"))
120	require.True(t, handler.canDownload(nil, "https://example.com/file.zip", "PUT"))
121	require.True(t, handler.canDownload(nil, "https://example.com/file.zip", ""))
122}
123
124func TestMarkFinished_SuppressesDuplicatesAfterCleanup(t *testing.T) {
125	preparer := usecase.NewPrepareDownloadUseCase(nil)
126	handler := newDownloadHandler("/tmp/downloads", nil, preparer, nil)
127
128	// Simulate a download that was tracked through onBeforeDownload.
129	handler.mu.Lock()
130	handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
131	handler.mu.Unlock()
132
133	require.True(t, handler.markFinished(42), "first terminal event should succeed")
134
135	// Simulate a concurrent update re-populating active before a duplicate terminal update.
136	handler.mu.Lock()
137	handler.active[42] = cefDownloadState{filename: "test.bin", destination: "/tmp/test.bin", lastProgressPercent: -1}
138	handler.mu.Unlock()
139
140	require.False(t, handler.markFinished(42), "second terminal event should be suppressed")
141
142	// Active map should not contain the entry.
143	handler.mu.Lock()
144	_, inActive := handler.active[42]
145	handler.mu.Unlock()
146	require.False(t, inActive)
147}
148
149func TestCEFDownloadHandlerEmitsProgressOncePerPercent(t *testing.T) {
150	ctx := context.Background()
151	preparer := usecase.NewPrepareDownloadUseCase(nil)
152	events := &captureDownloadEvents{}
153	handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
154	callback := &stubBeforeDownloadCallback{}
155
156	item := stubDownloadItem{
157		id:        11,
158		url:       "https://example.com/archive.zip",
159		suggested: "archive.zip",
160		mimeType:  "application/zip",
161	}
162
163	require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
164
165	item.fullPath = callback.path
166	item.percentComplete = 12
167	item.receivedBytes = 12
168	item.totalBytes = 100
169	handler.onDownloadUpdated(ctx, nil, item, nil)
170	handler.onDownloadUpdated(ctx, nil, item, nil)
171
172	item.percentComplete = 13
173	item.receivedBytes = 13
174	handler.onDownloadUpdated(ctx, nil, item, nil)
175
176	require.Len(t, events.events, 3)
177	require.Equal(t, port.DownloadEventStarted, events.events[0].Type)
178	require.Equal(t, port.DownloadEventProgress, events.events[1].Type)
179	require.InEpsilon(t, 0.12, events.events[1].Progress, 0.0001)
180	require.Equal(t, port.DownloadEventProgress, events.events[2].Type)
181	require.InEpsilon(t, 0.13, events.events[2].Progress, 0.0001)
182}
183
184func TestCEFDownloadInterruptedErrorIncludesCodeAndReason(t *testing.T) {
185	ctx := context.Background()
186	preparer := usecase.NewPrepareDownloadUseCase(nil)
187	events := &captureDownloadEvents{}
188	handler := newDownloadHandler("/tmp/downloads", events, preparer, nil)
189	callback := &stubBeforeDownloadCallback{}
190
191	item := stubDownloadItem{
192		id:        9,
193		url:       "https://example.com/ubuntu.iso",
194		suggested: "ubuntu.iso",
195		mimeType:  "application/x-iso9660-image",
196	}
197
198	require.True(t, handler.onBeforeDownload(ctx, nil, item, item.suggested, callback))
199
200	item.fullPath = callback.path
201	item.interrupted = true
202	item.reason = purecef.DownloadInterruptReasonServerBadContent
203	handler.onDownloadUpdated(ctx, nil, item, nil)
204
205	require.Len(t, events.events, 2)
206	require.Error(t, events.events[1].Error)
207	expectedCode := fmt.Sprintf("code=%d", purecef.DownloadInterruptReasonServerBadContent)
208	require.ErrorContains(t, events.events[1].Error, expectedCode)
209	require.ErrorContains(t, events.events[1].Error, "reason=server_bad_content")
210}
211
212func TestDownloadHandlerTracksOwnersByBrowserAndID(t *testing.T) {
213	ctx := context.Background()
214	preparer := usecase.NewPrepareDownloadUseCase(nil)
215	events := &captureDownloadEvents{}
216	tracker := NewRuntimeActivityTracker()
217	handler := newDownloadHandler(t.TempDir(), events, preparer, tracker)
218	callback := &stubBeforeDownloadCallback{}
219
220	first := cefmocks.NewMockBrowser(t)
221	first.EXPECT().GetIdentifier().Return(int32(7))
222	second := cefmocks.NewMockBrowser(t)
223	second.EXPECT().GetIdentifier().Return(int32(9))
224
225	// Same filename from two owners are distinct activity entries.
226	require.True(t, handler.onBeforeDownload(ctx, first,
227		stubDownloadItem{id: 11, url: "https://example.com/a.bin", suggested: "a.bin"}, "a.bin", callback))
228	require.True(t, handler.onBeforeDownload(ctx, second,
229		stubDownloadItem{id: 12, url: "https://other.example/a.bin", suggested: "a.bin"}, "a.bin", callback))
230	require.Equal(t, 2, tracker.Snapshot().ActiveDownloads)
231
232	// Terminal for one ID leaves the other owner outstanding.
233	handler.onDownloadUpdated(ctx, first, stubDownloadItem{id: 11, complete: true, fullPath: "/tmp/a.bin"}, nil)
234	require.Equal(t, 1, tracker.Snapshot().ActiveDownloads)
235
236	// Lost owners reconcile through the native lifecycle, never removal.
237	tracker.DropBrowser(9)
238	require.True(t, tracker.Snapshot().Quiescent())
239}

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