main 5041353

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

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