1package systemviews
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"
15)
16
17func TestParseRoute(t *testing.T) {
18 t.Parallel()
19
20 tests := []struct {
21 name string
22 uri string
23 want Route
24 }{
25 {name: "empty", uri: "", want: RouteUnknown},
26 {name: "scheme only", uri: "://", want: RouteUnknown},
27 {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown},
28 {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown},
29 {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown},
30 {name: "history host", uri: "dumb://history", want: RouteHistory},
31 {name: "history opaque", uri: "dumb:history", want: RouteHistory},
32 {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
33 {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
34 {name: "config host", uri: "dumb://config", want: RouteConfig},
35 {name: "config opaque", uri: "dumb:config", want: RouteConfig},
36 }
37
38 for _, tt := range tests {
39 t.Run(tt.name, func(t *testing.T) {
40 t.Parallel()
41 if got := ParseRoute(tt.uri); got != tt.want {
42 t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
43 }
44 })
45 }
46}
47
48func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
49 t.Parallel()
50
51 dom := &recordingDOM{}
52 app := NewApp(Dependencies{
53 DOM: dom,
54 LocationURI: "dumb://history",
55 })
56
57 require.NoError(t, app.Run())
58 assert.Equal(t, RouteHistory, app.CurrentRoute())
59 assert.True(t, dom.Mounted())
60 assert.Contains(t, dom.HTML(), "history")
61 assert.Contains(t, dom.HTML(), "systemviews")
62}
63
64func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
65 t.Parallel()
66
67 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{
68 URL: "https://example.com",
69 Title: "Example",
70 }}}
71
72 app := NewApp(Dependencies{
73 History: history,
74 LocationURI: "dumb://history",
75 })
76
77 require.NoError(t, app.LoadInitial(context.Background()))
78 assert.Equal(t, RouteHistory, app.CurrentRoute())
79 assert.Len(t, app.historyEntries, 1)
80 assert.Contains(t, app.renderedHTML, "Example")
81 assert.Contains(t, app.renderedHTML, "https://example.com")
82 assert.True(t, history.called)
83 assert.False(t, history.analyticsCalled)
84 assert.Equal(t, 0, history.limit)
85 assert.Equal(t, 0, history.offset)
86
87 // Shell frame present, no full document wrapper.
88 assert.NotContains(t, app.renderedHTML, "<html")
89 assert.NotContains(t, app.renderedHTML, "<head")
90 assert.NotContains(t, app.renderedHTML, "<body")
91 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
92 assert.Contains(t, app.renderedHTML, `data-route="history"`)
93 assert.Contains(t, app.renderedHTML, `data-page-title="History - 1 entry, 1 visit, 0 days"`)
94 assert.Contains(t, app.renderedHTML, `sv-section`)
95 assert.Contains(t, app.renderedHTML, `class="sv-list"`)
96}
97
98func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) {
99 t.Parallel()
100
101 history := &recordingHistoryService{
102 entries: []*entity.HistoryEntry{
103 {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2},
104 {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3},
105 },
106 stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121},
107 }
108 app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
109
110 require.NoError(t, app.LoadInitial(context.Background()))
111 assert.True(t, history.statsCalled)
112 assert.Contains(t, app.renderedHTML, "Loaded 2 items")
113 assert.Contains(t, app.renderedHTML, `data-page-title="History - 8904 entries, 40947 visits, 121 days"`)
114 assert.Contains(t, app.renderedHTML, `<h1 class="sv-title">History <span class="sv-title-detail">- 8904 entries, 40947 visits, 121 days</span></h1>`)
115 assert.NotContains(t, app.renderedHTML, `sv-history-summary`)
116 assert.NotContains(t, app.renderedHTML, `sv-stat-value`)
117}
118
119func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
120 t.Parallel()
121
122 dom := &recordingActionDOM{recordingDOM: recordingDOM{mounts: make(chan string, 4)}}
123 history := &recordingHistoryService{
124 entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
125 timelineStarted: make(chan struct{}),
126 releaseTimeline: make(chan struct{}),
127 }
128 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
129
130 require.NoError(t, app.RunWithContext(context.Background()))
131 loadingHTML := receiveMount(t, dom.mounts)
132 assert.Contains(t, loadingHTML, "Loading history")
133 assert.NotContains(t, loadingHTML, "Loaded entry")
134
135 select {
136 case <-history.timelineStarted:
137 case <-time.After(time.Second):
138 t.Fatal("history timeline was not started asynchronously")
139 }
140 close(history.releaseTimeline)
141
142 hydratedHTML := receiveMount(t, dom.mounts)
143 assert.Contains(t, hydratedHTML, "Loaded entry")
144 assert.Contains(t, hydratedHTML, "Loaded 1 item")
145}
146
147func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148 t.Parallel()
149
150 dom := &recordingDOM{mounts: make(chan string, 4)}
151 app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152 app.currentRoute = RouteHistory
153 staleHTML := "<div>Stale hydrated entry</div>"
154 staleGeneration := app.renderGeneration
155 ctx := context.Background()
156
157 app.surfaceActionError(ctx, errors.New("action worker failed"))
158 errorHTML := receiveMount(t, dom.mounts)
159 assert.Contains(t, errorHTML, "action worker failed")
160 assert.NotContains(t, errorHTML, "Stale hydrated entry")
161
162 require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163 assertNoMount(t, dom.mounts, 100*time.Millisecond)
164 assert.Contains(t, dom.HTML(), "action worker failed")
165 assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166}
167
168func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169 t.Parallel()
170
171 dom := &recordingActionDOM{recordingDOM: recordingDOM{mounts: make(chan string, 4)}}
172 history := &recordingHistoryService{
173 entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174 timelineStarted: make(chan struct{}),
175 releaseTimeline: make(chan struct{}),
176 }
177 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178 ctx := context.Background()
179
180 require.NoError(t, app.RunWithContext(ctx))
181 loadingHTML := receiveMount(t, dom.mounts)
182 assert.Contains(t, loadingHTML, "Loading history")
183
184 select {
185 case <-history.timelineStarted:
186 case <-time.After(time.Second):
187 t.Fatal("history timeline was not started asynchronously")
188 }
189
190 app.surfaceActionError(ctx, errors.New("action worker failed"))
191 errorHTML := receiveMount(t, dom.mounts)
192 assert.Contains(t, errorHTML, "action worker failed")
193 assert.NotContains(t, errorHTML, "Stale hydrated entry")
194
195 close(history.releaseTimeline)
196 assertNoMount(t, dom.mounts, 100*time.Millisecond)
197 assert.Contains(t, dom.HTML(), "action worker failed")
198 assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199}
200
201func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203 app := NewApp(Dependencies{History: &recordingHistoryService{}, LocationURI: "dumb://history"})
204 app.currentRoute = RouteHistory
205 app.historyWindowAfter = cursor
206
207 snapshot := app.currentHistoryRouteSnapshotLocked()
208 assert.Equal(t, cursor, snapshot.windowAfter)
209}
210
211func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
212 t.Parallel()
213
214 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215 history := &recordingHistoryService{window: &entity.HistoryWindow{
216 Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217 Before: cursor,
218 CursorLastVisited: cursor.Add(-time.Hour),
219 CursorID: 2,
220 HasMore: true,
221 }}
222 dom := &recordingActionDOM{}
223 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
224 app.currentRoute = RouteHistory
225 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
226 app.historyWindowAfter = cursor
227 app.historyCursorID = 1
228 app.historyHasMore = true
229
230 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
231 Action: historyActionLoadMore,
232 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
233 }))
234 assert.Equal(t, cursor, history.windowBefore)
235 assert.Equal(t, int64(1), history.windowBeforeID)
236 assert.Equal(t, int64(2), app.historyCursorID)
237 assert.Equal(t, cursor.Add(-time.Hour), app.historyWindowAfter)
238 assert.Len(t, app.historyEntries, 2)
239 assert.Contains(t, dom.AppendedHTML(), "Older entry")
240 assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
241 assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
242}
243
244func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
245 t.Parallel()
246
247 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
248 history := &recordingHistoryService{window: &entity.HistoryWindow{
249 Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
250 Before: cursor,
251 CursorLastVisited: cursor.Add(-time.Hour),
252 CursorID: 2,
253 HasMore: true,
254 }}
255 dom := &recordingDOM{}
256 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
257 app.currentRoute = RouteHistory
258 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
259 app.historyWindowAfter = cursor
260 app.historyCursorID = 1
261 app.historyHasMore = true
262
263 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
264 Action: historyActionLoadMore,
265 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
266 }))
267 assert.Equal(t, cursor, history.windowBefore)
268 assert.Equal(t, int64(1), history.windowBeforeID)
269 assert.Len(t, app.historyEntries, 2)
270 assert.Contains(t, dom.HTML(), "Older entry")
271 assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
272}
273
274func TestAppHandleHistoryLoadMoreDisablesPartialNextCursor(t *testing.T) {
275 t.Parallel()
276
277 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
278 history := &recordingHistoryService{window: &entity.HistoryWindow{
279 Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
280 Before: cursor,
281 After: cursor.Add(-24 * time.Hour),
282 HasMore: true,
283 }}
284 dom := &recordingActionDOM{}
285 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
286 app.currentRoute = RouteHistory
287 app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
288 app.historyWindowAfter = cursor
289 app.historyCursorID = 1
290 app.historyHasMore = true
291
292 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
293 Action: historyActionLoadMore,
294 Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
295 }))
296 assert.False(t, app.historyHasMore)
297 assert.Zero(t, app.historyCursorID)
298 assert.True(t, app.historyWindowAfter.IsZero())
299 assert.NotContains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
300}
301
302func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
303 t.Parallel()
304
305 cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
306 history := &recordingHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}}
307 dom := &recordingActionDOM{}
308 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
309 app.currentRoute = RouteHistory
310 app.historyWindowAfter = cursor
311 app.historyHasMore = true
312
313 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
314 Action: historyActionLoadMore,
315 Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
316 }))
317 assert.False(t, history.called)
318 assert.Empty(t, dom.AppendedHTML())
319}
320
321func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
322 t.Parallel()
323
324 history := &recordingHistoryService{
325 entries: []*entity.HistoryEntry{{
326 ID: 42,
327 URL: "https://example.com/page",
328 Title: "Example",
329 }},
330 domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
331 }
332
333 app := NewApp(Dependencies{
334 History: history,
335 LocationURI: "dumb://history",
336 })
337
338 require.NoError(t, app.LoadInitial(context.Background()))
339 assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
340 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
341 assert.Contains(t, app.renderedHTML, `data-id="42"`)
342 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
343 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
344 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
345 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
346 assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
347 assert.Contains(t, app.renderedHTML, `>example.com</button>`)
348 assert.Contains(t, app.renderedHTML, `data-sv-history-search data-sv-autofocus`)
349 assert.NotContains(t, app.renderedHTML, "Keys:")
350}
351
352func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
353 dom := &recordingDOM{}
354 history := &recordingHistoryService{
355 entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
356 searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
357 }
358 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
359
360 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
361 Action: historyActionSearch,
362 Data: map[string]string{"query": " example "},
363 }))
364 assert.True(t, history.searchCalled)
365 assert.Equal(t, "example", history.query)
366 assert.Contains(t, dom.HTML(), "Search result")
367 assert.Contains(t, dom.HTML(), "Query: example")
368
369 app.historyOffset = historyTimelineLimit
370 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
371 Action: historyActionDeleteEntry,
372 Data: map[string]string{"id": "42"},
373 }))
374 assert.Equal(t, int64(42), history.deletedEntryID)
375 assert.Equal(t, 0, app.historyOffset)
376 assert.Contains(t, dom.HTML(), "Deleted history entry")
377
378 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
379 Action: historyActionDeleteRange,
380 Data: map[string]string{"range": "week"},
381 }))
382 assert.Equal(t, "week", history.deletedRangeID)
383 assert.Contains(t, dom.HTML(), "Deleted history from this week")
384
385 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
386 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
387 Action: historyActionFilterDomain,
388 Data: map[string]string{"domain": "example.com"},
389 }))
390 assert.True(t, history.called)
391 assert.Equal(t, "example.com", history.windowDomain)
392 assert.Empty(t, app.historyQuery)
393
394 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
395 Action: historyActionDeleteDomain,
396 Data: map[string]string{"domain": "example.com"},
397 }))
398 assert.Equal(t, "example.com", history.deletedDomain)
399 assert.Contains(t, dom.HTML(), "Deleted history for example.com")
400}
401
402func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
403 dom := &recordingActionDOM{}
404 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
405 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
406
407 ctx := t.Context()
408 require.NoError(t, app.RunWithContext(ctx))
409 require.NotNil(t, dom.handler)
410
411 app.Close()
412 assert.True(t, dom.released)
413 assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
414 require.NotPanics(t, func() {
415 dom.handler(DOMAction{Action: historyActionClear})
416 })
417}
418
419func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
420 bindErr := errors.New("bind failed")
421 dom := &failingActionDOM{bindErr: bindErr}
422 app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
423
424 err := app.RunWithContext(context.Background())
425 require.ErrorIs(t, err, bindErr)
426 assert.Nil(t, app.actionQueue)
427 assert.Nil(t, app.actionErrorQueue)
428 assert.Nil(t, app.actionCtx)
429 assert.False(t, app.actionWorkerActive())
430 require.NotPanics(t, app.Close)
431}
432
433func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
434 dom := &recordingActionDOM{}
435 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
436 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
437
438 ctx, cancel := context.WithCancel(context.Background())
439 cancel()
440 err := app.RunWithContext(ctx)
441
442 require.ErrorContains(t, err, "systemview action worker unavailable")
443 assert.True(t, dom.released)
444 assert.Nil(t, app.actionQueue)
445 assert.Nil(t, app.actionErrorQueue)
446 assert.Nil(t, app.actionCtx)
447 assert.False(t, app.actionWorkerActive())
448}
449
450func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
451 t.Parallel()
452
453 history := &recordingHistoryService{err: errors.New("database unavailable")}
454 app := NewApp(Dependencies{
455 History: history,
456 LocationURI: "dumb://history",
457 })
458
459 require.NoError(t, app.LoadInitial(context.Background()))
460 assert.Equal(t, RouteHistory, app.CurrentRoute())
461 assert.True(t, history.called)
462 assert.Contains(t, app.renderedHTML, "Could not load this system view")
463 assert.Contains(t, app.renderedHTML, "database unavailable")
464 assert.Contains(t, app.renderedHTML, `role="alert"`)
465}
466
467func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
468 t.Parallel()
469
470 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{
471 URL: "https://example.com",
472 Title: "Example",
473 }}}
474 config := &recordingConfigService{current: dto.SystemviewConfigPayload{
475 Appearance: dto.WebUIAppearanceConfig{
476 ColorScheme: "prefer-light",
477 LightPalette: dto.ColorPalette{
478 Background: "#ffffff",
479 Surface: "#fafafa",
480 SurfaceVariant: "#eeeeee",
481 Text: "#111111",
482 Muted: "#666666",
483 Accent: "#0055ff",
484 Border: "#dddddd",
485 },
486 DarkPalette: dto.ColorPalette{
487 Background: "#111111",
488 Surface: "#1a1a1a",
489 SurfaceVariant: "#2a2a2a",
490 Text: "#f5f5f5",
491 Muted: "#a0a0a0",
492 Accent: "#66aaff",
493 Border: "#333333",
494 },
495 SansFont: "Inter",
496 SerifFont: "Georgia",
497 MonospaceFont: "JetBrains Mono",
498 DefaultFontSize: 16,
499 },
500 }}
501
502 app := NewApp(Dependencies{
503 Config: config,
504 History: history,
505 LocationURI: "dumb://history",
506 })
507
508 require.NoError(t, app.LoadInitial(context.Background()))
509 assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
510 assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
511 assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
512}
513
514func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
515 t.Parallel()
516
517 favorites := &recordingFavoritesService{
518 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
519 tags: []*entity.Tag{{Name: "Go"}},
520 }
521
522 app := NewApp(Dependencies{
523 Favorites: favorites,
524 LocationURI: "dumb://favorites",
525 })
526
527 require.NoError(t, app.LoadInitial(context.Background()))
528 assert.Equal(t, RouteFavorites, app.CurrentRoute())
529 assert.Len(t, app.favorites, 1)
530 assert.Len(t, app.tags, 1)
531 assert.Contains(t, app.renderedHTML, "Favorites")
532 assert.Contains(t, app.renderedHTML, "Example")
533 assert.Contains(t, app.renderedHTML, "Go")
534 assert.True(t, favorites.calledList)
535 assert.True(t, favorites.calledTags)
536
537 // Shell frame present, no full document wrapper.
538 assert.NotContains(t, app.renderedHTML, "<html")
539 assert.NotContains(t, app.renderedHTML, "<head")
540 assert.NotContains(t, app.renderedHTML, "<body")
541 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
542 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
543 assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
544 assert.Contains(t, app.renderedHTML, `sv-section`)
545 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
546}
547
548func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
549 t.Parallel()
550
551 shortcut := 3
552 favorites := &recordingFavoritesService{
553 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", ShortcutKey: &shortcut}},
554 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
555 }
556
557 app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"})
558
559 require.NoError(t, app.LoadInitial(context.Background()))
560 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
561 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
562 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
563 assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.create"`)
564 assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.update"`)
565 assert.NotContains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
566 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
567 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
568 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
569 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
570 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.filterUntagged"`)
571 assert.Contains(t, app.renderedHTML, `name="tags"`)
572 assert.Contains(t, app.renderedHTML, `value="7"`)
573 assert.Contains(t, app.renderedHTML, "Go")
574 assert.Contains(t, app.renderedHTML, "Shortcut 3")
575}
576
577func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
578 dom := &recordingDOM{}
579 favorites := &recordingFavoritesService{
580 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
581 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
582 }
583 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
584
585 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
586 Action: favoriteActionCreate,
587 Data: map[string]string{"url": "https://new.example", "title": "New", "tags": "7"},
588 }))
589 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
590 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
591 assert.Contains(t, dom.HTML(), "Added favorite New")
592
593 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
594 Action: favoriteActionUpdate,
595 Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"},
596 }))
597 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
598 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
599 assert.True(t, favorites.updatedFavorite.ShortcutKeySet)
600 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
601 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
602 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
603
604 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
605 Action: tagActionAssign,
606 Data: map[string]string{"favoriteId": "42", "tagId": "7"},
607 }))
608 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
609 assert.Equal(t, int64(7), favorites.assignedTagID)
610
611 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
612 Action: favoriteActionFilterTag,
613 Data: map[string]string{"tagId": "7"},
614 }))
615 require.NotNil(t, app.favoriteTagFilter)
616 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
617 assert.False(t, app.favoriteUntaggedFilter)
618
619 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
620 Action: favoriteActionDelete,
621 Data: map[string]string{"id": "42"},
622 }))
623 assert.Equal(t, int64(42), favorites.deletedFavorite)
624 assert.Contains(t, dom.HTML(), "Deleted favorite")
625}
626
627func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
628 t.Parallel()
629
630 config := dto.SystemviewConfigPayload{
631 EngineType: "webkit",
632 Appearance: dto.WebUIAppearanceConfig{
633 ColorScheme: "prefer-dark",
634 SansFont: "Inter",
635 SerifFont: "Georgia",
636 MonospaceFont: "JetBrains Mono",
637 DefaultFontSize: 16,
638 LightPalette: dto.ColorPalette{
639 Background: "#ffffff",
640 Surface: "#fafafa",
641 SurfaceVariant: "#eeeeee",
642 Text: "#111111",
643 Muted: "#666666",
644 Accent: "#0055ff",
645 Border: "#dddddd",
646 },
647 DarkPalette: dto.ColorPalette{
648 Background: "#111111",
649 Surface: "#1a1a1a",
650 SurfaceVariant: "#2a2a2a",
651 Text: "#f5f5f5",
652 Muted: "#a0a0a0",
653 Accent: "#66aaff",
654 Border: "#333333",
655 },
656 ExternalTheme: dto.WebUIExternalThemeConfig{
657 Enabled: true,
658 Provider: "noctalia",
659 Format: "dumber-json",
660 Path: "/tmp/noctalia-dumber.json",
661 },
662 },
663 Performance: dto.SystemviewPerformancePayload{
664 Profile: "balanced",
665 Custom: dto.SystemviewCustomPerformancePayload{
666 SkiaCPUThreads: 4,
667 SkiaGPUThreads: 2,
668 WebProcessMemoryMB: 512,
669 NetworkProcessMemoryMB: 128,
670 WebViewPoolPrewarm: 1,
671 },
672 Hardware: dto.SystemviewHardwarePayload{
673 CPUCores: 8,
674 CPUThreads: 16,
675 TotalRAMMB: 32768,
676 GPUVendor: "NVIDIA",
677 GPUName: "RTX 4060",
678 VRAMMB: 8192,
679 },
680 },
681 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
682 SearchShortcuts: map[string]dto.SearchShortcut{
683 "ddg": {
684 URL: "https://duckduckgo.com/?q=%s",
685 Description: "DuckDuckGo",
686 },
687 },
688 }
689 service := &recordingConfigService{
690 current: config,
691 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
692 {
693 Mode: "default",
694 DisplayName: "Default",
695 Bindings: []port.KeybindingEntry{
696 {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
697 {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
698 },
699 },
700 {
701 Mode: "search",
702 DisplayName: "Search",
703 Bindings: []port.KeybindingEntry{
704 {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
705 },
706 },
707 }},
708 }
709
710 app := NewApp(Dependencies{
711 Config: service,
712 LocationURI: "dumb://config",
713 })
714
715 require.NoError(t, app.LoadInitial(context.Background()))
716 assert.Equal(t, RouteConfig, app.CurrentRoute())
717 require.NotNil(t, app.config)
718 assert.Equal(t, "webkit", app.config.EngineType)
719 assert.Contains(t, app.renderedHTML, "webkit")
720 assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
721 assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
722 assert.Contains(t, app.renderedHTML, "prefer-dark")
723 assert.Contains(t, app.renderedHTML, "appearance.sans_font")
724 assert.Contains(t, app.renderedHTML, "Inter")
725 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
726 assert.Contains(t, app.renderedHTML, "#ffffff")
727 assert.Contains(t, app.renderedHTML, "appearance.external_theme.enabled")
728 assert.Contains(t, app.renderedHTML, "appearance.external_theme.provider")
729 assert.Contains(t, app.renderedHTML, "appearance.external_theme.format")
730 assert.Contains(t, app.renderedHTML, "appearance.external_theme.path")
731 assert.Contains(t, app.renderedHTML, "noctalia")
732 assert.Contains(t, app.renderedHTML, "dumber-json")
733 assert.Contains(t, app.renderedHTML, "/tmp/noctalia-dumber.json")
734 assert.Contains(t, app.renderedHTML, "performance.profile")
735 assert.Contains(t, app.renderedHTML, "balanced")
736 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
737 assert.Contains(t, app.renderedHTML, "4")
738 assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
739 assert.Contains(t, app.renderedHTML, "NVIDIA")
740 assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
741 assert.Contains(t, app.renderedHTML, "Default")
742 assert.Contains(t, app.renderedHTML, "Search")
743 assert.Contains(t, app.renderedHTML, "Open")
744 assert.Contains(t, app.renderedHTML, "ctrl+o")
745 assert.Contains(t, app.renderedHTML, "New tab")
746 assert.Contains(t, app.renderedHTML, "ctrl+t")
747 assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
748 assert.Contains(t, app.renderedHTML, "Find")
749 assert.Contains(t, app.renderedHTML, "open")
750 assert.Contains(t, app.renderedHTML, "default")
751 assert.Contains(t, app.renderedHTML, "custom")
752 assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
753 assert.True(t, service.calledCurrent)
754 assert.True(t, service.calledKeybindings)
755
756 // Shell frame present, no full document wrapper.
757 assert.NotContains(t, app.renderedHTML, "<html")
758 assert.NotContains(t, app.renderedHTML, "<head")
759 assert.NotContains(t, app.renderedHTML, "<body")
760 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
761 assert.Contains(t, app.renderedHTML, `data-route="config"`)
762 assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
763 assert.Contains(t, app.renderedHTML, `sv-section`)
764 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
765}
766
767func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
768 t.Parallel()
769
770 service := &recordingConfigService{
771 current: testConfigPayload(),
772 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
773 Mode: "default",
774 DisplayName: "Default",
775 Bindings: []port.KeybindingEntry{{
776 Action: "toggle_history_systemview",
777 Description: "Toggle history",
778 Keys: []string{"ctrl+h"},
779 DefaultKeys: []string{"ctrl+h"},
780 }},
781 }}},
782 }
783 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
784
785 require.NoError(t, app.LoadInitial(context.Background()))
786 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
787 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
788 assert.Contains(t, app.renderedHTML, `name="external_theme_enabled"`)
789 assert.Contains(t, app.renderedHTML, `name="external_theme_provider"`)
790 assert.Contains(t, app.renderedHTML, `name="external_theme_format"`)
791 assert.Contains(t, app.renderedHTML, `name="external_theme_path"`)
792 assert.Contains(t, app.renderedHTML, `value="noctalia"`)
793 assert.Contains(t, app.renderedHTML, `value="dumber-json"`)
794 assert.Contains(t, app.renderedHTML, `/tmp/noctalia-dumber.json`)
795 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
796 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
797 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
798 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
799 assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
800 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
801 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
802 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
803 assert.Contains(t, app.renderedHTML, "may require restarting")
804}
805
806func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
807 dom := &recordingDOM{}
808 service := &recordingConfigService{
809 current: testConfigPayload(),
810 defaultCfg: testDefaultConfigPayload(),
811 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
812 Mode: "default",
813 DisplayName: "Default",
814 Bindings: []port.KeybindingEntry{{
815 Action: "toggle_history_systemview",
816 Description: "Toggle history",
817 Keys: []string{"ctrl+h"},
818 DefaultKeys: []string{"ctrl+h"},
819 }},
820 }}},
821 setResp: port.SetKeybindingResponse{},
822 }
823 app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
824
825 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
826 Action: configActionSaveAppearance,
827 Data: map[string]string{
828 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
829 "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
830 "external_theme_enabled": "yes", "external_theme_provider": " noctalia ", "external_theme_format": " dumber-json ", "external_theme_path": " /tmp/noctalia-dumber.json ",
831 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
832 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
833 },
834 }))
835 assert.True(t, service.calledSave)
836 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
837 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
838 assert.True(t, service.savedConfig.Appearance.ExternalTheme.Enabled)
839 assert.Equal(t, "noctalia", service.savedConfig.Appearance.ExternalTheme.Provider)
840 assert.Equal(t, "dumber-json", service.savedConfig.Appearance.ExternalTheme.Format)
841 assert.Equal(t, "/tmp/noctalia-dumber.json", service.savedConfig.Appearance.ExternalTheme.Path)
842 assert.Contains(t, dom.HTML(), "Saved appearance settings")
843 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
844
845 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
846 Action: configActionCreateSearchShortcut,
847 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
848 }))
849 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
850 assert.Contains(t, dom.HTML(), "Created search shortcut g")
851
852 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
853 Action: configActionSavePerformance,
854 Data: map[string]string{
855 "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
856 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
857 },
858 }))
859 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
860 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
861 assert.Contains(t, dom.HTML(), "Saved performance settings")
862
863 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
864 Action: configActionSavePerformance,
865 Data: map[string]string{"profile": "default"},
866 }))
867 assert.Equal(t, "default", service.savedConfig.Performance.Profile)
868 assert.Contains(t, dom.HTML(), "Saved performance settings")
869
870 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
871 Action: configActionSetKeybinding,
872 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
873 }))
874 assert.True(t, service.calledSet)
875 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
876 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
877 assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
878
879 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
880 Action: configActionResetKeybinding,
881 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
882 }))
883 assert.True(t, service.calledReset)
884 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
885 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
886 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
887 assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
888
889 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
890 assert.True(t, service.calledResetAll)
891 assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
892}
893
894func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
895 t.Parallel()
896
897 service := &recordingConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
898 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
899
900 err := app.handleConfigAction(context.Background(), DOMAction{
901 Action: configActionSaveSearch,
902 Data: map[string]string{"default_search_engine": "https://example.com/search"},
903 })
904 require.Error(t, err)
905 assert.False(t, service.calledSave)
906
907 err = app.handleConfigAction(context.Background(), DOMAction{
908 Action: configActionCreateSearchShortcut,
909 Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
910 })
911 require.Error(t, err)
912 assert.False(t, service.calledSave)
913}
914
915func TestAppLoadInitialConfigRouteUsesResolvedAppearanceOnlyForShellTheme(t *testing.T) {
916 t.Parallel()
917
918 cfg := testConfigPayload()
919 resolved := cfg.Appearance
920 resolved.DarkPalette.Background = "#222222"
921 resolved.DarkPalette.SurfaceVariant = "#444444"
922 cfg.ResolvedAppearance = &resolved
923 service := &recordingConfigService{
924 current: cfg,
925 keybindings: port.KeybindingsConfig{},
926 }
927
928 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
929
930 require.NoError(t, app.LoadInitial(context.Background()))
931 assert.Contains(t, app.renderedHTML, `--sv-background: #222222;`)
932 assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #444444;`)
933 assert.Contains(t, app.renderedHTML, `value="#111111"`)
934 assert.Contains(t, app.renderedHTML, `value="#2a2a2a"`)
935}
936
937func testConfigPayload() dto.SystemviewConfigPayload {
938 return dto.SystemviewConfigPayload{
939 EngineType: "webkit",
940 Appearance: dto.WebUIAppearanceConfig{
941 ColorScheme: "prefer-dark",
942 SansFont: "Inter",
943 SerifFont: "Georgia",
944 MonospaceFont: "JetBrains Mono",
945 DefaultFontSize: 16,
946 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
947 DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
948 ExternalTheme: dto.WebUIExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "dumber-json", Path: "/tmp/noctalia-dumber.json"},
949 },
950 DefaultUIScale: 1,
951 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
952 SearchShortcuts: map[string]dto.SearchShortcut{
953 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
954 },
955 Performance: dto.SystemviewPerformancePayload{
956 Profile: "default",
957 Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
958 Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
959 },
960 }
961}
962
963func testDefaultConfigPayload() dto.SystemviewConfigPayload {
964 cfg := testConfigPayload()
965 cfg.Appearance.DefaultFontSize = 14
966 cfg.DefaultUIScale = 1
967 return cfg
968}
969
970// Recording fixture to capture DOM mounts for stateful render assertions.
971type recordingDOM struct {
972 mu sync.Mutex
973 mounted bool
974 html string
975 appendedHTML string
976 mounts chan string
977}
978
979func (d *recordingDOM) Mount(markup string) error {
980 d.mu.Lock()
981 d.mounted = true
982 d.html = markup
983 mounts := d.mounts
984 d.mu.Unlock()
985 if mounts != nil {
986 select {
987 case mounts <- markup:
988 default:
989 }
990 }
991 return nil
992}
993
994func (d *recordingDOM) Mounted() bool {
995 d.mu.Lock()
996 defer d.mu.Unlock()
997 return d.mounted
998}
999
1000func (d *recordingDOM) HTML() string {
1001 d.mu.Lock()
1002 defer d.mu.Unlock()
1003 return d.html
1004}
1005
1006func (d *recordingDOM) AppendedHTML() string {
1007 d.mu.Lock()
1008 defer d.mu.Unlock()
1009 return d.appendedHTML
1010}
1011
1012func receiveMount(t *testing.T, mounts <-chan string) string {
1013 t.Helper()
1014 select {
1015 case html := <-mounts:
1016 return html
1017 case <-time.After(time.Second):
1018 t.Fatal("timed out waiting for DOM mount")
1019 return ""
1020 }
1021}
1022
1023func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
1024 t.Helper()
1025 select {
1026 case html := <-mounts:
1027 t.Fatalf("unexpected DOM mount: %s", html)
1028 case <-time.After(wait):
1029 }
1030}
1031
1032type recordingActionDOM struct {
1033 recordingDOM
1034 handler DOMActionHandler
1035 released bool
1036}
1037
1038func (d *recordingActionDOM) BindActions(handler DOMActionHandler) error {
1039 d.handler = handler
1040 return nil
1041}
1042
1043func (d *recordingActionDOM) AppendHistoryTimeline(markup string) error {
1044 d.mu.Lock()
1045 d.appendedHTML += markup
1046 d.mu.Unlock()
1047 return nil
1048}
1049
1050func (d *recordingActionDOM) Release() {
1051 d.released = true
1052}
1053
1054type failingActionDOM struct {
1055 recordingDOM
1056 bindErr error
1057}
1058
1059func (d *failingActionDOM) BindActions(DOMActionHandler) error {
1060 return d.bindErr
1061}
1062
1063// Recording fixture to capture history state for stateful render assertions.
1064type recordingHistoryService struct {
1065 called bool
1066 limit int
1067 offset int
1068 entries []*entity.HistoryEntry
1069 err error
1070 domainCalled bool
1071 domain string
1072
1073 searchCalled bool
1074 query string
1075 searchLimit int
1076 searchEntries []*entity.HistoryEntry
1077
1078 deletedEntryID int64
1079 deletedRangeID string
1080 deletedDomain string
1081 domainStats []*entity.DomainStat
1082 stats *entity.HistoryStats
1083 statsCalled bool
1084 window *entity.HistoryWindow
1085 windowBefore time.Time
1086 windowBeforeID int64
1087 windowDomain string
1088 analyticsCalled bool
1089
1090 timelineStarted chan struct{}
1091 releaseTimeline chan struct{}
1092 startOnce sync.Once
1093}
1094
1095func (s *recordingHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
1096 s.called = true
1097 s.limit = limit
1098 s.offset = offset
1099 if s.timelineStarted != nil {
1100 s.startOnce.Do(func() { close(s.timelineStarted) })
1101 }
1102 if s.releaseTimeline != nil {
1103 <-s.releaseTimeline
1104 }
1105 return s.entries, s.err
1106}
1107
1108func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
1109 s.domainCalled = true
1110 s.domain = domain
1111 s.limit = limit
1112 s.offset = offset
1113 return s.entries, s.err
1114}
1115
1116func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
1117 s.called = true
1118 s.windowBefore = before
1119 s.windowBeforeID = beforeID
1120 s.windowDomain = domain
1121 if s.timelineStarted != nil {
1122 s.startOnce.Do(func() { close(s.timelineStarted) })
1123 }
1124 if s.releaseTimeline != nil {
1125 <-s.releaseTimeline
1126 }
1127 if s.err != nil {
1128 return nil, s.err
1129 }
1130 if s.window != nil {
1131 return s.window, nil
1132 }
1133 return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil
1134}
1135
1136func (s *recordingHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
1137 s.searchCalled = true
1138 s.query = query
1139 s.searchLimit = limit
1140 if s.searchEntries != nil {
1141 return s.searchEntries, nil
1142 }
1143 return s.entries, nil
1144}
1145
1146func (s *recordingHistoryService) DeleteEntry(_ context.Context, id int64) error {
1147 s.deletedEntryID = id
1148 return nil
1149}
1150
1151func (s *recordingHistoryService) DeleteRange(_ context.Context, rangeID string) error {
1152 s.deletedRangeID = rangeID
1153 return nil
1154}
1155
1156func (s *recordingHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1157 s.statsCalled = true
1158 return s.stats, nil
1159}
1160
1161func (s *recordingHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1162 s.analyticsCalled = true
1163 return nil, nil
1164}
1165
1166func (s *recordingHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
1167 return s.domainStats, nil
1168}
1169
1170func (s *recordingHistoryService) DeleteDomain(_ context.Context, domain string) error {
1171 s.deletedDomain = domain
1172 return nil
1173}
1174
1175// Recording fixture to capture favorites state for stateful render assertions.
1176type recordingFavoritesService struct {
1177 calledList bool
1178 calledTags bool
1179 favorites []*entity.Favorite
1180 tags []*entity.Tag
1181
1182 createdFavorite dto.FavoriteCreateInput
1183 updatedFavorite dto.FavoriteUpdateInput
1184 deletedFavorite int64
1185 createdTag string
1186 updatedTagID int64
1187 deletedTagID int64
1188 assignedFavoriteID int64
1189 assignedTagID int64
1190 removedFavoriteID int64
1191 removedTagID int64
1192}
1193
1194func (s *recordingFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
1195 s.calledList = true
1196 return s.favorites, nil
1197}
1198
1199func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1200 s.calledTags = true
1201 return s.tags, nil
1202}
1203
1204func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1205 s.createdFavorite = input
1206 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title}, nil
1207}
1208
1209func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1210 s.updatedFavorite = input
1211 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, ShortcutKey: input.ShortcutKey}, nil
1212}
1213
1214func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
1215 s.deletedFavorite = id
1216 return nil
1217}
1218
1219func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1220
1221func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1222 s.createdTag = name
1223 return &entity.Tag{ID: 55, Name: name, Color: color}, nil
1224}
1225
1226func (s *recordingFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error {
1227 s.updatedTagID = id
1228 return nil
1229}
1230
1231func (s *recordingFavoritesService) DeleteTag(_ context.Context, id int64) error {
1232 s.deletedTagID = id
1233 return nil
1234}
1235
1236func (s *recordingFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error {
1237 s.assignedFavoriteID = favoriteID
1238 s.assignedTagID = tagID
1239 return nil
1240}
1241
1242func (s *recordingFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error {
1243 s.removedFavoriteID = favoriteID
1244 s.removedTagID = tagID
1245 return nil
1246}
1247
1248// Recording fixture to capture config state for stateful render assertions.
1249type recordingConfigService struct {
1250 calledCurrent bool
1251 calledDefault bool
1252 calledSave bool
1253 calledKeybindings bool
1254 calledSet bool
1255 calledReset bool
1256 calledResetAll bool
1257
1258 current dto.SystemviewConfigPayload
1259 defaultCfg dto.SystemviewConfigPayload
1260 keybindings port.KeybindingsConfig
1261 savedConfig dto.WebUIConfig
1262 setReq port.SetKeybindingRequest
1263 setResp port.SetKeybindingResponse
1264 resetReq port.ResetKeybindingRequest
1265}
1266
1267func (s *recordingConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
1268 s.calledCurrent = true
1269 return s.current, nil
1270}
1271
1272func (s *recordingConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
1273 s.calledDefault = true
1274 return s.defaultCfg, nil
1275}
1276
1277func (s *recordingConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
1278 s.calledSave = true
1279 s.savedConfig = cfg
1280 s.current.Appearance = cfg.Appearance
1281 s.current.DefaultUIScale = cfg.DefaultUIScale
1282 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
1283 s.current.SearchShortcuts = cfg.SearchShortcuts
1284 s.current.Performance.Profile = cfg.Performance.Profile
1285 s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
1286 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
1287 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
1288 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
1289 NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
1290 WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
1291 }
1292 return nil
1293}
1294
1295func (s *recordingConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
1296 s.calledKeybindings = true
1297 return s.keybindings, nil
1298}
1299
1300func (s *recordingConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
1301 s.calledSet = true
1302 s.setReq = req
1303 return s.setResp, nil
1304}
1305
1306func (s *recordingConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
1307 s.calledReset = true
1308 s.resetReq = req
1309 return nil
1310}
1311
1312func (s *recordingConfigService) ResetAllKeybindings(context.Context) error {
1313 s.calledResetAll = true
1314 return nil
1315}
1316
1317func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
1318 app := NewApp(Dependencies{Favorites: &recordingFavoritesService{}})
1319
1320 err := app.handleFavoriteAction(context.Background(), DOMAction{
1321 Action: favoriteActionCreate,
1322 Data: map[string]string{"url": " ", "title": "Blank"},
1323 })
1324 require.Error(t, err)
1325 assert.Contains(t, err.Error(), "favorite URL is required")
1326
1327 err = app.handleFavoriteAction(context.Background(), DOMAction{
1328 Action: favoriteActionCreate,
1329 Data: map[string]string{"url": "example.com", "title": "Relative"},
1330 })
1331 require.Error(t, err)
1332 assert.Contains(t, err.Error(), "favorite URL must be absolute")
1333
1334 err = app.handleFavoriteAction(context.Background(), DOMAction{
1335 Action: favoriteActionCreate,
1336 Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1337 })
1338 require.Error(t, err)
1339 assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1340}
1341
1342func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1343 favorites := &recordingFavoritesService{}
1344 app := NewApp(Dependencies{Favorites: favorites})
1345
1346 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1347 Action: favoriteActionCreate,
1348 Data: map[string]string{"url": "dumb:history", "title": "History"},
1349 }))
1350 assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1351
1352 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1353 Action: favoriteActionCreate,
1354 Data: map[string]string{"url": "dumb://config", "title": "Config"},
1355 }))
1356 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1357}
1358
1359func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1360 favorites := &recordingFavoritesService{}
1361 app := NewApp(Dependencies{Favorites: favorites})
1362
1363 err := app.handleFavoriteAction(context.Background(), DOMAction{
1364 Action: favoriteActionCreate,
1365 Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1366 })
1367
1368 require.Error(t, err)
1369 assert.Contains(t, err.Error(), "known systemview route")
1370}
1371
1372func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1373 _, err := parsePositiveInt64("abc", "tag id")
1374 require.Error(t, err)
1375 assert.Contains(t, err.Error(), "invalid tag id")
1376 assert.Contains(t, err.Error(), "invalid syntax")
1377
1378 _, err = parsePositiveInt64("0", "tag id")
1379 require.Error(t, err)
1380 assert.Contains(t, err.Error(), "tag id must be a positive integer")
1381}
1382
1383func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1384 favorites := &recordingFavoritesService{}
1385 app := NewApp(Dependencies{Favorites: favorites})
1386
1387 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1388 Action: tagActionAssign,
1389 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1390 }))
1391 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1392 assert.Equal(t, int64(7), favorites.assignedTagID)
1393
1394 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1395 Action: tagActionRemove,
1396 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1397 }))
1398 assert.Equal(t, int64(42), favorites.removedFavoriteID)
1399 assert.Equal(t, int64(7), favorites.removedTagID)
1400}
1401
1402func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1403 dom := &recordingDOM{}
1404 history := &recordingHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1405 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1406 app.historyDomainFilter = "example.com"
1407
1408 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1409 Action: historyActionSearch,
1410 Data: map[string]string{"query": "search"},
1411 }))
1412
1413 assert.Equal(t, "search", app.historyQuery)
1414 assert.Empty(t, app.historyDomainFilter)
1415 assert.True(t, history.searchCalled)
1416 assert.False(t, history.domainCalled)
1417}
1418
1419func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1420 app := NewApp(Dependencies{DOM: &recordingDOM{}, LocationURI: "dumb://history"})
1421
1422 var ctx context.Context
1423 err := app.RunWithContext(ctx)
1424
1425 require.ErrorContains(t, err, "context is nil")
1426}
1427
1428func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1429 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1430 app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1431
1432 err := app.RunWithContext(context.Background())
1433
1434 require.ErrorContains(t, err, "DOM not configured")
1435 assert.False(t, history.called)
1436}
1437
1438func TestHandleActionsRejectUnknownAction(t *testing.T) {
1439 app := NewApp(Dependencies{
1440 History: &recordingHistoryService{},
1441 Favorites: &recordingFavoritesService{},
1442 Config: &recordingConfigService{current: testConfigPayload()},
1443 })
1444
1445 require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1446 require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1447 require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1448}
1449
1450func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1451 service := &recordingConfigService{current: testConfigPayload()}
1452 app := NewApp(Dependencies{Config: service})
1453
1454 err := app.handleConfigAction(context.Background(), DOMAction{
1455 Action: configActionSavePerformance,
1456 Data: map[string]string{
1457 "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1458 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1459 },
1460 })
1461
1462 require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1463 assert.False(t, service.calledSave)
1464}