v0.29.0 21b3150

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

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