v0.30.0 eb3068f

app_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close