1package content
2
3import (
4 "context"
5 "errors"
6 "testing"
7
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/port/mocks"
14 "github.com/bnema/dumber/internal/domain/entity"
15)
16
17type externalLaunchRecorder struct {
18 prompts []string
19 origins []string
20 respond func(bool)
21 launched []string
22}
23
24const testPaneID = entity.PaneID("pane-1")
25
26// uriChanged delivers a URI change to testPaneID as the engine callback
27// installed for wv.
28func uriChanged(ctx context.Context, c *Coordinator, wv port.WebView, uri string) {
29 identity, _ := identityForWebView(wv)
30 c.handleURIChanged(ctx, testPaneID, wv, identity, uri)
31}
32
33// externalWebView returns a WebView mock with ID 1 and the given generation.
34func externalWebView(t *testing.T, generation uint64) *mocks.MockWebView {
35 wv := mocks.NewMockWebView(t)
36 wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
37 wv.EXPECT().Generation().Return(generation).Maybe()
38 return wv
39}
40
41func (r *externalLaunchRecorder) install(c *Coordinator) {
42 c.onLaunchExternalURL = func(uri string) {
43 r.launched = append(r.launched, uri)
44 }
45 c.onConfirmExternalLaunch = func(_ entity.PaneID, origin, uri string, respond func(bool)) {
46 r.prompts = append(r.prompts, uri)
47 r.origins = append(r.origins, origin)
48 r.respond = respond
49 }
50}
51
52func TestHandleURIChanged_ExternalSchemeWithoutConfirmerIsBlocked(t *testing.T) {
53 t.Parallel()
54
55 ctx := context.Background()
56 wv := externalWebView(t, 1)
57 wv.EXPECT().Stop(ctx).Return(nil).Once()
58
59 launchCount := 0
60 c := &Coordinator{
61 webViews: map[entity.PaneID]port.WebView{"pane-1": wv},
62 onLaunchExternalURL: func(string) { launchCount++ },
63 }
64
65 uriChanged(ctx, c, wv, "vscode://file/etc/passwd")
66
67 assert.Equal(t, 0, launchCount)
68}
69
70func TestHandleURIChanged_ExternalSchemeLaunchesOnlyAfterConfirmation(t *testing.T) {
71 t.Parallel()
72
73 ctx := context.Background()
74 wv := externalWebView(t, 1)
75 wv.EXPECT().Stop(ctx).Return(nil).Once()
76
77 rec := &externalLaunchRecorder{}
78 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
79 rec.install(c)
80
81 uri := "claude://login/google-auth?code=abc"
82 uriChanged(ctx, c, wv, uri)
83
84 assert.Equal(t, []string{uri}, rec.prompts)
85 assert.Empty(t, rec.launched)
86
87 rec.respond(true)
88 assert.Equal(t, []string{uri}, rec.launched)
89}
90
91func TestHandleURIChanged_ExternalSchemeDeniedDoesNotLaunchOrGoBack(t *testing.T) {
92 t.Parallel()
93
94 ctx := context.Background()
95 wv := externalWebView(t, 1)
96 // GoBack must not be called: going back to an OAuth endpoint loops.
97 wv.EXPECT().Stop(ctx).Return(nil).Once()
98
99 rec := &externalLaunchRecorder{}
100 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
101 rec.install(c)
102
103 uriChanged(ctx, c, wv, "spotify://track/123")
104 rec.respond(false)
105
106 assert.Empty(t, rec.launched)
107}
108
109func TestHandleURIChanged_OneOutstandingPromptPerPane(t *testing.T) {
110 t.Parallel()
111
112 ctx := context.Background()
113 wv := externalWebView(t, 1)
114 wv.EXPECT().Stop(ctx).Return(nil).Times(3)
115
116 rec := &externalLaunchRecorder{}
117 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
118 rec.install(c)
119
120 // Alternating URIs must not queue more prompts while one is pending.
121 uriChanged(ctx, c, wv, "claude://a")
122 uriChanged(ctx, c, wv, "claude://b")
123 uriChanged(ctx, c, wv, "claude://a")
124 assert.Equal(t, []string{"claude://a"}, rec.prompts)
125
126 // Once answered, the pane may prompt again.
127 rec.respond(false)
128 wv.EXPECT().Stop(ctx).Return(nil).Once()
129 uriChanged(ctx, c, wv, "claude://b")
130 assert.Len(t, rec.prompts, 2)
131}
132
133func TestHandleURIChanged_StalePromptAfterReleaseDoesNotLaunch(t *testing.T) {
134 t.Parallel()
135
136 ctx := context.Background()
137 wv := externalWebView(t, 1)
138 wv.EXPECT().Stop(ctx).Return(nil).Once()
139
140 rec := &externalLaunchRecorder{}
141 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
142 rec.install(c)
143
144 uriChanged(ctx, c, wv, "claude://login?code=x")
145 // Pane closed while the prompt was queued.
146 c.clearExternalPrompt("pane-1")
147 delete(c.webViews, "pane-1")
148
149 rec.respond(true)
150 assert.Empty(t, rec.launched)
151}
152
153func TestHandleURIChanged_HTTPURIStillRecordsSPANavigation(t *testing.T) {
154 t.Parallel()
155
156 ctx := context.Background()
157 paneID := testPaneID
158 wv := externalWebView(t, 1)
159 wv.EXPECT().IsLoading().Return(false).Once()
160
161 var recordedPane entity.PaneID
162 var recordedURI string
163 c := &Coordinator{
164 webViews: map[entity.PaneID]port.WebView{paneID: wv},
165 onPaneURIUpdated: func(paneID entity.PaneID, uri string) {
166 recordedPane = paneID
167 recordedURI = uri
168 },
169 }
170
171 uriChanged(ctx, c, wv, "https://example.com/app#state")
172
173 assert.Equal(t, paneID, recordedPane)
174 assert.Equal(t, "https://example.com/app#state", recordedURI)
175}
176
177func TestHandleURIChanged_StopErrorDoesNotPanic(t *testing.T) {
178 t.Parallel()
179
180 ctx := context.Background()
181 wv := externalWebView(t, 1)
182 wv.EXPECT().Stop(ctx).Return(errors.New("stop failed")).Once()
183
184 rec := &externalLaunchRecorder{}
185 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
186 rec.install(c)
187
188 uriChanged(ctx, c, wv, "vscode://file/etc/passwd")
189
190 assert.Len(t, rec.prompts, 1)
191 assert.Empty(t, rec.launched)
192}
193
194func TestHandleURIChanged_OldAnswerCannotLaunchReplacementPrompt(t *testing.T) {
195 t.Parallel()
196
197 ctx := context.Background()
198 wv := externalWebView(t, 1)
199 wv.EXPECT().Stop(ctx).Return(nil).Twice()
200
201 rec := &externalLaunchRecorder{}
202 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
203 rec.install(c)
204
205 uriChanged(ctx, c, wv, "claude://old")
206 oldRespond := rec.respond
207 // The pane is invalidated, then the same WebView asks again.
208 c.clearExternalPrompt("pane-1")
209 uriChanged(ctx, c, wv, "claude://new")
210 newRespond := rec.respond
211
212 oldRespond(true)
213 assert.Empty(t, rec.launched, "old answer must not launch")
214
215 newRespond(true)
216 assert.Equal(t, []string{"claude://new"}, rec.launched)
217}
218
219func TestHandleURIChanged_RebindToNewGenerationMakesPromptStale(t *testing.T) {
220 t.Parallel()
221
222 ctx := context.Background()
223 wv := externalWebView(t, 1)
224 wv.EXPECT().Stop(ctx).Return(nil).Once()
225 rebound := externalWebView(t, 2)
226
227 rec := &externalLaunchRecorder{}
228 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
229 rec.install(c)
230
231 uriChanged(ctx, c, wv, "claude://login?code=x")
232 c.webViews["pane-1"] = rebound
233
234 rec.respond(true)
235 assert.Empty(t, rec.launched)
236}
237
238func TestHandleURIChanged_PromptNamesLastWebOrigin(t *testing.T) {
239 t.Parallel()
240
241 ctx := context.Background()
242 wv := externalWebView(t, 1)
243 wv.EXPECT().IsLoading().Return(true).Once()
244 wv.EXPECT().Stop(ctx).Return(nil).Once()
245
246 rec := &externalLaunchRecorder{}
247 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
248 rec.install(c)
249
250 uriChanged(ctx, c, wv, "https://claude.ai/login/popup-google-auth?code=x")
251 uriChanged(ctx, c, wv, "claude://login")
252
253 assert.Equal(t, []string{"claude.ai"}, rec.origins)
254}
255
256func TestHandleURIChanged_IgnoresCallbackFromReleasedWebView(t *testing.T) {
257 t.Parallel()
258
259 ctx := context.Background()
260 wv := externalWebView(t, 1)
261
262 rec := &externalLaunchRecorder{}
263 c := &Coordinator{}
264 rec.install(c)
265
266 // The callback was queued before the pane released its WebView.
267 uriChanged(ctx, c, wv, "https://site.example/")
268 uriChanged(ctx, c, wv, "claude://login")
269
270 assert.Empty(t, rec.prompts)
271 assert.Empty(t, c.externalPromptOrigins)
272}
273
274func TestHandleURIChanged_NonHTTPDocumentClearsOrigin(t *testing.T) {
275 t.Parallel()
276
277 for _, doc := range []string{"data:text/html,hi", "file://trusted.example/page.html"} {
278 ctx := context.Background()
279 wv := externalWebView(t, 1)
280 wv.EXPECT().IsLoading().Return(true).Times(2)
281 wv.EXPECT().Stop(ctx).Return(nil).Once()
282
283 rec := &externalLaunchRecorder{}
284 c := &Coordinator{webViews: map[entity.PaneID]port.WebView{"pane-1": wv}}
285 rec.install(c)
286
287 uriChanged(ctx, c, wv, "https://trusted.example/")
288 uriChanged(ctx, c, wv, doc)
289 uriChanged(ctx, c, wv, "claude://login")
290
291 assert.Equal(t, []string{""}, rec.origins, doc)
292 }
293}
294
295func TestURIChangedCallback_IgnoredAfterSameWebViewIsReusedFromPool(t *testing.T) {
296 ctx := context.Background()
297 c := newMinimalCoordinator()
298 rec := &externalLaunchRecorder{}
299 rec.install(c)
300 spaCalls := 0
301 c.onPaneURIUpdated = func(entity.PaneID, string) { spaCalls++ }
302
303 generation := uint64(1)
304 wv := mocks.NewMockWebView(t)
305 wv.EXPECT().ID().Return(port.WebViewID(1)).Maybe()
306 wv.EXPECT().Generation().RunAndReturn(func() uint64 { return generation }).Maybe()
307 var callbacks *port.WebViewCallbacks
308 wv.EXPECT().SetCallbacks(mock.Anything).Run(func(cb *port.WebViewCallbacks) { callbacks = cb }).Once()
309
310 c.setWebViewLocked(testPaneID, wv)
311 c.setupWebViewCallbacks(ctx, testPaneID, wv)
312 require.NotNil(t, callbacks)
313
314 // The same object is reused from the pool for a new binding.
315 generation = 2
316 callbacks.OnURIChanged("https://site.example/")
317 callbacks.OnURIChanged("claude://login")
318
319 assert.Empty(t, rec.prompts)
320 assert.Empty(t, c.externalPromptOrigins)
321 assert.Zero(t, spaCalls)
322}