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, cancel := context.WithCancel(context.Background())
408 defer cancel()
409 require.NoError(t, app.RunWithContext(ctx))
410 require.NotNil(t, dom.handler)
411
412 app.Close()
413 assert.True(t, dom.released)
414 assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
415 require.NotPanics(t, func() {
416 dom.handler(DOMAction{Action: historyActionClear})
417 })
418}
419
420func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
421 bindErr := errors.New("bind failed")
422 dom := &failingActionDOM{bindErr: bindErr}
423 app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
424
425 err := app.RunWithContext(context.Background())
426 require.ErrorIs(t, err, bindErr)
427 assert.Nil(t, app.actionQueue)
428 assert.Nil(t, app.actionErrorQueue)
429 assert.Nil(t, app.actionCtx)
430 assert.False(t, app.actionWorkerActive())
431 require.NotPanics(t, app.Close)
432}
433
434func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
435 dom := &recordingActionDOM{}
436 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
437 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
438
439 ctx, cancel := context.WithCancel(context.Background())
440 cancel()
441 err := app.RunWithContext(ctx)
442
443 require.ErrorContains(t, err, "systemview action worker unavailable")
444 assert.True(t, dom.released)
445 assert.Nil(t, app.actionQueue)
446 assert.Nil(t, app.actionErrorQueue)
447 assert.Nil(t, app.actionCtx)
448 assert.False(t, app.actionWorkerActive())
449}
450
451func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
452 t.Parallel()
453
454 history := &recordingHistoryService{err: errors.New("database unavailable")}
455 app := NewApp(Dependencies{
456 History: history,
457 LocationURI: "dumb://history",
458 })
459
460 require.NoError(t, app.LoadInitial(context.Background()))
461 assert.Equal(t, RouteHistory, app.CurrentRoute())
462 assert.True(t, history.called)
463 assert.Contains(t, app.renderedHTML, "Could not load this system view")
464 assert.Contains(t, app.renderedHTML, "database unavailable")
465 assert.Contains(t, app.renderedHTML, `role="alert"`)
466}
467
468func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
469 t.Parallel()
470
471 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{
472 URL: "https://example.com",
473 Title: "Example",
474 }}}
475 config := &recordingConfigService{current: dto.SystemviewConfigPayload{
476 Appearance: dto.WebUIAppearanceConfig{
477 ColorScheme: "prefer-light",
478 LightPalette: dto.ColorPalette{
479 Background: "#ffffff",
480 Surface: "#fafafa",
481 SurfaceVariant: "#eeeeee",
482 Text: "#111111",
483 Muted: "#666666",
484 Accent: "#0055ff",
485 Border: "#dddddd",
486 },
487 DarkPalette: dto.ColorPalette{
488 Background: "#111111",
489 Surface: "#1a1a1a",
490 SurfaceVariant: "#2a2a2a",
491 Text: "#f5f5f5",
492 Muted: "#a0a0a0",
493 Accent: "#66aaff",
494 Border: "#333333",
495 },
496 SansFont: "Inter",
497 SerifFont: "Georgia",
498 MonospaceFont: "JetBrains Mono",
499 DefaultFontSize: 16,
500 },
501 }}
502
503 app := NewApp(Dependencies{
504 Config: config,
505 History: history,
506 LocationURI: "dumb://history",
507 })
508
509 require.NoError(t, app.LoadInitial(context.Background()))
510 assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
511 assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
512 assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
513}
514
515func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
516 t.Parallel()
517
518 favorites := &recordingFavoritesService{
519 favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
520 folders: []*entity.Folder{{Name: "Read Later"}},
521 tags: []*entity.Tag{{Name: "Go"}},
522 }
523
524 app := NewApp(Dependencies{
525 Favorites: favorites,
526 LocationURI: "dumb://favorites",
527 })
528
529 require.NoError(t, app.LoadInitial(context.Background()))
530 assert.Equal(t, RouteFavorites, app.CurrentRoute())
531 assert.Len(t, app.favorites, 1)
532 assert.Len(t, app.folders, 1)
533 assert.Len(t, app.tags, 1)
534 assert.Contains(t, app.renderedHTML, "Favorites")
535 assert.Contains(t, app.renderedHTML, "Example")
536 assert.Contains(t, app.renderedHTML, "Read Later")
537 assert.Contains(t, app.renderedHTML, "Go")
538 assert.True(t, favorites.calledList)
539 assert.True(t, favorites.calledFolders)
540 assert.True(t, favorites.calledTags)
541
542 // Shell frame present, no full document wrapper.
543 assert.NotContains(t, app.renderedHTML, "<html")
544 assert.NotContains(t, app.renderedHTML, "<head")
545 assert.NotContains(t, app.renderedHTML, "<body")
546 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
547 assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
548 assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
549 assert.Contains(t, app.renderedHTML, `sv-section`)
550 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
551}
552
553func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
554 t.Parallel()
555
556 folderID := entity.FolderID(1)
557 shortcut := 3
558 favorites := &recordingFavoritesService{
559 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},
560 folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
561 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
562 }
563
564 app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"})
565
566 require.NoError(t, app.LoadInitial(context.Background()))
567 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
568 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
569 assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
570 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)
571 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)
572 assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
573 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
574 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
575 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
576 assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
577 assert.Contains(t, app.renderedHTML, `name="tags"`)
578 assert.Contains(t, app.renderedHTML, `value="7"`)
579 assert.Contains(t, app.renderedHTML, "Read Later")
580 assert.Contains(t, app.renderedHTML, "Go")
581 assert.Contains(t, app.renderedHTML, "Shortcut 3")
582}
583
584func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
585 dom := &recordingDOM{}
586 favorites := &recordingFavoritesService{
587 favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
588 folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
589 tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
590 }
591 app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
592
593 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
594 Action: favoriteActionCreate,
595 Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},
596 }))
597 require.NotNil(t, favorites.createdFavorite.FolderID)
598 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
599 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
600 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
601 assert.Contains(t, dom.HTML(), "Added favorite New")
602
603 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
604 Action: favoriteActionUpdate,
605 Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"},
606 }))
607 assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
608 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
609 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
610 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
611 assert.Contains(t, dom.HTML(), "Saved favorite Updated")
612
613 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
614 Action: tagActionAssign,
615 Data: map[string]string{"favoriteId": "42", "tagId": "7"},
616 }))
617 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
618 assert.Equal(t, int64(7), favorites.assignedTagID)
619
620 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
621 Action: favoriteActionFilterFolder,
622 Data: map[string]string{"folderId": "5"},
623 }))
624 require.NotNil(t, app.favoriteFolderFilter)
625 assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
626 assert.Nil(t, app.favoriteTagFilter)
627
628 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
629 Action: favoriteActionFilterTag,
630 Data: map[string]string{"tagId": "7"},
631 }))
632 require.NotNil(t, app.favoriteTagFilter)
633 assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
634 assert.Nil(t, app.favoriteFolderFilter)
635
636 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
637 Action: folderActionCreate,
638 Data: map[string]string{"name": "Reading", "icon": "📚"},
639 }))
640 assert.Equal(t, "Reading", favorites.createdFolder)
641 assert.Equal(t, "📚", favorites.createdFolderIcon)
642 assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
643
644 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
645 Action: favoriteActionDelete,
646 Data: map[string]string{"id": "42"},
647 }))
648 assert.Equal(t, int64(42), favorites.deletedFavorite)
649 assert.Contains(t, dom.HTML(), "Deleted favorite")
650}
651
652func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
653 t.Parallel()
654
655 config := dto.SystemviewConfigPayload{
656 EngineType: "webkit",
657 Appearance: dto.WebUIAppearanceConfig{
658 ColorScheme: "prefer-dark",
659 SansFont: "Inter",
660 SerifFont: "Georgia",
661 MonospaceFont: "JetBrains Mono",
662 DefaultFontSize: 16,
663 LightPalette: dto.ColorPalette{
664 Background: "#ffffff",
665 Surface: "#fafafa",
666 SurfaceVariant: "#eeeeee",
667 Text: "#111111",
668 Muted: "#666666",
669 Accent: "#0055ff",
670 Border: "#dddddd",
671 },
672 DarkPalette: dto.ColorPalette{
673 Background: "#111111",
674 Surface: "#1a1a1a",
675 SurfaceVariant: "#2a2a2a",
676 Text: "#f5f5f5",
677 Muted: "#a0a0a0",
678 Accent: "#66aaff",
679 Border: "#333333",
680 },
681 ExternalTheme: dto.WebUIExternalThemeConfig{
682 Enabled: true,
683 Provider: "noctalia",
684 Format: "dumber-json",
685 Path: "/tmp/noctalia-dumber.json",
686 },
687 },
688 Performance: dto.SystemviewPerformancePayload{
689 Profile: "balanced",
690 Custom: dto.SystemviewCustomPerformancePayload{
691 SkiaCPUThreads: 4,
692 SkiaGPUThreads: 2,
693 WebProcessMemoryMB: 512,
694 NetworkProcessMemoryMB: 128,
695 WebViewPoolPrewarm: 1,
696 },
697 Hardware: dto.SystemviewHardwarePayload{
698 CPUCores: 8,
699 CPUThreads: 16,
700 TotalRAMMB: 32768,
701 GPUVendor: "NVIDIA",
702 GPUName: "RTX 4060",
703 VRAMMB: 8192,
704 },
705 },
706 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
707 SearchShortcuts: map[string]dto.SearchShortcut{
708 "ddg": {
709 URL: "https://duckduckgo.com/?q=%s",
710 Description: "DuckDuckGo",
711 },
712 },
713 }
714 service := &recordingConfigService{
715 current: config,
716 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
717 {
718 Mode: "default",
719 DisplayName: "Default",
720 Bindings: []port.KeybindingEntry{
721 {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
722 {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
723 },
724 },
725 {
726 Mode: "search",
727 DisplayName: "Search",
728 Bindings: []port.KeybindingEntry{
729 {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
730 },
731 },
732 }},
733 }
734
735 app := NewApp(Dependencies{
736 Config: service,
737 LocationURI: "dumb://config",
738 })
739
740 require.NoError(t, app.LoadInitial(context.Background()))
741 assert.Equal(t, RouteConfig, app.CurrentRoute())
742 require.NotNil(t, app.config)
743 assert.Equal(t, "webkit", app.config.EngineType)
744 assert.Contains(t, app.renderedHTML, "webkit")
745 assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
746 assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
747 assert.Contains(t, app.renderedHTML, "prefer-dark")
748 assert.Contains(t, app.renderedHTML, "appearance.sans_font")
749 assert.Contains(t, app.renderedHTML, "Inter")
750 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
751 assert.Contains(t, app.renderedHTML, "#ffffff")
752 assert.Contains(t, app.renderedHTML, "appearance.external_theme.enabled")
753 assert.Contains(t, app.renderedHTML, "appearance.external_theme.provider")
754 assert.Contains(t, app.renderedHTML, "appearance.external_theme.format")
755 assert.Contains(t, app.renderedHTML, "appearance.external_theme.path")
756 assert.Contains(t, app.renderedHTML, "noctalia")
757 assert.Contains(t, app.renderedHTML, "dumber-json")
758 assert.Contains(t, app.renderedHTML, "/tmp/noctalia-dumber.json")
759 assert.Contains(t, app.renderedHTML, "performance.profile")
760 assert.Contains(t, app.renderedHTML, "balanced")
761 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
762 assert.Contains(t, app.renderedHTML, "4")
763 assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
764 assert.Contains(t, app.renderedHTML, "NVIDIA")
765 assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
766 assert.Contains(t, app.renderedHTML, "Default")
767 assert.Contains(t, app.renderedHTML, "Search")
768 assert.Contains(t, app.renderedHTML, "Open")
769 assert.Contains(t, app.renderedHTML, "ctrl+o")
770 assert.Contains(t, app.renderedHTML, "New tab")
771 assert.Contains(t, app.renderedHTML, "ctrl+t")
772 assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
773 assert.Contains(t, app.renderedHTML, "Find")
774 assert.Contains(t, app.renderedHTML, "open")
775 assert.Contains(t, app.renderedHTML, "default")
776 assert.Contains(t, app.renderedHTML, "custom")
777 assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
778 assert.True(t, service.calledCurrent)
779 assert.True(t, service.calledKeybindings)
780
781 // Shell frame present, no full document wrapper.
782 assert.NotContains(t, app.renderedHTML, "<html")
783 assert.NotContains(t, app.renderedHTML, "<head")
784 assert.NotContains(t, app.renderedHTML, "<body")
785 assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
786 assert.Contains(t, app.renderedHTML, `data-route="config"`)
787 assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
788 assert.Contains(t, app.renderedHTML, `sv-section`)
789 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
790}
791
792func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
793 t.Parallel()
794
795 service := &recordingConfigService{
796 current: testConfigPayload(),
797 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
798 Mode: "default",
799 DisplayName: "Default",
800 Bindings: []port.KeybindingEntry{{
801 Action: "toggle_history_systemview",
802 Description: "Toggle history",
803 Keys: []string{"ctrl+h"},
804 DefaultKeys: []string{"ctrl+h"},
805 }},
806 }}},
807 }
808 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
809
810 require.NoError(t, app.LoadInitial(context.Background()))
811 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
812 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
813 assert.Contains(t, app.renderedHTML, `name="external_theme_enabled"`)
814 assert.Contains(t, app.renderedHTML, `name="external_theme_provider"`)
815 assert.Contains(t, app.renderedHTML, `name="external_theme_format"`)
816 assert.Contains(t, app.renderedHTML, `name="external_theme_path"`)
817 assert.Contains(t, app.renderedHTML, `value="noctalia"`)
818 assert.Contains(t, app.renderedHTML, `value="dumber-json"`)
819 assert.Contains(t, app.renderedHTML, `/tmp/noctalia-dumber.json`)
820 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
821 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
822 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
823 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
824 assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
825 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
826 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
827 assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
828 assert.Contains(t, app.renderedHTML, "may require restarting")
829}
830
831func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
832 dom := &recordingDOM{}
833 service := &recordingConfigService{
834 current: testConfigPayload(),
835 defaultCfg: testDefaultConfigPayload(),
836 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
837 Mode: "default",
838 DisplayName: "Default",
839 Bindings: []port.KeybindingEntry{{
840 Action: "toggle_history_systemview",
841 Description: "Toggle history",
842 Keys: []string{"ctrl+h"},
843 DefaultKeys: []string{"ctrl+h"},
844 }},
845 }}},
846 setResp: port.SetKeybindingResponse{},
847 }
848 app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
849
850 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
851 Action: configActionSaveAppearance,
852 Data: map[string]string{
853 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
854 "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
855 "external_theme_enabled": "yes", "external_theme_provider": " noctalia ", "external_theme_format": " dumber-json ", "external_theme_path": " /tmp/noctalia-dumber.json ",
856 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
857 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
858 },
859 }))
860 assert.True(t, service.calledSave)
861 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
862 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
863 assert.True(t, service.savedConfig.Appearance.ExternalTheme.Enabled)
864 assert.Equal(t, "noctalia", service.savedConfig.Appearance.ExternalTheme.Provider)
865 assert.Equal(t, "dumber-json", service.savedConfig.Appearance.ExternalTheme.Format)
866 assert.Equal(t, "/tmp/noctalia-dumber.json", service.savedConfig.Appearance.ExternalTheme.Path)
867 assert.Contains(t, dom.HTML(), "Saved appearance settings")
868 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
869
870 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
871 Action: configActionCreateSearchShortcut,
872 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
873 }))
874 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
875 assert.Contains(t, dom.HTML(), "Created search shortcut g")
876
877 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
878 Action: configActionSavePerformance,
879 Data: map[string]string{
880 "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
881 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
882 },
883 }))
884 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
885 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
886 assert.Contains(t, dom.HTML(), "Saved performance settings")
887
888 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
889 Action: configActionSavePerformance,
890 Data: map[string]string{"profile": "default"},
891 }))
892 assert.Equal(t, "default", service.savedConfig.Performance.Profile)
893 assert.Contains(t, dom.HTML(), "Saved performance settings")
894
895 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
896 Action: configActionSetKeybinding,
897 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
898 }))
899 assert.True(t, service.calledSet)
900 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
901 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
902 assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
903
904 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
905 Action: configActionResetKeybinding,
906 Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
907 }))
908 assert.True(t, service.calledReset)
909 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
910 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
911 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
912 assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
913
914 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
915 assert.True(t, service.calledResetAll)
916 assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
917}
918
919func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
920 t.Parallel()
921
922 service := &recordingConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
923 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
924
925 err := app.handleConfigAction(context.Background(), DOMAction{
926 Action: configActionSaveSearch,
927 Data: map[string]string{"default_search_engine": "https://example.com/search"},
928 })
929 require.Error(t, err)
930 assert.False(t, service.calledSave)
931
932 err = app.handleConfigAction(context.Background(), DOMAction{
933 Action: configActionCreateSearchShortcut,
934 Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
935 })
936 require.Error(t, err)
937 assert.False(t, service.calledSave)
938}
939
940func TestAppLoadInitialConfigRouteUsesResolvedAppearanceOnlyForShellTheme(t *testing.T) {
941 t.Parallel()
942
943 cfg := testConfigPayload()
944 resolved := cfg.Appearance
945 resolved.DarkPalette.Background = "#222222"
946 resolved.DarkPalette.SurfaceVariant = "#444444"
947 cfg.ResolvedAppearance = &resolved
948 service := &recordingConfigService{
949 current: cfg,
950 keybindings: port.KeybindingsConfig{},
951 }
952
953 app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
954
955 require.NoError(t, app.LoadInitial(context.Background()))
956 assert.Contains(t, app.renderedHTML, `--sv-background: #222222;`)
957 assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #444444;`)
958 assert.Contains(t, app.renderedHTML, `value="#111111"`)
959 assert.Contains(t, app.renderedHTML, `value="#2a2a2a"`)
960}
961
962func testConfigPayload() dto.SystemviewConfigPayload {
963 return dto.SystemviewConfigPayload{
964 EngineType: "webkit",
965 Appearance: dto.WebUIAppearanceConfig{
966 ColorScheme: "prefer-dark",
967 SansFont: "Inter",
968 SerifFont: "Georgia",
969 MonospaceFont: "JetBrains Mono",
970 DefaultFontSize: 16,
971 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
972 DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
973 ExternalTheme: dto.WebUIExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "dumber-json", Path: "/tmp/noctalia-dumber.json"},
974 },
975 DefaultUIScale: 1,
976 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
977 SearchShortcuts: map[string]dto.SearchShortcut{
978 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
979 },
980 Performance: dto.SystemviewPerformancePayload{
981 Profile: "default",
982 Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
983 Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
984 },
985 }
986}
987
988func testDefaultConfigPayload() dto.SystemviewConfigPayload {
989 cfg := testConfigPayload()
990 cfg.Appearance.DefaultFontSize = 14
991 cfg.DefaultUIScale = 1
992 return cfg
993}
994
995// Recording fixture to capture DOM mounts for stateful render assertions.
996type recordingDOM struct {
997 mu sync.Mutex
998 mounted bool
999 html string
1000 appendedHTML string
1001 mounts chan string
1002}
1003
1004func (d *recordingDOM) Mount(markup string) error {
1005 d.mu.Lock()
1006 d.mounted = true
1007 d.html = markup
1008 mounts := d.mounts
1009 d.mu.Unlock()
1010 if mounts != nil {
1011 select {
1012 case mounts <- markup:
1013 default:
1014 }
1015 }
1016 return nil
1017}
1018
1019func (d *recordingDOM) Mounted() bool {
1020 d.mu.Lock()
1021 defer d.mu.Unlock()
1022 return d.mounted
1023}
1024
1025func (d *recordingDOM) HTML() string {
1026 d.mu.Lock()
1027 defer d.mu.Unlock()
1028 return d.html
1029}
1030
1031func (d *recordingDOM) AppendedHTML() string {
1032 d.mu.Lock()
1033 defer d.mu.Unlock()
1034 return d.appendedHTML
1035}
1036
1037func receiveMount(t *testing.T, mounts <-chan string) string {
1038 t.Helper()
1039 select {
1040 case html := <-mounts:
1041 return html
1042 case <-time.After(time.Second):
1043 t.Fatal("timed out waiting for DOM mount")
1044 return ""
1045 }
1046}
1047
1048func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
1049 t.Helper()
1050 select {
1051 case html := <-mounts:
1052 t.Fatalf("unexpected DOM mount: %s", html)
1053 case <-time.After(wait):
1054 }
1055}
1056
1057type recordingActionDOM struct {
1058 recordingDOM
1059 handler DOMActionHandler
1060 released bool
1061}
1062
1063func (d *recordingActionDOM) BindActions(handler DOMActionHandler) error {
1064 d.handler = handler
1065 return nil
1066}
1067
1068func (d *recordingActionDOM) AppendHistoryTimeline(markup string) error {
1069 d.mu.Lock()
1070 d.appendedHTML += markup
1071 d.mu.Unlock()
1072 return nil
1073}
1074
1075func (d *recordingActionDOM) Release() {
1076 d.released = true
1077}
1078
1079type failingActionDOM struct {
1080 recordingDOM
1081 bindErr error
1082}
1083
1084func (d *failingActionDOM) BindActions(DOMActionHandler) error {
1085 return d.bindErr
1086}
1087
1088// Recording fixture to capture history state for stateful render assertions.
1089type recordingHistoryService struct {
1090 called bool
1091 limit int
1092 offset int
1093 entries []*entity.HistoryEntry
1094 err error
1095 domainCalled bool
1096 domain string
1097
1098 searchCalled bool
1099 query string
1100 searchLimit int
1101 searchEntries []*entity.HistoryEntry
1102
1103 deletedEntryID int64
1104 deletedRangeID string
1105 deletedDomain string
1106 domainStats []*entity.DomainStat
1107 stats *entity.HistoryStats
1108 statsCalled bool
1109 window *entity.HistoryWindow
1110 windowBefore time.Time
1111 windowBeforeID int64
1112 windowDomain string
1113 analyticsCalled bool
1114
1115 timelineStarted chan struct{}
1116 releaseTimeline chan struct{}
1117 startOnce sync.Once
1118}
1119
1120func (s *recordingHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
1121 s.called = true
1122 s.limit = limit
1123 s.offset = offset
1124 if s.timelineStarted != nil {
1125 s.startOnce.Do(func() { close(s.timelineStarted) })
1126 }
1127 if s.releaseTimeline != nil {
1128 <-s.releaseTimeline
1129 }
1130 return s.entries, s.err
1131}
1132
1133func (s *recordingHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
1134 s.domainCalled = true
1135 s.domain = domain
1136 s.limit = limit
1137 s.offset = offset
1138 return s.entries, s.err
1139}
1140
1141func (s *recordingHistoryService) TimelineWindow(_ context.Context, before time.Time, beforeID int64, domain string) (*entity.HistoryWindow, error) {
1142 s.called = true
1143 s.windowBefore = before
1144 s.windowBeforeID = beforeID
1145 s.windowDomain = domain
1146 if s.timelineStarted != nil {
1147 s.startOnce.Do(func() { close(s.timelineStarted) })
1148 }
1149 if s.releaseTimeline != nil {
1150 <-s.releaseTimeline
1151 }
1152 if s.err != nil {
1153 return nil, s.err
1154 }
1155 if s.window != nil {
1156 return s.window, nil
1157 }
1158 return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil
1159}
1160
1161func (s *recordingHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
1162 s.searchCalled = true
1163 s.query = query
1164 s.searchLimit = limit
1165 if s.searchEntries != nil {
1166 return s.searchEntries, nil
1167 }
1168 return s.entries, nil
1169}
1170
1171func (s *recordingHistoryService) DeleteEntry(_ context.Context, id int64) error {
1172 s.deletedEntryID = id
1173 return nil
1174}
1175
1176func (s *recordingHistoryService) DeleteRange(_ context.Context, rangeID string) error {
1177 s.deletedRangeID = rangeID
1178 return nil
1179}
1180
1181func (s *recordingHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1182 s.statsCalled = true
1183 return s.stats, nil
1184}
1185
1186func (s *recordingHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1187 s.analyticsCalled = true
1188 return nil, nil
1189}
1190
1191func (s *recordingHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
1192 return s.domainStats, nil
1193}
1194
1195func (s *recordingHistoryService) DeleteDomain(_ context.Context, domain string) error {
1196 s.deletedDomain = domain
1197 return nil
1198}
1199
1200// Recording fixture to capture favorites state for stateful render assertions.
1201type recordingFavoritesService struct {
1202 calledList bool
1203 calledFolders bool
1204 calledTags bool
1205 favorites []*entity.Favorite
1206 folders []*entity.Folder
1207 tags []*entity.Tag
1208
1209 createdFavorite dto.FavoriteCreateInput
1210 updatedFavorite dto.FavoriteUpdateInput
1211 deletedFavorite int64
1212 createdFolder string
1213 createdFolderIcon string
1214 updatedFolderID int64
1215 deletedFolderID int64
1216 createdTag string
1217 updatedTagID int64
1218 deletedTagID int64
1219 assignedFavoriteID int64
1220 assignedTagID int64
1221 removedFavoriteID int64
1222 removedTagID int64
1223}
1224
1225func (s *recordingFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
1226 s.calledList = true
1227 return s.favorites, nil
1228}
1229
1230func (s *recordingFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1231 s.calledFolders = true
1232 return s.folders, nil
1233}
1234
1235func (s *recordingFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1236 s.calledTags = true
1237 return s.tags, nil
1238}
1239
1240func (s *recordingFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1241 s.createdFavorite = input
1242 return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1243}
1244
1245func (s *recordingFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1246 s.updatedFavorite = input
1247 return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
1248}
1249
1250func (s *recordingFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
1251 s.deletedFavorite = id
1252 return nil
1253}
1254
1255func (s *recordingFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1256
1257func (s *recordingFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
1258
1259func (s *recordingFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
1260 s.createdFolder = name
1261 s.createdFolderIcon = icon
1262 return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
1263}
1264
1265func (s *recordingFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
1266 s.updatedFolderID = id
1267 return nil
1268}
1269
1270func (s *recordingFavoritesService) DeleteFolder(_ context.Context, id int64) error {
1271 s.deletedFolderID = id
1272 return nil
1273}
1274
1275func (s *recordingFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1276 s.createdTag = name
1277 return &entity.Tag{ID: 55, Name: name, Color: color}, nil
1278}
1279
1280func (s *recordingFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error {
1281 s.updatedTagID = id
1282 return nil
1283}
1284
1285func (s *recordingFavoritesService) DeleteTag(_ context.Context, id int64) error {
1286 s.deletedTagID = id
1287 return nil
1288}
1289
1290func (s *recordingFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error {
1291 s.assignedFavoriteID = favoriteID
1292 s.assignedTagID = tagID
1293 return nil
1294}
1295
1296func (s *recordingFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error {
1297 s.removedFavoriteID = favoriteID
1298 s.removedTagID = tagID
1299 return nil
1300}
1301
1302// Recording fixture to capture config state for stateful render assertions.
1303type recordingConfigService struct {
1304 calledCurrent bool
1305 calledDefault bool
1306 calledSave bool
1307 calledKeybindings bool
1308 calledSet bool
1309 calledReset bool
1310 calledResetAll bool
1311
1312 current dto.SystemviewConfigPayload
1313 defaultCfg dto.SystemviewConfigPayload
1314 keybindings port.KeybindingsConfig
1315 savedConfig dto.WebUIConfig
1316 setReq port.SetKeybindingRequest
1317 setResp port.SetKeybindingResponse
1318 resetReq port.ResetKeybindingRequest
1319}
1320
1321func (s *recordingConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
1322 s.calledCurrent = true
1323 return s.current, nil
1324}
1325
1326func (s *recordingConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
1327 s.calledDefault = true
1328 return s.defaultCfg, nil
1329}
1330
1331func (s *recordingConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
1332 s.calledSave = true
1333 s.savedConfig = cfg
1334 s.current.Appearance = cfg.Appearance
1335 s.current.DefaultUIScale = cfg.DefaultUIScale
1336 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
1337 s.current.SearchShortcuts = cfg.SearchShortcuts
1338 s.current.Performance.Profile = cfg.Performance.Profile
1339 s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
1340 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
1341 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
1342 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
1343 NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
1344 WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
1345 }
1346 return nil
1347}
1348
1349func (s *recordingConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
1350 s.calledKeybindings = true
1351 return s.keybindings, nil
1352}
1353
1354func (s *recordingConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
1355 s.calledSet = true
1356 s.setReq = req
1357 return s.setResp, nil
1358}
1359
1360func (s *recordingConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
1361 s.calledReset = true
1362 s.resetReq = req
1363 return nil
1364}
1365
1366func (s *recordingConfigService) ResetAllKeybindings(context.Context) error {
1367 s.calledResetAll = true
1368 return nil
1369}
1370
1371func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
1372 app := NewApp(Dependencies{Favorites: &recordingFavoritesService{}})
1373
1374 err := app.handleFavoriteAction(context.Background(), DOMAction{
1375 Action: favoriteActionCreate,
1376 Data: map[string]string{"url": " ", "title": "Blank"},
1377 })
1378 require.Error(t, err)
1379 assert.Contains(t, err.Error(), "favorite URL is required")
1380
1381 err = app.handleFavoriteAction(context.Background(), DOMAction{
1382 Action: favoriteActionCreate,
1383 Data: map[string]string{"url": "example.com", "title": "Relative"},
1384 })
1385 require.Error(t, err)
1386 assert.Contains(t, err.Error(), "favorite URL must be absolute")
1387
1388 err = app.handleFavoriteAction(context.Background(), DOMAction{
1389 Action: favoriteActionCreate,
1390 Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1391 })
1392 require.Error(t, err)
1393 assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1394}
1395
1396func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1397 favorites := &recordingFavoritesService{}
1398 app := NewApp(Dependencies{Favorites: favorites})
1399
1400 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1401 Action: favoriteActionCreate,
1402 Data: map[string]string{"url": "dumb:history", "title": "History"},
1403 }))
1404 assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1405
1406 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1407 Action: favoriteActionCreate,
1408 Data: map[string]string{"url": "dumb://config", "title": "Config"},
1409 }))
1410 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1411}
1412
1413func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1414 favorites := &recordingFavoritesService{}
1415 app := NewApp(Dependencies{Favorites: favorites})
1416
1417 err := app.handleFavoriteAction(context.Background(), DOMAction{
1418 Action: favoriteActionCreate,
1419 Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1420 })
1421
1422 require.Error(t, err)
1423 assert.Contains(t, err.Error(), "known systemview route")
1424}
1425
1426func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1427 _, err := parsePositiveInt64("abc", "tag id")
1428 require.Error(t, err)
1429 assert.Contains(t, err.Error(), "invalid tag id")
1430 assert.Contains(t, err.Error(), "invalid syntax")
1431
1432 _, err = parsePositiveInt64("0", "tag id")
1433 require.Error(t, err)
1434 assert.Contains(t, err.Error(), "tag id must be a positive integer")
1435}
1436
1437func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1438 favorites := &recordingFavoritesService{}
1439 app := NewApp(Dependencies{Favorites: favorites})
1440
1441 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1442 Action: tagActionAssign,
1443 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1444 }))
1445 assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1446 assert.Equal(t, int64(7), favorites.assignedTagID)
1447
1448 require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1449 Action: tagActionRemove,
1450 Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1451 }))
1452 assert.Equal(t, int64(42), favorites.removedFavoriteID)
1453 assert.Equal(t, int64(7), favorites.removedTagID)
1454}
1455
1456func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1457 dom := &recordingDOM{}
1458 history := &recordingHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1459 app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1460 app.historyDomainFilter = "example.com"
1461
1462 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1463 Action: historyActionSearch,
1464 Data: map[string]string{"query": "search"},
1465 }))
1466
1467 assert.Equal(t, "search", app.historyQuery)
1468 assert.Empty(t, app.historyDomainFilter)
1469 assert.True(t, history.searchCalled)
1470 assert.False(t, history.domainCalled)
1471}
1472
1473func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1474 app := NewApp(Dependencies{DOM: &recordingDOM{}, LocationURI: "dumb://history"})
1475
1476 var ctx context.Context
1477 err := app.RunWithContext(ctx)
1478
1479 require.ErrorContains(t, err, "context is nil")
1480}
1481
1482func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1483 history := &recordingHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1484 app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1485
1486 err := app.RunWithContext(context.Background())
1487
1488 require.ErrorContains(t, err, "DOM not configured")
1489 assert.False(t, history.called)
1490}
1491
1492func TestHandleActionsRejectUnknownAction(t *testing.T) {
1493 app := NewApp(Dependencies{
1494 History: &recordingHistoryService{},
1495 Favorites: &recordingFavoritesService{},
1496 Config: &recordingConfigService{current: testConfigPayload()},
1497 })
1498
1499 require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1500 require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1501 require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1502}
1503
1504func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1505 service := &recordingConfigService{current: testConfigPayload()}
1506 app := NewApp(Dependencies{Config: service})
1507
1508 err := app.handleConfigAction(context.Background(), DOMAction{
1509 Action: configActionSavePerformance,
1510 Data: map[string]string{
1511 "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1512 "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1513 },
1514 })
1515
1516 require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1517 assert.False(t, service.calledSave)
1518}